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 devKeep 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:
| Option | Type | Typical | Why it matters |
|---|---|---|---|
exitOnCtrlC | boolean | true (default) | Auto-exits on Ctrl+C. Set false only if you need custom exit handling. |
useAlternateScreen | boolean | true | Keeps UI isolated (no log spam in your shell history). |
useMouse | boolean | true | Enables click/drag/scroll interactions and selection. |
useConsole | boolean | true | Makes logs visible even in alternate screen mode. |
targetFps | number | 30 | Sets 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()