TerminalSession — RAII Lifecycle
TerminalSession is a one-call entry point that flips every terminal
mode your application needs (raw mode, alternate screen, mouse capture,
bracketed paste, focus events, kitty keyboard) and guarantees — via
Drop — that every one of them is turned off again when the program
exits, panics, or is killed by a signal it has registered for.
The contract is deliberately blunt: acquire one, use it, drop it.
There is no open/close/reopen state machine to reason about. The
Drop impl undoes the modes in reverse order, restores the cursor, and
takes the scrollback back to where it was.
This page documents the knobs on SessionOptions, the invariants the
guard upholds, and what happens when things go wrong. For the reasoning
that led to this shape, see ADR-003.
Motivation
Terminals are global, shared, stateful hardware abstractions — they do
not bounce back after a crash. A panic inside a widget that left CSI ?1049h applied will dump the user back at a shell with no echo, no
cooked newlines, and a mysterious cursor on row 47. The historical
answer — “remember to call cleanup()” — does not survive contact with
panics, signals, or early returns.
RAII flips that responsibility: the guard is the cleanup. As long as
the guard’s Drop runs (which Rust guarantees outside panic = "abort"
and std::process::exit), the terminal is restored. All the rest of
ftui-core assumes this and does not redundantly track mode state.
Mental model
SessionOptions ─┐
▼
TerminalSession::new()
│ enter raw mode
│ push panic hook
│ enable alt-screen ────┐
│ enable mouse SGR ────┤ tracked with
│ enable paste ─────┤ _enabled flags
│ enable focus ──────┤
│ enable kitty-kb ──────┘
▼
... application runs ...
│
▼
Drop:
disable kitty-kb (if set)
disable focus (if set)
disable paste (if set)
disable mouse (if set)
disable alt-screen (if set)
leave raw mode
remove panic hookThe guard records a boolean *_enabled flag for each optional mode
after the enabling escape sequence has actually been written, so
partial failures (e.g. mouse enable rejected by the terminal) still
leave the cleanup sound.
SessionOptions
use ftui_core::terminal_session::{SessionOptions, TerminalSession};
let session = TerminalSession::new(SessionOptions {
alternate_screen: true, // CSI ? 1049 h — preserves scrollback
mouse_capture: true, // CSI ? 1000;1002;1006 h — SGR mouse
bracketed_paste: true, // CSI ? 2004 h — wrap pastes in markers
focus_events: true, // CSI ? 1004 h — FocusIn / FocusOut
kitty_keyboard: false, // CSI > 15 u — release + repeat (opt-in)
intercept_signals: true, // install SIGINT/SIGTERM/SIGHUP handler
})?;| Field | ANSI | When to enable |
|---|---|---|
alternate_screen | CSI ?1049h | Full-screen apps that want scrollback restored on exit. Leave false for inline mode. |
mouse_capture | CSI ?1000;1002;1006h (plus mux-safe variant) | Any app that wants clicks, drags, or wheel events. SGR supports coordinates > 223. |
bracketed_paste | CSI ?2004h | Apps that need to distinguish typed from pasted text (e.g. a REPL). |
focus_events | CSI ?1004h | Apps that want to pause animations when the terminal loses focus. |
kitty_keyboard | CSI >15u | Apps that need key-release and key-repeat semantics (code editors). Opt-in, capability-gated. |
intercept_signals | — | Installs a signal handler that cooperates with shutdown_signal(). Default true. |
All requested modes are sanitized against the detected
TerminalCapabilities before any escape sequence
is written — asking for kitty-keyboard inside tmux silently downgrades
to “off” rather than emitting a sequence the mux will mangle.
What gets restored
On Drop, TerminalSession writes the disabling counterpart of every
sequence it successfully emitted, in reverse order. Concretely, a
session that enabled all five optional modes performs:
Exit kitty keyboard
CSI < u (pop flags).
Disable focus events
CSI ?1004l.
Disable bracketed paste
CSI ?2004l.
Disable mouse capture
CSI ?1006;1002;1000l (reverse order of enable).
Leave alt-screen
CSI ?1049l — this restores the original scrollback.
Leave raw mode
Cooked input, echo, and line buffering come back. Cursor is made visible.
Drop signal guard
The process signal mask returns to what it was before new().
The effect is idempotent: calling drop(session) explicitly or letting
the guard fall out of scope both produce the same terminal state.
Invariants
-
RAII cleanup runs on panic (unless the binary uses
panic = "abort").Dropis a library-level guarantee. -
Exclusive ownership. A process-wide
SessionLockprevents twoTerminalSession::new()calls from racing. The second call returnsio::Error. (new_for_testsintentionally bypasses the lock so headless tests can run in parallel.) -
Sanitize before apply. Requested modes are AND-ed with
TerminalCapabilitiesbefore emission (crates/ftui-core/src/terminal_session.rs:L360). Unsafe combos (kitty-keyboard inside tmux, focus events inside a mux) are silently cleared. -
No reference cycles into the terminal. The guard holds only
boolflags and an optionalSignalGuard. No thread ever observes a partially-initialized session.
Never construct a second session while one is alive. The exclusivity lock will return an error, but if you manage to defeat it (e.g. by leaking the first guard), cleanup order becomes undefined and the terminal may end up in raw mode with alt-screen active. Drop the first session before creating another.
Inline mode
If alternate_screen is left false, the session preserves the
scrollback region; your UI must cooperate by rendering through
InlineRenderer and picking an
InlineStrategy that matches the terminal’s scroll-region and
sync-output support.
use ftui_core::terminal_session::{SessionOptions, TerminalSession};
// Inline — no alt-screen, still want mouse + paste
let _session = TerminalSession::new(SessionOptions {
alternate_screen: false,
mouse_capture: true,
bracketed_paste: true,
..Default::default()
})?;Signals and panic interaction
When intercept_signals is true (the default), the session registers
for SIGINT, SIGTERM, and SIGHUP on Unix. Any delivered signal sets the
process-global shutdown_signal slot (first-pending wins) and arranges
for an orderly drop of the session. The runtime event loop checks this
slot every tick and breaks cleanly.
Panic hooks are installed for the same purpose: if a widget or the
runtime panics, the hook ensures the guard’s Drop still fires before
the default panic message is printed. Without this, the stack unwind
might race the terminal restore and print the backtrace into raw-mode
glyphs.
Testing hook
TerminalSession::new_for_tests(options) produces a headless guard
that does not write escape sequences, does not acquire the exclusivity
lock, and does not install a panic hook. Use it in tests that need a
plausible session shape without touching a real TTY — or, better, use
the ftui-harness program simulator which
bypasses the terminal entirely.
Cross-references
- Events & input — what flows through a live session.
- Screen modes — inline vs. alt-screen strategy.
- Capabilities — what gets sanitized before enabling.
- Frame API — what the runtime hands to your widgets
after
view(). - One-writer rule — why only a single writer may own the terminal stream.