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:

SignalDescription
SIGINTCtrl+C
SIGTERMTermination signal
SIGQUITCtrl+\
SIGABRTAbort signal
SIGHUPHangup (terminal closed)
SIGBREAKCtrl+Break on Windows
SIGPIPEBroken pipe
SIGBUSBus 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:

  1. Run reset in your terminal to restore it
  2. Add uncaughtException and unhandledRejection handlers to your app
  3. Make sure you call renderer.destroy() in all exit paths