Tutorial: Build a mini app

From hello world to a small multi-panel interactive TUI.

Goal and mental model

This tutorial builds a small terminal application with a sidebar, a main panel, and a command input. It is intentionally simple, but demonstrates the patterns you will reuse in real apps:

- a single renderer instance for the process
- a root UI shell (header/body/footer)
- keyboard routing (global shortcuts vs focused input)
- updating UI in place

If you prefer declarative composition, use constructs. If you prefer runtime object control, use renderables. The tutorial sticks to renderables first, then shows a construct version.

Project setup

Create a new project with the generator, then run the dev script. In this tutorial we assume your entry file is src/index.ts.

bun create @cascadetui/create-cascade my-app
cd my-app
bun install
bun run dev

Keep the built-in console enabled in development so you can see logs while the UI is running.

Renderer config cheats

These are the most common createCliRenderer knobs used in real apps:

OptionTypeTypicalWhy it matters
exitOnCtrlCbooleantrue (default)Auto-exits on Ctrl+C. Set false only if you need custom exit handling.
useAlternateScreenbooleantrueKeeps UI isolated (no log spam in your shell history).
useMousebooleantrueEnables click/drag/scroll interactions and selection.
useConsolebooleantrueMakes logs visible even in alternate screen mode.
targetFpsnumber30Sets animation/render loop target (use only if you need continuous rendering).

Create a renderer and a basic shell

Start by creating a renderer and a three-part layout: header, body, footer. The body is a row with a fixed sidebar and a flexible main panel.

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

const renderer = await createCliRenderer({
  useAlternateScreen: true,
  useMouse: true,
})

const header = new BoxRenderable(renderer, { height: 3, border: true, paddingLeft: 1, alignItems: "center" })
header.add(new TextRenderable(renderer, { content: "Mini App" }))

const footer = new BoxRenderable(renderer, { height: 3, border: true, paddingLeft: 1, alignItems: "center" })
const footerText = new TextRenderable(renderer, { content: "Ctrl+C to quit" })
footer.add(footerText)

const body = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: "row" })
const sidebar = new BoxRenderable(renderer, { width: 22, border: true, padding: 1 })
const main = new BoxRenderable(renderer, { flexGrow: 1, border: true, padding: 1, marginLeft: 1 })

sidebar.add(new TextRenderable(renderer, { content: "Commands" }))
main.add(new TextRenderable(renderer, { content: "Ready" }))

body.add(sidebar)
body.add(main)

renderer.root.add(header)
renderer.root.add(body)
renderer.root.add(footer)

This is the “shell” pattern: your app always renders the shell, and the shell swaps the inner main content based on state.

Add input, state, and updates

Next, add an input at the bottom (or inside the footer). When the user submits, update the main panel and append a line to a log area.

import { InputRenderable, TextRenderable, BoxRenderable } from "@cascadetui/core"

const log = new TextRenderable(renderer, { content: "" })
main.add(log)

const input = new InputRenderable(renderer, {
  placeholder: "Type a command",
  onSubmit: (value) => {
    log.content = (log.content ? log.content + "
" : "") + "> " + value
    footerText.content = "Last command: " + value
  },
})

footer.add(input)
input.focus()

In small apps, updating renderables in place is the simplest approach. In larger apps, keep state in a single store and render from state via constructs or framework bindings.

Global shortcuts and focus

Global key handlers are great for app-wide shortcuts. Keep them small and predictable (quit/help/debug). Route editing keys to the focused input.

renderer.on("key", (event) => {
  if (event.name === "?") {
    footerText.content = "Help: type anything and press Enter"
  }
  if (event.name === "escape") {
    input.focus()
  }
})

Cleanup

Always call renderer.destroy() to restore terminal state. If you created many renderables dynamically, keep a single destroy path and call destroyRecursively() on your root nodes when switching screens.

renderer.onDestroy(() => {
  // stop timers, close sockets, flush logs
})

renderer.destroy()