Skip to content

Terminal Lifecycle

Silvery handles terminal lifecycle events (suspend/resume, interrupt) automatically. When stdin is in raw mode, Ctrl+Z and Ctrl+C don't generate OS signals (SIGTSTP/SIGINT). Silvery intercepts the raw bytes and manages the full terminal state save/restore cycle.

Quick Start

Both run() (Layer 2) and createApp().run() (Layer 3) enable lifecycle handling by default:

tsx
// Ctrl+Z suspends, Ctrl+C exits — no config needed
await run(<App />)

Options

OptionTypeDefaultDescription
suspendOnCtrlZbooleantrueHandle Ctrl+Z by suspending the process
exitOnCtrlCbooleantrueHandle Ctrl+C by exiting
onSuspend() => boolean | voidCalled before suspend. Return false to prevent
onResume() => voidCalled after resume
onInterrupt() => boolean | voidCalled on Ctrl+C. Return false to prevent exit

Suspend/Resume Flow

When the user presses Ctrl+Z:

  1. onSuspend hook is called (if provided). If it returns false, suspend is cancelled.
  2. Terminal state is captured (protocols, modes, cursor visibility).
  3. Terminal is restored to normal: raw mode off, alt screen exit, cursor shown, all protocols disabled.
  4. SIGTSTP is sent to the process (it actually suspends).
  5. On SIGCONT (resume): terminal state is restored, screen is cleared, synthetic resize triggers full redraw.
  6. onResume hook is called.

Protocols Saved/Restored

  • Raw mode (stdin)
  • Alternate screen buffer (DEC mode 1049)
  • Cursor visibility (DEC mode 25)
  • Mouse tracking (modes 1000, 1002, 1006)
  • Kitty keyboard protocol (with original flags)
  • Bracketed paste (DEC mode 2004)
  • SGR attributes (reset on suspend, not restored — redraw handles this)

Interrupt Flow

When the user presses Ctrl+C:

  1. onInterrupt hook is called (if provided). If it returns false, exit is cancelled.
  2. The app's exit function is called, which triggers normal cleanup (unmount, restore terminal, exit).

Hooks Example

tsx
await run(<App />, {
  onSuspend: () => {
    // Pause background timers before suspend
    pauseTimers()
  },
  onResume: () => {
    // Refresh data that may have changed while suspended
    refetchData()
  },
  onInterrupt: () => {
    if (hasUnsavedChanges) {
      showConfirmDialog()
      return false // Prevent exit
    }
  },
})

Low-Level API

For custom lifecycle management (e.g., framework integrations), the building blocks are exported from silvery/runtime:

tsx
import {
  captureTerminalState,
  restoreTerminalState,
  resumeTerminalState,
  performSuspend,
  CTRL_C,
  CTRL_Z,
  type TerminalLifecycleOptions,
  type TerminalState,
} from "@silvery/term/runtime"

// Capture current state
const state = captureTerminalState({
  alternateScreen: true,
  mouse: true,
  kitty: true,
  kittyFlags: 3,
  bracketedPaste: true,
})

// Manual suspend/resume
restoreTerminalState(stdout, stdin) // Before suspend
// ... process suspends ...
resumeTerminalState(state, stdout, stdin) // After resume

// Or use the all-in-one helper
performSuspend(state, stdout, stdin, () => {
  console.log("Resumed!")
})

TerminalState

typescript
interface TerminalState {
  rawMode: boolean
  alternateScreen: boolean
  cursorHidden: boolean
  mouseEnabled: boolean
  kittyEnabled: boolean
  kittyFlags: number
  bracketedPaste: boolean
}

Terminal Compatibility

Suspend/resume works on all terminals that support the standard escape sequences. Tested with:

  • Ghostty
  • Kitty
  • WezTerm
  • iTerm2
  • Alacritty
  • macOS Terminal.app
  • tmux / screen (nested sessions work correctly)

Implementation Notes

  • writeSync is used for restore sequences during signal handlers (async writes may not complete before suspend)
  • The SIGCONT handler is registered as a one-time handler BEFORE sending SIGTSTP
  • Screen clear (CSI 2 J + CSI H) is always included on resume to ensure a clean slate
  • Synthetic resize (stdout.emit("resize")) triggers a full redraw after resume
  • The runtime's invalidate() is called to force a fresh render (not incremental diff)

Released under the MIT License.