Lifecycle and cleanup
OpenTUI gives you control over terminal cleanup. Call renderer.destroy() when you shut down. It restores the terminal to its original state, and it frees resources.
Why you must handle cleanup
OpenTUI does not automatically clean up on process.exit or unhandled errors. This design gives you more control over shutdown behavior:
- You may want to handle errors and keep running
- You may use effect systems, like Effect.ts, with their own shutdown handling
- You may need custom cleanup order or additional shutdown logic
Use renderer.destroy()
Call destroy() when your app exits:
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
// ... your application code ...
// Clean shutdown
renderer.destroy()
For reliable cleanup, keep renderer teardown in the same control flow as your app startup:
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
try {
// ... your application code ...
} finally {
renderer.destroy()
}
Signal handling
OpenTUI listens for common exit signals and calls destroy() when it receives them. By default, it handles these signals:
| Signal | Description |
|---|---|
SIGINT | Ctrl+C |
SIGTERM | Termination signal |
SIGQUIT | Ctrl+\ |
SIGABRT | Abort signal |
SIGHUP | Hangup (terminal closed) |
SIGBREAK | Ctrl+Break on Windows |
SIGPIPE | Broken pipe |
SIGBUS | Bus error |
You can customize which signals trigger cleanup:
// Only handle SIGINT and SIGTERM
const renderer = await createCliRenderer({
exitSignals: ["SIGINT", "SIGTERM"],
})
// Disable all signal-based cleanup (handle it yourself)
const renderer = await createCliRenderer({
exitSignals: [],
})
Ctrl+C behavior
By default, Ctrl+C calls destroy(). If you want to handle Ctrl+C yourself, disable the internal handler and remove SIGINT from exitSignals:
const renderer = await createCliRenderer({
exitOnCtrlC: false,
exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGPIPE", "SIGBUS"],
})
renderer.keyInput.on("keypress", (key) => {
if (key.ctrl && key.name === "c") {
// Custom Ctrl+C handling
console.log("Ctrl+C pressed, but not exiting")
}
})
Destroy callback
Run custom logic when the renderer is destroyed:
const renderer = await createCliRenderer({
onDestroy: () => {
console.log("Renderer destroyed, performing additional cleanup...")
},
})
What destroy() cleans up
The destroy() method cleans up these resources:
- Removes the signal and process listeners that OpenTUI adds
- Clears timers and render loops
- Destroys all renderables in the tree
- Restores stdin raw mode
- Resets terminal state (cursor, alternate screen, etc.)
- Flushes pending split-footer captured output
- Frees native resources
Custom stream sessions
For socket, SSH, or pty sessions, give each connection its own renderer. Destroy it before closing the transport:
import { createCliRenderer, type CliRenderer } from "@opentui/core"
interface TerminalSession {
renderer: CliRenderer
closeTransport: () => void
}
async function startSession(
stdin: NodeJS.ReadStream,
stdout: NodeJS.WriteStream,
closeTransport: () => void,
): Promise<TerminalSession> {
const renderer = await createCliRenderer({
stdin,
stdout,
width: stdout.columns || 80,
height: stdout.rows || 24,
exitOnCtrlC: false,
exitSignals: [],
})
return { renderer, closeTransport }
}
async function closeSession(session: TerminalSession): Promise<void> {
session.renderer.destroy()
await new Promise<void>((resolve) => queueMicrotask(resolve))
session.closeTransport()
}
Use exitOnCtrlC: false and exitSignals: [] when a long-lived server process owns many renderer sessions. A stdin or stdout stream cannot be shared by active renderers; destroy() releases it.
Troubleshooting
If your terminal stays in a broken state after a crash:
- Run
resetin your terminal to restore it - Add
uncaughtExceptionandunhandledRejectionhandlers to your app - Make sure you call
renderer.destroy()in all exit paths