Skip to content

Terminal Widgets (require feature terminal) ​

toml
tui-lipan = { version = "*", features = ["terminal"] }

ManagedTerminal ​

PTY output reaches a retained live screen. Completed parser mutations select graphics-only, terminal-cell or invisible updates instead of rebuilding the component for every output event. The PTY reader pushes messages to the runner; quiet managed terminals do not impose frame-rate polling. Lifecycle, status and resize changes retain their normal component updates.

The recommended starting point: a complete PTY terminal with automatic lifecycle management. No manual wiring needed.

PropTypeDescription
configTerminalPtyConfigShell/cwd/env configuration
scrollbackusizeScrollback buffer size in lines (default: 2000)
initial_colsu16Initial columns (default: 120)
initial_rowsu16Initial rows (default: 24)
auto_startboolStart PTY on init (default: true)
placeholderOption<Arc<str>>Text shown before PTY is ready
forward_mouseboolForward mouse events to PTY (default: true)
scroll_wheelboolMouse wheel for scrollback (default: true)
scroll_wheel_multiplierOption<u16>Scrollback rows per wheel tick, overriding the app-wide multiplier (default: None)
link_activation_modsKeyModsModifiers required for link activation (default: KeyMods::CTRL; extras allowed)
link_hover_styleStyleSlotStyle for the link under a modified pointer (default: underline)
on_link_activateCallback<TerminalLinkEvent>Modified click on an OSC 8 link or detected URL
resize_debounceDurationTrailing-edge PTY/screen resize delay (default: 16ms; zero is immediate)
styleStyleTerminal content style
focusableboolAccept focus (default: true)
tab_stopboolInclude in sequential Tab traversal (default: true)
on_focus / on_blurCallback<()>Focus gained / lost
widthLengthWidth (default: Flex(1))
heightLengthHeight (default: Flex(1))
on_statusCallback<ManagedTerminalStatus>Status change callback
rust
use tui_lipan::prelude::*;

// Simple usage - starts shell in current directory
ManagedTerminal::new()
    .on_status(ctx.link().callback(Msg::TerminalStatus))

// Custom shell and working directory
ManagedTerminal::new()
    .config(
        TerminalPtyConfig::new("/bin/bash")
            .cwd("/home/user/projects")
            .env("MY_VAR", "value")
    )
    .scrollback(5000)
    .initial_size(120, 40)
    .resize_debounce(std::time::Duration::from_millis(16))
    .on_status(ctx.link().callback(Msg::TerminalStatus))

ManagedTerminal coalesces rapid viewport changes at the trailing edge of this delay so the PTY and screen emulator are resized once at the settled dimensions. Set it to Duration::ZERO to apply each resize immediately.

Status events (ManagedTerminalStatus):

VariantMeaning
StartingPTY is being initialized
ReadyPTY is ready and accepting input
Exited(i32)Shell exited with status code
Error(Arc<str>)Error occurred
rust
match status {
    ManagedTerminalStatus::Ready => ctx.state.terminal_ready = true,
    ManagedTerminalStatus::Exited(code) => { /* handle exit */ }
    ManagedTerminalStatus::Error(msg) => { /* handle error */ }
    _ => {}
}

Terminal (Low-Level) ​

The low-level terminal viewport widget. Use when you need custom PTY handling, multiple terminals, or specialized input routing.

When the child enables focus reporting with CSI ? 1004 h, on_input receives focus-in and focus-out events as keyboard or host-window focus changes. Enabling reporting while the terminal already holds focus sends one focus-in event, including when a live screen restores the mode during session replay. Replacing the live screen on a focused keyed widget also sends focus-in to the replacement child if it has reporting enabled. Recreating a callback for the same live screen does not repeat the report. Paint-only updates also synchronize these reports.

PropTypeDescription
snapshotTerminalRenderSnapshotCurrent screen snapshot
screenTerminalScreenHandleRead a live app-owned screen instead of a snapshot, so output repaints rather than rebuilds (below)
decorationsArc<[TerminalDecoration]>Overlays applied on top of a live screen's snapshot
show_cursorboolShow the hardware caret
cursor_shapeCaretShapeHardware caret shape
cursor_blinkingboolOverride blinking, including child requests on live screens
caret_colorColorHardware caret color via OSC 12 (terminal support required)
styleStyleContainer style
hover_styleStyleHover style
extend_hover_style / inherit_hover_styleStyle / ()Extend or inherit the hover theme role instead of replacing it
focus_styleStyleFocus chrome style
extend_focus_style / inherit_focus_styleStyle / ()Extend or inherit the focus theme role instead of replacing it
focusableboolAccept focus
tab_stopboolInclude in sequential Tab traversal (default: true)
on_focus / on_blurCallback<()>Focus gained / lost
scroll_wheelboolMouse wheel scrollback
scroll_wheel_multiplieru16Scrollback rows per wheel tick, overriding the app-wide multiplier
selection_styleStyleText selection style
extend_selection_style / inherit_selection_styleStyle / ()Extend or inherit the text-selection theme role instead of replacing it
selectionOption<TerminalSelection>Controlled selection in absolute retained-line coordinates
borderboolShow border
border_styleBorderStyleBorder style
paddingimpl Into<Padding>Padding
widthLengthWidth
heightLengthHeight
on_inputCallback<TerminalInputEvent>Keyboard/paste input from user
on_pasteCallback<TerminalPasteEvent>Optional text/image/file paste handler before PTY encoding
paste_shortcut_behaviorTerminalPasteShortcutBehaviorDirect Ctrl+V routing (Forward by default; Performable pastes text and forwards rich/non-text content)
on_resizeCallback<TerminalViewport>Viewport size changed; emitted synchronously during reconciliation
on_scroll_toCallback<usize>Scrollback offset changed
on_mouse_forwardCallback<Vec<u8>>Mouse event bytes for PTY
on_mouse_forward_eventCallback<ForwardedMouseEvent>Structured mouse report with terminal-local coordinates, modes, and encoded bytes
link_activation_modsKeyModsModifiers required for link activation (default: KeyMods::CTRL; extras allowed)
link_hover_styleStyleSlotStyle for the link under a modified pointer (default: underline)
on_link_activateCallback<TerminalLinkEvent>Modified click on an OSC 8 link or detected URL
on_selectionCallback<TerminalSelectionEvent>Selection and extracted text changed
on_keyKeyHandlerLow-level key handler

Calling selection(...) makes the selection controlled. Mouse clicks and drags still emit on_selection, but they do not transiently overwrite the rendered selection while the parent remains authoritative.

Use scrollbar_config to configure layout variant, gap, and thumb for the vertical scrollbar.

Terminal::on_resize runs synchronously when reconciliation observes a changed viewport, before the frame is drawn. The native runner may coalesce consecutive host resize events before the next reconciliation, so the callback is not a one-for-one notification of every host event; it reports the viewport that is actually reconciled.

Live screens vs snapshots ​

Terminal::snapshot puts the screen's contents in the element tree, so every chunk of PTY output produces a different element and the app must answer it with Update::full() — re-running view() and layout for the whole window on each chunk. In an app hosting several terminals, one pane streaming makes every other pane pay for it.

Terminal::screen avoids that by handing the widget the screen itself:

rust
// The app owns the screen and feeds it output.
let screen: Rc<RefCell<TerminalScreen>> = self.screen.clone();

Terminal::new()
    .screen(TerminalScreenHandle::new(screen))
    // Overlays still come from the view, since they depend on app state, not on the child program.
    .decorations(search_highlights)

The element no longer changes when the child program draws, so output becomes a repaint:

rust
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::PtyOutput(bytes) => {
            self.screen.borrow_mut().process_bytes(&bytes);
            Update::terminal_paint()
        }
        // Chrome outside the screen — a title from OSC 0/2, a tab's activity dot — still needs
        // `view()` to run.
        Msg::PtyTitle(title) => { self.title = title; Update::layout() }
    }
}

Update::terminal_paint() claims more than Update::paint(): that live terminal content is the only thing that looks different this frame. A frame that keeps the claim repaints only the viewport rows the emulator reports as damaged, so a spinner rewriting one character costs one row rather than the whole window. Return it wherever nothing but the screen moved, and paint() wherever something else did — an indicator, a badge, a border colour. The claim is checked rather than trusted: any generic paint, layout or full update anywhere in the same frame widens it back, and cases a row repaint cannot serve (several terminals moving at once, full damage from a resize or screen swap, a scrolled-back or bordered terminal, images, overlays, an inline surface) fall back to an ordinary paint on their own.

The runtime pulls each live screen's current snapshot immediately before every draw, and skips screens that have not moved. snapshot still wins when both are set, so a caller that needs to hand over a doctored snapshot can; use decorations to overlay a live screen instead.

Cursor visibility is the intersection of both sides: the child program's request from the screen, ANDed with show_cursor. A pane that has exited, or is wearing hint labels, sets show_cursor(false) and shows no caret regardless of what the child asked for.

Install on_link_activate to opt into terminal-style Ctrl-click links:

rust
Terminal::new()
    .screen(TerminalScreenHandle::new(screen))
    .on_link_activate(ctx.link().callback(Msg::OpenLink))

The default activation chord is Ctrl+left-click. link_activation_mods(...) changes the required modifiers; extra held modifiers are allowed. The callback receives a TerminalLinkEvent with the destination URI and visible viewport row/column.

Moving the pointer over an activatable link with those modifiers held applies link_hover_style to the complete link; the default underlines it. The link_hover_style, extend_link_hover_style, and inherit_link_hover_style builders customize that feedback. Modifier state arrives on pointer events, so pressing Ctrl over an already stationary link applies the style on the next pointer movement rather than on the press itself.

Explicit OSC 8 destinations win, including labels whose visible text is not a URL. Otherwise the widget uses the built-in URL hint scanner, which recognizes allowed-scheme http, https, and mailto text and rejoins soft-wrapped rows before matching. The framework reports the destination; the application decides whether and how to open it. tui_lipan::utils::open_url is the scheme-restricted system-opener helper.

A link press is resolved before terminal selection and PTY mouse forwarding. When its release lands on the same destination without crossing the drag threshold, the callback fires and neither half of the gesture reaches a mouse-tracking child. A drag cancels activation and remains consumed. A click without the required modifiers, or with no link under it, keeps the existing selection/forwarding behavior. An ancestor MouseRegion::capture_requires_mods(...) still takes precedence.


Terminal key routing policy ​

Configure how keys are ordered when a terminal pane has focus:

rust
App::new()
    .terminal_key_policy(TerminalKeyPolicy::AppCommandsThenTerminal)
    .mount(MyMuxApp)
    .run()
PolicyTypical useOrder (summary)
FrameworkFirst (default)Simple apps, backward-compatible behaviorFramework shortcuts → terminal clipboard preflight → terminal on_key / on_input → bubble
AppCommandsThenTerminalMultiplexer / terminal-host appsTerminal performable copy/paste preflight → app command chords/shortcuts → terminal forwarding → bubble → framework fallback
TerminalFirstPTY-first apps that still want optional app shortcutsTerminal preflight → terminal forwarding → app commands (if terminal did not consume) → bubble → framework fallback
TerminalOnlyFull passthrough; no app command or framework fallback while terminal is focusedTerminal preflight → terminal forwarding only

Performable preflight runs before app commands under mux-style policies so terminal copy/paste is never stolen:

  • Ctrl+C with a non-empty selection in the focused terminal copies to the clipboard instead of running an app shortcut on the same key. A selection in some other widget or terminal is ignored: the preflight declines, and the key continues through the policy, to an app command bound to it and otherwise to the terminal's child.
  • Ctrl+Shift+C / Ctrl+Shift+V paste paths run when the terminal can accept input.
  • A terminal using TerminalPasteShortcutBehavior::Performable consumes direct Ctrl+V when the clipboard contains plain text, but forwards the key to the child for files, images, and unknown non-text formats. This lets terminal hosts keep a text-paste shortcut without blocking a nested clipboard-aware TUI from reading richer formats itself.

Register mux lifecycle shortcuts with executable command bindings (CommandEntry::shortcut(...)) rather than framework keymap entries. Pair AppCommandsThenTerminal with KeyDispatchPolicy::AppCommandsFirst when app shortcuts should win over non-terminal widgets too.

See focus.md for non-terminal dispatch order and keybindings.md for Rust/file keymap precedence.


Key encoding ​

Once a key is routed to the terminal, key_event_to_bytes(key, modes) turns it into the bytes written to the child's stdin. modes is a TerminalKeyModes, which carries the modes the child has negotiated — see Input modes below.

Cursor, navigation, and function keys carry Ctrl/Shift as an xterm modifier parameter of 1 + shift + 2·alt + 4·ctrl — so Shift is 2, Ctrl is 5, Ctrl+Shift is 6, Ctrl+Alt is 7.

KeyUnmodifiedModified
Up / Down / Right / LeftCSI A / B / C / DCSI 1;<mod>A … D
Home / EndCSI H / CSI FCSI 1;<mod>H / CSI 1;<mod>F
Insert / DeleteCSI 2~ / CSI 3~CSI 2;<mod>~ / CSI 3;<mod>~
PageUp / PageDownCSI 5~ / CSI 6~CSI 5;<mod>~ / CSI 6;<mod>~
F1–F20CSI 11~ … CSI 34~CSI 11;<mod>~ … CSI 34;<mod>~
BackspaceDELESC DEL (with Ctrl)
Enter / Tab / EscCR / HT / ESCunchanged (legacy)

Three deliberate exceptions:

  • Alt on its own keeps the historical ESC-prefix encoding (Alt+Left is ESC followed by CSI D), matching xterm's metaSendsEscape. Combined with Ctrl or Shift it folds into the modifier parameter instead.
  • Shift+Insert, Shift+PageUp, and Shift+PageDown keep their unmodified bytes. A terminal emulator conventionally consumes these for paste and scrollback, but the Terminal widget forwards them (its scrollback runs on the wheel and on_scroll_to), and children do not recognize the parameterized form. Sending CSI 5;2~ would make Shift+PageUp a no-op rather than paging the child. Adding Ctrl lifts the exception.
  • Super-modified keys produce no bytes at all. Super has no representation in any of these encodings, and forwarding the unmodified key would type a character the user never pressed. key_event_to_bytes returns None and the chord bubbles to the app.

Ctrl+Backspace sends ESC DEL — readline's backward-kill-word, the same bytes as Alt+Backspace — so it deletes the previous word out of the box.

Char keys ​

Ctrl+<letter> maps to its C0 control code (Ctrl+A → 0x01); everything else is sent as UTF-8. Ctrl also maps the punctuation that has a control code:

ChordByteChordByte
Ctrl+Space, Ctrl+@, Ctrl+20x00Ctrl+^, Ctrl+60x1e
Ctrl+[, Ctrl+30x1bCtrl+_, Ctrl+/, Ctrl+70x1f
Ctrl+\, Ctrl+40x1cCtrl+?, Ctrl+80x7f
Ctrl+], Ctrl+50x1d

A Ctrl+<char> chord with no control code (Ctrl+1, Ctrl+;) has no legacy encoding: it returns None and stays available to the app unless the child has negotiated the Kitty keyboard protocol (below), which can carry it.

Note that in the legacy encoding Ctrl+Shift+C produces 0x03, identical to Ctrl+C — a control code has no shift bit. The Terminal widget never reaches that path, because the clipboard preflight consumes the chord first. Code calling key_event_to_bytes directly must do the same. Under the Kitty protocol the two are distinct (CSI 99;6u versus CSI 99;5u).

Kitty keyboard protocol ​

Chords like Ctrl+1, Ctrl+Enter, and Shift+Enter have no legacy terminal encoding. There is no byte sequence to send a shell for Ctrl+1, which is why pressing it in a plain shell does nothing anywhere. They become expressible only under the Kitty keyboard protocol, which a child turns on by pushing flags with CSI > <flags> u.

tui-lipan's own backend pushes DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES on startup (when the host terminal supports it), so a tui-lipan app running inside a Terminal widget negotiates the protocol automatically. When modes.kitty_keyboard.disambiguate_escape_codes is set, keys with no unambiguous legacy encoding switch to CSI <codepoint>;<mod> u:

ChordBytes
Ctrl+1 … Ctrl+9CSI 49;5u … CSI 57;5u
Ctrl+Enter / Shift+EnterCSI 13;5u / CSI 13;2u
Ctrl+TabCSI 9;5u
Ctrl+BackspaceCSI 127;5u
Ctrl+C / Ctrl+Shift+CCSI 99;5u / CSI 99;6u
EscCSI 27u

The codepoint is the key as engraved (lowercase, no shift applied); Shift lands in the modifier parameter. Text keys still arrive as text — Shift alone never promotes a key to the escape form — and the arrows, tilde keys, and function keys keep their unambiguous legacy sequences. Only Ctrl/Alt (or a chord that has no other encoding, like Esc) triggers CSI u.

Sending these to a child that has not negotiated the protocol would be wrong: a crossterm-based reader rejects the unknown sequence and discards it. So the encoder falls back to the legacy bytes above until the child asks, and a chord with no legacy form is simply dropped (None).

Input modes ​

TerminalKeyModes carries the modes that change what a key press or a paste must produce. TerminalRenderSnapshot publishes it, Terminal::snapshot picks it up automatically, and TerminalScreen::key_modes() exposes it for manual wiring.

FieldModeEffect
app_cursorDECCKM (CSI ? 1 h)Unmodified cursor keys are introduced by SS3 (ESC O A) instead of CSI (ESC [ A). Modified cursor keys and the tilde keys are unaffected.
bracketed_pasteCSI ? 2004 hencode_paste wraps pasted text in CSI 200~ / CSI 201~.
kitty_keyboardCSI > <flags> uThe negotiated Kitty keyboard protocol flags (a KittyKeyboardFlags).

These matter for correctness rather than polish. ncurses emits smkx (ESC [ ? 1 h ESC =) on startup and then matches arrows against terminfo's kcuu1=\EOA, so a child in application-cursor mode is waiting for ESC O A. A child that never enabled bracketed paste does not strip the wrapper, so pasting into it would insert the literal bytes ESC [ 200 ~. And a chord like Ctrl+1 cannot reach the child at all until it negotiates the Kitty protocol.

rust
// Manual wiring: take the modes from the screen, hand them to the encoder.
Msg::Key(key) => {
    let modes = ctx.state.screen.key_modes();
    ctx.state.pty.send_key(key, modes).ok();
    Update::none()
}

TerminalPty ​

PTY spawner and I/O bridge. Used internally by ManagedTerminal.

rust
use tui_lipan::prelude::*;

let config = TerminalPtyConfig::new("/bin/zsh")
    .cwd("/home/user")
    .term("xterm-256color");

The child environment ​

The child inherits this process's environment, then TerminalPtyConfig adjusts it: removals first, then explicit values, so a variable you both remove and set keeps the value you set.

TERM and COLORTERM are set for you, because this widget - not whatever launched the host - is the terminal the child talks to. For the same reason the multiplexer markers TMUX, TMUX_PANE, STY, and WINDOW are removed by default. A host started inside tmux would otherwise hand every pane child a $TMUX that describes a multiplexer the child is no longer talking to, and the child would act on it: wrapping its OSC 52 clipboard writes in tmux's DCS passthrough, which this widget's parser does not unwrap, and suppressing inline-image protocols that it assumes tmux will eat.

rust
let config = TerminalPtyConfig::new("/bin/zsh")
    .env_remove("SECRET_TOKEN")        // drop one more inherited variable
    .inherit_multiplexer_env(true);    // opt out: let the outer multiplexer handle it

Pass inherit_multiplexer_env(true) only when the host genuinely wants the enclosing multiplexer to own clipboard and image passthrough for its children.

rust

// Spawn the PTY with an event callback
let pty = TerminalPty::spawn(config, move |event| {
    match event {
        TerminalPtyEvent::Output(bytes) => link.send(Msg::Output(bytes)),
        TerminalPtyEvent::Exited(code) => link.send(Msg::Exited(code)),
        TerminalPtyEvent::Error(msg) => link.send(Msg::Error(msg)),
    }
})?;

// Send input to the PTY
pty.write(b"ls -la\r")?;

// Read the spawned child pid when available
let pid = pty.pid();

// Resize the PTY
pty.resize(cols, rows)?;

Event ordering: Exited is delivered after every Output the child produced, so it is safe to treat it as "this PTY is finished" and drop the handle — nothing the command wrote is still in flight. An explicit kill() is the exception: it stops the reader deliberately, so output in the buffer at that moment is discarded and the exit is reported as soon as the child is reaped.

PTY env defaults: TERM=xterm-256color, COLORTERM=truecolor (overridable via .env(...)).

TerminalPty::pid() returns the OS process id reported by the platform at spawn time, or None when unavailable.

TerminalPty::foreground_process_group_id() (Unix-only) returns the PTY's foreground process-group id (tcgetpgrp(3)) without exposing the underlying master file descriptor - a building block for a native foreground-process fallback when no shell integration/OSC 133 metadata is available.

Cloning a TerminalPty shares the same child process; dropping one clone only kills the child once every clone has been dropped, not on the first one.

Unix PTY handoff ​

On Unix, advanced terminal-host apps that move a live PTY between processes can call TerminalPty::handoff() before transferring the master fd over their own IPC channel:

rust
#[cfg(unix)]
let handoff = pty.handoff()?;

TerminalPtyHandoff contains the raw master fd and optional child pid. The token keeps the original PTY master alive until it is dropped; pass or duplicate the fd before dropping it. After handoff, the original TerminalPty stops forwarding output, rejects writes/resizes, and will not kill the child on drop. The receiving process is responsible for reading, writing, resizing, and terminating the adopted PTY.

handoff() waits for the local reader thread to stop before returning, so the receiving process can start reading without racing the old owner. Calling kill() after handoff is a no-op because ownership has moved to the receiver.

This is intentionally a low-level Unix-only escape hatch for multiplexers and session managers. Ordinary apps should keep using ManagedTerminal or TerminalPty::spawn.


TerminalScreen ​

VT100/VT220 screen emulator (wraps alacritty_terminal). Maintains scrollback buffer.

rust
// Create a screen with given dimensions and scrollback
let mut screen = TerminalScreen::new(rows, cols, scrollback_lines);
// Embedding terminals and multiplexers should identify themselves, not the outer terminal.
screen.set_terminal_identity("my-mux 1.4.0");

// Process PTY output
screen.process_bytes(&bytes);

// Drain terminal responses (e.g., device queries from TUI apps like fzf)
for response in screen.drain_responses() {
    pty.write(&response)?;
}

// Apply decoded OSC 52 stores according to the host application's clipboard policy.
for event in screen.drain_clipboard_events() {
    clipboard.store(event.target, &event.text)?;
}

// Get a render snapshot for the Terminal widget
let snapshot = screen.render_snapshot();

// Optional: resolve default/ANSI colors through an app-owned palette
screen.set_palette(TerminalColorPalette::new(fg, bg, ansi_16));

// Or preserve the host terminal's exact ANSI palette while choosing the pane background
let host = query_host_colors();
if let Some(colors) = host {
    screen.set_palette(TerminalColorPalette::from_host_colors(colors, pane_bg));
}

// Or derive semantic ANSI slots from a theme. Themes created with
// Theme::from_host_colors preserve the probed host ANSI slots automatically.
screen.set_palette(TerminalColorPalette::from_theme(&theme, pane_bg));

// Scrollback control
screen.set_scrollback(offset);    // 0 = live view, >0 = history
screen.scrollback_offset()        // Current offset
screen.total_scrollback_rows()    // Total scrollback rows available
screen.resize(new_rows, new_cols) // Resize the terminal

// Serialize the full state so a fresh same-sized screen can reproduce it
let replay = screen.export_replay_bytes();

// Or stream it to any std::io::Write sink without retaining the complete replay
screen.write_replay_bytes(&mut destination)?;

TerminalScreen answers the XTVERSION query (CSI > 0 q) through drain_responses(). Its default identity is tui-lipan <crate version>. An embedding terminal or multiplexer should call set_terminal_identity(...) once when constructing each child-facing screen, using an identity that remains stable when outer terminals detach or change. The supplied text is restricted to printable ASCII and 256 bytes so it cannot terminate or inject into the DCS response. Reporting is enabled by default; call set_xtversion_enabled(false) to leave the query unanswered.

Primary device attributes (CSI c) are answered as a VT220 with ANSI color, CSI ? 62 ; 22 c. Tools such as Kitty's icat send this query after their graphics probes and reject a shorter reply.

drain_clipboard_events() returns TerminalClipboardEvent values for valid UTF-8 OSC 52 store requests. Each event identifies the requested TerminalClipboardTarget (Clipboard or Selection) and contains the decoded text. The parser deliberately leaves applying or relaying the request to the host application. OSC 52 clipboard loads are disabled, so clipboard queries are ignored and produce no PTY response.

TerminalRenderSnapshot fields:

FieldTypeDescription
textArc<str>Plain visible contents
color_linesArc<[Vec<Span>]>Styled visible lines matching text logical lines
cursor_row / cursor_colu16Cursor position in the visible viewport
cursor_visibleboolWhether cursor is shown
cursor_shapeCaretShapeShape requested by the child via DECSCUSR
cursor_blinkingboolWhether the child requested a blinking cursor
sequenceu64Cache invalidation sequence
scrollback_offsetusizeCurrent scrollback offset
total_scrollback_rowsusizeTotal history rows
mouse_modeMouseModeStatePTY mouse/focus reporting mode
key_modesTerminalKeyModesPTY input modes (DECCKM, bracketed paste)
wrapped_rowsArc<[bool]>Whether each visible row soft-wraps into the next
hyperlinksArc<[TerminalHyperlink]>Visible row-local OSC 8 destination spans

TerminalRenderSnapshot::from_parts(...) rebuilds a snapshot from owned parts. It is intended for applications that define their own versioned external snapshot transport. TerminalRenderSnapshot itself is not a stable wire protocol. Such a transport attaches wrap flags with with_wrapped_rows(...) and explicit links with with_hyperlinks(...); a transport that cannot carry them omits them, and callers see a viewport where nothing wraps and no OSC 8 metadata exists. Plain-text URL activation still works from text.

wrapped_rows is what turns visible rows back into logical lines. A row is one grid row, not one line of output: a program that printed something longer than the terminal is wide occupies several of them, and only this flag says which breaks were the terminal's rather than the program's. Anything scanning text for something that can outrun the width - a URL, a path - has to rejoin those rows first. HintScan::scan_snapshot does that and also hints the snapshot's OSC 8 hyperlinks:

rust
let snapshot = screen.render_snapshot();
let hints = HintScan::new().scan_snapshot(&snapshot);

A hyperlink becomes a HintKind::Url hint whose text is its destination, not its visible label, so PR #200 linked to a pull request copies the pull request URL. Links with a scheme open_url refuses are skipped, and a link replaces any text hint it overlaps. With the URL scanner disabled, hyperlinks are not hinted either. HintScan::scan_wrapped(text, wrapped_rows) scans text alone.

The built-in scanners find:

  • URLs with a scheme open_url allows. A URL ends at whitespace, <, >, ", or a backtick, and at an apostrophe when one opened it. Trailing sentence punctuation and closing brackets are dropped.
  • Paths starting with ./, ../, ~/, or a drive letter; rooted paths with a second separator or a file extension, such as /etc/hosts or /README.md; and bare relative paths whose last component has a file extension, such as src/main.rs. A :line suffix is kept. A lone /status and a bare and/or or feature/branch are not paths. Slash and backslash separators are both accepted: Windows drive-relative, rooted, UNC, and verbatim paths are recognized, and quoting allows a path to contain spaces.
  • Git SHAs of 7 to 40 hex digits containing both a digit and a letter, so decimal numbers and hex-spelled words such as defaced are skipped.

A URL or path containing an ellipsis (…) is skipped: it is what remains after a program shortened the text, and copying it would give neither the real URL nor the real path.

A hint crossing a wrap is one HintMatch carrying one HintSpan per row it covers, in row order; row() / start_col() and end_row() / end_col() read the first and last of them. HintScan::scan still treats every row as a line of its own and always returns single-span matches.

Capturing the visible screen ​

screen.capture_frame() returns the visible viewport as a CapturedFrame, the same representation a headless UI capture produces, so every frame serializer applies to it:

rust
let frame = screen.capture_frame();
let text = frame.plain_text();
let ansi = frame.to_ansi_text();
let png = frame.to_png(&PngOptions::default())?; // feature `ui-snapshot-png`

The frame holds the emulator's cells, not what a Terminal widget paints: no selection, decorations, or focus styling. Colors keep their terminal meaning and ignore set_palette. Default foreground and background stay Color::Reset, the 16 named ANSI colors stay named, and indexed and truecolor values pass through as written: SGR 38;5;1 captures as Color::Indexed(1), not red. A PNG therefore takes its default colors from PngOptions::default_fg and default_bg.

Print or save a capture with to_ansi_text(), not to_ansi(). to_ansi() is a repaint stream for a terminal backend: it clears the screen and scrollback and moves the cursor. to_ansi_text() emits SGR only, keeps every row at the frame's width, and does not draw the cursor.

A wide glyph sits in its first cell and the column it covers holds an empty symbol, so a row's symbols join to text at its true display width. A row that wrapped a wide glyph early keeps the blank column it left. Hidden text captures as spaces. cursor is set while the cursor lies in the viewport, and is visible only when the program shows it and the view is not scrolled back. Its shape and blinking follow the program's DECSCUSR request (CSI Ps SP q), and until it makes one they are the screen's configured default. Live screens inherit the host cursor style; standalone headless screens default to a blinking block. Its color is the one the program set with OSC 12, or None after OSC 112 or when it never set one.

With terminal-images, the images the program displayed are in frame.images, cropped to the viewport, and the cells under them hold a half-block stand-in; to_png() draws the pixels. See Captures.

Selection, decoration, and copy mode ​

TerminalSelection uses absolute retained-line indices from the oldest available line, while its columns are display columns. It therefore remains attached to the same text as the viewport scrolls. Use screen.selection_display_text(&selection, end_kind, trim_row_end) to extract a selection across scrollback, including rows containing CJK or emoji. Snapshot-only hosts can project with to_viewport and use snapshot.selection_text, which is limited to visible rows. TerminalScreen::export_selection_text is intentionally different: it reads pre-trimmed scrollback text and therefore takes character-indexed positions.

snapshot.decorated(&decorations) applies batched TerminalDecoration::highlight, TerminalDecoration::label, and TerminalDecoration::overlay values to color_lines. It deliberately leaves the snapshot's plain text untouched, so search/hint scanning and selection extraction continue to see undecorated terminal output. Decorations are applied before the renderer's selection style, so selection remains the final overlay.

label(row, col, span) inserts the span, moving every later column right; overlay(row, col, span) paints it over the columns it covers and moves nothing. Prefer overlay for anything anchored to content: a terminal row is exactly as wide as the terminal, so a label inserted at or near the end of a row - which is where a wrapped URL's end_col lands - is pushed off the edge and clipped away. Overlays are applied before inserts, so their columns are the plain ones the caller passed. utils::spans::overwrite_at_column is the same operation on a styled line.

TerminalCopyMode is navigation state, not a widget. An app owns clipboard and screen integration:

rust
match mode.handle_key(key, grid) {
    CopyModeAction::Moved | CopyModeAction::SelectionChanged =>
        screen.set_scrollback(mode.scrollback_offset()),
    CopyModeAction::RequestCopy => {
        if let Some(selection) = mode.selection(screen.total_scrollback_rows()) {
            let text = screen.selection_display_text(
                &selection,
                SelectionEnd::Inclusive,
                true,
            );
            ctx.clipboard().copy(&text)?;
            // Leaving copy mode here clears the selection, so hand the flash its
            // own range rather than keeping the selection alive to be painted.
            ctx.flash_copy_feedback_range(terminal_node_id, selection);
        }
    }
    CopyModeAction::Cancel | CopyModeAction::Ignored => {}
}

Cursor shape and blinking ​

The focused Terminal renders the real hardware cursor. It follows the cursor shape the child program requests through DECSCUSR (CSI Ps SP q): a block (1/2), underline (3/4), or bar (5/6), mapped to CaretShape. Odd codes request blinking and even codes request a steady cursor; the widget honors that preference (blinking is driven by the framework blink timer, so a steady cursor stays lit).

On Unix, startup queries the host's current shape and blink through DECRQSS, before changing the cursor or entering the alternate screen. Live Terminal::screen(handle) widgets use that style as their screen default, including screens attached later. Explicit child requests through DECSCUSR or DEC mode 12 take precedence; CSI 0 SP q and hard resets return to the inherited default. This is a startup snapshot, so terminal configuration changes during a run are not re-queried. Unsupported hosts and Windows keep the blinking-block fallback. Text inputs continue to use their theme's caret preferences.

Applications can override a screen's default with screen.set_default_cursor_style(TerminalCursorStyle { shape: CaretShape::Bar, blinking: false }). That explicit default takes precedence over automatic inheritance and survives resets. Snapshot-only widgets and standalone headless screens do not query a terminal; configure their screen explicitly or keep the deterministic blinking-block default.

Inheritance happens when the widget is attached. Until then a screen answers cursor-mode queries such as CSI ? 12 $ p from its own default, the blinking block unless configured. A screen that parses child output before it is attached should seed itself from the startup probe first, so the child sees the host preference from its first query:

rust
if let Some(style) = ctx.host_cursor_style() {
    screen.set_default_cursor_style(style);
}

These fields flow through TerminalRenderSnapshot and can be overridden directly with Terminal::cursor_shape() / Terminal::cursor_blinking().

Cursor color ​

Terminal::caret_color(Color) sets the focused hardware caret color through OSC 12. This is independent of the terminal cell styles and palette; omit it to let the host terminal choose the caret color. OSC 12 support can be disabled with TUI_LIPAN_OSC12=0.

Semantic state (working directory & command lifecycle) ​

TerminalScreen runs a second, independent OSC observer beside the grid parser, fed the same raw bytes process_bytes() receives. It never touches cell/cursor state and is not part of TerminalRenderSnapshot - it is runtime metadata a host app polls separately:

rust
screen.process_bytes(&bytes);

// Current accumulated state (cwd, command phase, foreground executable).
let state = screen.semantic_state();

// Or react only to what changed since the last drain.
for event in screen.drain_semantic_events() {
    match event {
        TerminalSemanticEvent::WorkingDirectoryChanged(cwd) => { /* cwd.path, cwd.host */ }
        TerminalSemanticEvent::CommandPhaseChanged(phase) => { /* ... */ }
        TerminalSemanticEvent::ExecutableChanged(exe) => { /* ... */ }
    }
}

// Reapply previously captured state (e.g. after recreating a screen for session
// resurrection) without replaying the escape sequences that originally produced it.
screen.restore_semantic_state(state);

Recognized sequences:

  • OSC 7 ; file://host/path (percent-encoded) reports the child's working directory. TerminalWorkingDirectory::path holds the percent-decoded path as raw bytes rather than a lossy UTF-8 string, since arbitrary filenames are valid OsStr data but not necessarily valid UTF-8; use path_str() for the common UTF-8 case. host is Some(..) when the child reported a non-empty host component (e.g. over SSH) - a caller must not treat that path as locally spawnable without first checking the host.
  • OSC 9 ; 9 ; path reports a Windows-style working directory (ConEmu/Windows Terminal convention); the path is taken as-is (no percent-decoding, no host).
  • OSC 133 ; A/B/C/D reports command lifecycle boundaries: A = prompt start, B = prompt end / input start, C = execution start, D[;exit_code] = command finished, surfaced as TerminalCommandPhase::{Prompt, Input, Executing, Completed { exit_status }}.
  • Two OSC 133 key/value forms report foreground-executable identity - never a full command line - as a normalized basename: a namespaced private <vendor>_exe=<percent-encoded name> parameter, and Fish/Kitty's cmdline_url=<percent-encoded command line> (only the first token's basename is kept). The private form requires a non-empty namespace and rejects control characters.

While a prompt marked by A is active (through B, until C), resizing clears that prompt region before reflow. This gives the shell's SIGWINCH redraw a clean grid instead of duplicating wrapped input. Shells without OSC 133 integration retain ordinary terminal reflow.

TerminalScreen::reset() clears in-flight parser state but preserves accumulated semantic state - a child hard reset (RIS) does not imply its last-known working directory or command lifecycle became invalid.

Reading logical text lines ​

try_for_each_text_line visits physical terminal grid rows. For search, indexing, or other text features that must not split a soft-wrapped command or output line, use try_for_each_logical_text_line instead:

rust
use std::ops::ControlFlow;

screen.try_for_each_logical_text_line(0, screen.total_text_lines(), |line| {
    index(line.text());
    let occupied_rows = line.absolute_lines();
    ControlFlow::Continue(())
});

The range is still expressed in absolute physical rows so callers can retain bounded-work cursors. Only logical lines whose first row falls inside the range are visited. A line that starts in the range is completed past its end; a continuation whose first row precedes the range is skipped. This makes adjacent range scans lossless without returning a wrapped line twice.

Search matches use UTF-8 byte offsets into line.text(). Convert their inclusive start and exclusive end with start_position and end_position; the two methods deliberately choose opposite sides of an exact soft-wrap boundary. They similarly choose the start or end of an Alacritty cell when a byte boundary falls inside a multi-scalar cell such as a combining sequence. The resulting TerminalTextPosition reports the authoritative absolute grid row and column needed for scrolling or decoration.

Exporting replay bytes ​

TerminalScreen::export_replay_bytes() serializes the current screen state as a VT byte stream. write_replay_bytes() produces the identical stream through a std::io::Write sink without first retaining the complete replay in memory. Feeding either stream into a fresh, same-sized TerminalScreen (via process_bytes) reproduces the state, because replay goes through the normal VTE parser rather than a parallel snapshot format — so future parser fixes apply to exported state automatically.

rust
// On the source (e.g. a server-owned terminal):
let replay = source.export_replay_bytes();

// For a large screen, stream to a bounded sink instead:
source.write_replay_bytes(&mut destination)?;

// On a fresh receiver of the same size (`scrollback_lines` is the app's own capacity):
let mut screen = TerminalScreen::new(source.rows, source.cols, scrollback_lines);
screen.set_palette(source.palette());
screen.process_bytes(&replay);
let _ = screen.drain_responses(); // the source already answered device queries

The stream captures scrollback, primary/alternate screen contents, OSC 8 hyperlinks, the cursor position and pen template, the title, and common terminal modes, including the effective Kitty keyboard flags. It is a replay stream, not a stable data format: tab stops, custom scrolling regions, cursor style, exact Kitty keyboard stack depth, and the display offset are intentional non-goals — the receiver lands on the live view. This is the seeding mechanism a terminal host uses to bring a newly attached client up to date with a live terminal it does not own directly.

Serializable terminal snapshot leaf types ​

The optional terminal-serde feature enables serde derives for the terminal snapshot leaf style/mouse types that external transports commonly need to name: Style, Paint, Color, ColorTransform, ContrastPolicy, MouseModeState, MouseMode, and MouseEncoding. It includes the terminal feature.

Runtime-local animation state never crosses the wire: a Paint::Animated serializes as the solid colour it resolves to, and a late-bound EffectAmount inside a ColorTransform as its resting value. An older payload carrying an Animated paint still deserializes, as its fallback colour.

terminal-serde intentionally does not serialize TerminalScreen, TerminalPty, Span, or TerminalRenderSnapshot. Types containing Arc should be mirrored by the application in an owned, versioned wire format and converted back with Span::new(...).style(...) plus TerminalRenderSnapshot::from_parts(...).

TerminalColorPalette resolves SGR default foreground/background plus ANSI slots 0..15 for render snapshots and OSC 4/10/11 color-query responses. Use TerminalColorPalette::from_host_colors(colors, pane_bg) when embedding terminals that should keep the user's real ANSI palette but still paint on an app-controlled pane background. TerminalColorPalette::from_theme(theme, pane_bg) instead derives slots from semantic theme colors; when the theme came from Theme::from_host_colors, its HostTerminalColors extension preserves the probed foreground and ANSI slots while still using pane_bg.


Manual Wiring Pattern ​

For advanced use cases (multiple terminals, custom input routing):

rust
pub struct MyState {
    screen: TerminalScreen,
    snapshot: TerminalRenderSnapshot,
    pty: Option<TerminalPty>,
    cols: u16,
    rows: u16,
}

// In update():
Msg::PtyOutput(bytes) => {
    ctx.state.screen.process_bytes(&bytes);
    // Forward device query responses back to PTY
    if let Some(pty) = &ctx.state.pty {
        for response in ctx.state.screen.drain_responses() {
            let _ = pty.write(&response);
        }
    }
    ctx.state.snapshot = ctx.state.screen.render_snapshot();
    Update::full()
}
Msg::TerminalInput(input) => {
    if let Some(pty) = &ctx.state.pty {
        let _ = pty.write(&input.bytes);
        // Snap to live view when user types
        if ctx.state.screen.scrollback_offset() > 0 {
            ctx.state.screen.set_scrollback(0);
            ctx.state.snapshot = ctx.state.screen.render_snapshot();
            return Update::full();
        }
    }
    Update::none()
}
Msg::Resize { cols, rows } => {
    ctx.state.cols = cols;
    ctx.state.rows = rows;
    if let Some(pty) = &ctx.state.pty {
        let _ = pty.resize(cols, rows);
    }
    ctx.state.screen.resize(rows, cols);
    ctx.state.snapshot = ctx.state.screen.render_snapshot();
    Update::full()
}

// In view():
Terminal::new()
    .snapshot(ctx.state.snapshot.clone())
    .focusable(true)
    .scroll_wheel(true)
    .on_input(ctx.link().callback(Msg::TerminalInput))
    .on_resize(ctx.link().callback(|v: TerminalViewport| Msg::Resize {
        cols: v.cols, rows: v.rows
    }))
    .on_scroll_to(ctx.link().callback(Msg::ScrollTo))
    .on_mouse_forward(ctx.link().callback(Msg::MouseForward))
    .into()

Scrollback ​

Mouse wheel scrolls through scrollback history when scroll_wheel(true) (default in ManagedTerminal).

scroll_wheel_multiplier(n), on both Terminal and ManagedTerminal, sets how many scrollback rows one wheel tick moves in this terminal, overriding the app-wide App::scroll_wheel_multiplier. Use it to offer a terminal scroll speed without changing the wheel step of every list and view in the app.

Use on_scroll_to to receive the new offset and call screen.set_scrollback(offset).

rust
Msg::ScrollTo(offset) => {
    ctx.state.screen.set_scrollback(offset);
    ctx.state.snapshot = ctx.state.screen.render_snapshot();
    Update::full()
}

The cursor is hidden while scrolled into history. Typing input snaps back to live view (set scrollback to 0).

TerminalScreen::process_bytes() automatically preserves the user's scrollback position when new output arrives while scrolled up - the offset is adjusted for newly added rows.

Wheel forwarding under mouse tracking ​

Once the child enables mouse tracking (mouse_mode.mode is not MouseMode::None), the wheel stops driving local scrollback and is reported to the child through on_mouse_forward instead. Hold Shift to keep scrolling the widget's own scrollback over a tracking child.

The runner coalesces a burst of same-direction wheel events into one dispatch, so a single on_mouse_forward payload may carry several mouse reports back to back - one per raw wheel event the host sent. Write the payload to the PTY as-is; do not deduplicate it. Many terminals emit more than one wheel event per physical notch, and collapsing them makes the child scroll less than the user asked for.

Forwarding is a passthrough: the app-level scroll_wheel_multiplier scales local scrolling only and is never applied to reports sent to a child, which applies its own scroll step.

Structured mouse forwarding ​

Use on_mouse_forward_event when forwarding policy needs the mouse event's meaning:

rust,ignore
Terminal::new().on_mouse_forward_event(Callback::new(|event: ForwardedMouseEvent| {
    inspect(event.kind, event.cell, event.mods);
    write_to_child(event.bytes);
}))

ForwardedMouseEvent carries kind with its button or wheel direction, mods, zero-based terminal-local cell coordinates, optional zero-based pixel coordinates, and the child's mouse_mode including tracking and wire encoding. pixel is present when the host supplied sub-cell precision and uses the child terminal's cell geometry. repeat_count records raw wheel ticks coalesced into one callback. bytes contains the authoritative encoding with all repetitions already applied; forward it unchanged rather than encoding it again. Legacy X10 and UTF-8 release reports use the no-button release code; SGR reports identify the released button.

on_mouse_forward remains available for byte-only consumers. Both setters select the same callback; the last setter wins. They share tracking-mode filtering, Shift selection, focus-only press consumption, and protocol coordinate limits. Semantic consumers can switch the setter to on_mouse_forward_event, inspect the fields, and forward event.bytes to their existing PTY writer. No terminal-byte decoder is needed.

Pixel-precise reports ​

A child that wants to know where the pointer is more finely than which cell it is over - one dragging a scrollbar or panning a canvas - enables SGR-pixels reporting (DEC private mode 1016) and probes whether it took. TerminalScreen holds that request and answers the probe, and mouse_mode.encoding becomes MouseEncoding::SgrPixels; the encoding is the only thing that changes, so a host writing on_mouse_forward payloads to the PTY needs no special handling.

How much precision those reports actually carry depends on the host the app itself runs under. The runner asks it for the same mode while a forwarding terminal on screen uses that encoding, the host answers the probe, and the host reports a cell size that a pixel position can be divided by. It then passes the sub-cell offset through to the report. The mode is withdrawn again once no terminal uses the encoding: a pixel-reporting host sends a report for every pixel the pointer crosses rather than every cell, which the whole input path would otherwise pay for with nothing reading it. These facts can settle at different times - a host that pads its window only reveals its cell size on a resize that happens to divide evenly - so the request may come mid-run rather than at startup. Each switch is followed by a query of the mode, and reports change meaning exactly where the host's answer arrives: one the host sent before acting on the switch is still read the old way, so a click in the transition lands on the cell it was made on. A host that only says which cell was clicked leaves reports on that cell's first pixel - correct, one cell granular, and exactly what the child would have got from cell coordinates.


Plain-text export ​

TerminalScreen can read out its contents as plain text without touching the display offset or running the render pipeline, so exporting never disturbs what the user is looking at.

Lines are addressed by absolute index, counting from the oldest line still retained (0) through the live bottom (total_text_lines() - 1):

MethodReturns
total_text_lines()Retained line count (scrollback history + visible screen)
try_for_each_text_line(start, end, visitor)Streams (absolute_line_index, &str) until the range ends or the visitor breaks
text_lines(start, end)Vec<String> for the half-open range [start, end)
export_text(start, end)The same range joined with \n
absolute_line_to_viewport(abs)Option<(scrollback_offset, viewport_row)>
absolute_line_to_offset(abs)Option<usize> scrollback offset alone

Out-of-range bounds are clamped and empty ranges yield empty output. Trailing blanks are trimmed per line, and wide-character spacer cells are skipped, so exported text matches what is on screen rather than the raw cell grid.

try_for_each_text_line passes each line's absolute retained-line index, reuses one scratch string for the whole range, and stops immediately when the visitor returns ControlFlow::Break(()). The callback's &str is valid only for that callback invocation; copy it only when it must outlive the visit.

rust
use std::ops::ControlFlow;

// Copy the whole buffer.
let all = screen.export_text(0, screen.total_text_lines());

// Copy just the last 20 lines.
let total = screen.total_text_lines();
let tail = screen.export_text(total.saturating_sub(20), total);

// Scan without allocating one String per line.
let _ = screen.try_for_each_text_line(0, total, |absolute, line| {
    if line.contains("needle") {
        println!("found on retained line {absolute}");
        return ControlFlow::Break(());
    }
    ControlFlow::Continue(())
});

Absolute indices are not stable across output. They are relative to the oldest retained line, so once scrollback is full every new line shifts existing indices down by one. Resolve a range and use it promptly rather than storing indices across reads.

Semantic marks (OSC 133) ​

When the shell emits OSC 133 prompt markers, TerminalScreen records them against the same absolute line space:

rust
pub struct SemanticMark {
    pub kind: SemanticMarkKind,   // Prompt | OutputStart | OutputEnd
    pub absolute_line: usize,
    pub exit_status: Option<i32>, // from OSC 133;D
}
MethodReturns
semantic_marks()Retained marks, oldest first
last_command_output_range()Option<(start, end)> for the last command's output
export_last_command_output()That range as text

While a command is still running (an OutputStart with no matching OutputEnd), the range extends to the live bottom.

rust
if let Some(output) = screen.export_last_command_output() {
    ctx.copy_to_clipboard(&output);
}

Behaviour worth knowing:

  • Marks are bounded (256 retained); the oldest are discarded first.
  • Evicted marks are dropped. When a marked line falls out of scrollback the mark goes with it, so last_command_output_range() returns None rather than a range pointing at whatever text now occupies those indices. A command whose output is longer than the scrollback depth is therefore not recoverable - size scrollback for the output you intend to export.
  • Alt-screen marks are ignored. Full-screen programs (vim, less) emitting OSC 133 do not contribute marks, since the alt screen has no scrollback of its own.
  • reset() clears all marks.

Image passthrough ​

Programs in a pane can draw pictures with the Kitty graphics protocol; enable the terminal-images feature. The host terminal does not have to speak Kitty - the pane decodes the child's escapes and re-encodes for whatever the host supports, down to half-blocks. See terminal-images.md. TerminalScreen::has_images() is whether any of those pixels are still retained; snapshot.images is the visible set this frame.

Program status through OSC 7501 ​

TerminalScreen supports OSC 7501 revision 0.4. Enable it on the authoritative terminal with screen.set_program_status_enabled(true). It is disabled by default, so a passive parser does not store competing records or answer feature queries. Drain terminal responses and write them to the child after each process_bytes call. OSC queries and device attribute replies preserve their input order. Changing enablement discards partial wire sequences. Disabling preserves existing records, but prompts, process exit and resets still apply their usual lifecycle rules. A disabled screen with no retained records skips status scanning. OSC 133 prompts drop ignorable C0 bytes as the terminal parser does, independently of the OSC 7501 4096-byte sequence limit. Prompt expiry also follows the terminal parser when CAN, SUB or ESC interrupts OSC 133. OSC 7501 reports and queries still require BEL or a complete ST terminator.

program_status_snapshot() returns at most 256 independent records in stable ID order, with a revision and report order. Use snapshot.app_for(record) to resolve the nearest ancestor's application identifier.

drain_program_status_events() returns one ProgramStatusEvent per record change, in the order the terminal applied them. Drain it after each output batch. The final snapshot is enough for presentation, but only the events keep every transition a batch contains: a program that writes working and then done in one batch yields both. Each event carries:

  • id, and app: the record's application when it changed, inherited from the nearest ancestor that names one.
  • previous: the state before the change, or None for a new record.
  • record: the record after the change, or None when it was removed.
  • cause: Report, Clear for an explicit state=clear, Lifecycle for a prompt or process exit, Reset for RIS or reset(), or Evicted when a full store dropped its least recently updated record.

Changes made by one operation share its revision. A hierarchical clear produces an event for each record it removes. A report that evicts a record produces the eviction first, then the report. An operation that changes nothing produces no event.

Reports replace the addressed record completely. Hierarchical clear removes that record and its descendants. Prompts emitted through OSC 133 A and the explicit program_status_process_exited() hook remove working and blocked records. Call the exit hook before replacing a child process that shares the same logical terminal. Done, error and idle survive those events. RIS and reset() clear every record; soft reset, screen switching and resizing preserve them. No heartbeat is required.

For repeated fields, the last value wins. Encoded size limits apply to every occurrence; base64, UTF-8 and control character checks apply to the final title and message values. Titles and messages are decoded UTF-8 plain text. They remain untrusted text. Disarm bidirectional overrides and invisible formatting before displaying them outside the terminal grid, and never interpret them as markup. Feature replies contain no report-supplied data.

Completed parser mutations ​

TerminalScreen::process_bytes returns TerminalMutation. cells reports the emulator's changed rows or full-grid damage. graphics reports successful source and placement changes. Cursor, title, modes, semantic metadata, clipboard requests, and responses have separate flags. Queries and incomplete graphics commands do not cause visual mutations. Several calls accumulate independently of snapshot and row-damage consumption; take_mutation() drains that accumulator. mutation_generation() advances when a completed parser operation changes state or emits events.

Ignoring the return value remains valid. Code that used process_bytes as a unit-returning tail expression should add a semicolon. This is the migration for its new return type.

Live TerminalScreenHandle nodes register a runner wakeup. Quiet screens with a registered wakeup do not require frame-rate polling. Sources without a wakeup retain the compatibility cadence. Managed terminal PTY output already enters through component messages and wakes the runtime.

With terminal-images, TerminalImage::source_key() identifies the screen lifetime, producer ID, and incarnation. Retransmitting a retained producer ID increments source_revision() but deletes all its previous direct and virtual placements, as Kitty requires. a=t stores the replacement without displaying it; a=T creates only the placement requested by that command. Existing placeholder cells remain text and display no image until a new virtual placement is declared. The source key remains available for backend upload-ID reuse; it does not keep placements alive. Deleting and reusing the ID creates a new source incarnation. An id-less transmission receives a fresh assigned ID. TerminalImagePlacement::placement_key() survives viewport scrolling but a recreated cursor placement receives a new key. Placeholder identities use their absolute retained grid origins. Host resource IDs are presentation details, outside replay or capture semantics.

Graphics-only updates and successful presentation ​

Return Update::terminal_graphics() when retained graphics are the only visual change. The runtime uses the previous native plan without painting cells. Composition, changed geometry, overlays, effects or host capabilities automatically widen to terminal paint. Queries and metadata without visual changes can return Update::none().

TerminalRenderSnapshot::cell_generation and graphics_generation let observers detect visual changes even after another consumer reads take_mutation(). Graphics-only snapshots reuse the retained cell content. Migration: exhaustive snapshot literals must supply the new generation fields and presentation, or use ..TerminalRenderSnapshot::default().

Call screen.set_presentation_token(sequence) after processing application output. Subscribe using ctx.observe_terminal_presentations(ctx.link().callback(Msg::Presented)) and retain the returned TerminalPresentationSubscription in component state. Each callback receives a batch of PresentedTerminal { stable_terminal_id, token } for terminal state included in a successfully written host frame. screen.stable_terminal_id() identifies the screen across mounted widgets. Tokens do not trigger a frame. Failed writes and discarded frames do not report tokens, and software captures and recording renders do not count as host presentation. A handler that only forwards acknowledgements should return Update::none().

Lossless semantic output transactions ​

Session transports can call screen.set_output_transaction_recording(true) before feeding output, then drain screen.take_output_transactions() after parsing. Recording is optional and disabled by default. Each TerminalOutputTransaction contains the exact original bytes and a TerminalOutputClass established by the same parser that changes the retained scene. The one exception is inline image relay: with it on, a single out-of-band image transmission is recorded as the inline transmission it stands for.

Ordered segments must retain their order and bytes. Graphics segments include the stable source key, its revision, and GraphicsDependency. Only Independent transactions can supersede earlier pending ones. The contract covers a run: consecutive Independent transactions with nothing else between them. A run holds a single source, and a later transaction makes every earlier one in the run redundant, so a consumer may keep only the newest. A replacement of a different source, or any other transaction, ends the run. Replacements of different images are never reordered, because the image budget evicts by recency and size, and moving one could evict an image that the original order kept. For the same reason, a replacement whose execution evicted an image is reported as DependsOnPrevious.

Two shapes qualify:

  • A single command: a complete, accepted replacement of a nonzero image ID with unchanged displayed placement state, quiet responses, reusable media (t=d or t=f), and no cursor or grid effects.
  • A whole synchronized update (CSI ? 2026 h to CSI ? 2026 l) that replaces one image, whose text is only absolute cursor positioning (CSI row ; col H). Its graphics must be complete, quiet transmissions of that image's explicit ID (retransmission retires the image's placements), an a=d,d=I delete of it, or a=d,d=i placement deletes of it alongside one of those. Placements must use C=1 and come after the update has set the cursor. This is how terminal-browser's whole-frame presenter writes each frame.

The screen holds a candidate update until its end marker. Text, other controls, other graphics actions, chunked or failed transmissions, or more than 1 MiB release the held bytes unchanged at once. An update that touched a second image or caused an eviction is released unchanged at its end. Partial patches, chunked uploads, queries, unknown controls, consuming media and id-less creations remain dependent or ordered. Patch streams such as terminal-browser's default presenter are dependency chains: each frame layers new images over earlier ones, so they stay ordered.

A graphics escape spanning several parser calls is emitted only when complete. Additional recording buffers and ordered transactions are bounded to 1 MiB; larger commands stream as ordered fragments. Before an external resize, reset or exit boundary, call flush_output_transaction_boundary() and drain the transactions. This forwards unfinished bytes, including a held synchronized update, as ordered without resetting the parser; continuation bytes remain ordered. output_transaction_pending() reports a held update as pending. Concatenating transactions reproduces the original byte stream exactly.

Mode changes without visual effects update live input handling without painting cells. Live screens supply current keyboard, paste, mouse and focus modes directly rather than waiting for an app view rebuild. An unchanged native scene writes no synchronized-output wrapper and reports no presentation.

Per-PTY read credits ​

TerminalPty::read_gate() returns a cloneable TerminalReadGate. Reads start unrestricted. A session transport can call set_credit(bytes, available, maximum_hold) to admit one drain or hold only that PTY's reader. A drain keeps reading while the PTY has bytes immediately pending, up to the budget, and delivers them as one Output event. Budgets clamp to 1 byte through 64 KiB, and safety holds to 1 through 250 ms.

Unix readers poll and drain the kernel PTY directly. Windows ConPTY uses a byte-mode pipe: the first read has a buffer large enough for the full credit and returns when it has read the queued bytes or filled the budget. It does not wait for future writes to fill a short read. Unrestricted reads remain capped at 8 KiB on both platforms.

While the reader is held, the producer's output queues in the kernel, oldest first. A release must take that whole backlog for the consumer to coalesce it to current state. A one-frame budget delivers the oldest queued frame instead: with small file-backed graphics commands the kernel holds dozens of frames, and a 240 fps producer paced to a 60 Hz presenter then trailed it by about a second. Use the largest budget whose parsing cost is acceptable, and coalesce replaceable transactions within each delivered event.

Call resume() for ordinary output, input answer windows or policy resets. Child exit permanently releases the gate, so late policy changes cannot hold its final output ahead of the exit event. stats() reports actual holds, total hold duration and forced safety releases. This API controls producer reads; it does not block the UI thread or share credits with other PTYs.

Native host IDs are reserved for each logical source incarnation while the retained source or a snapshot still owns it. Hiding an image or switching to composition frees host pixel data while keeping that lightweight reservation; returning to native rendering reuploads with the same ID. Cursor and placeholder placement IDs survive the same transition. Backend retry pixels and encoder caches do not keep producer identities alive. Unchanged clipped rectangles retain their placement IDs when a neighboring region is added or removed. Placeholder identities include the producer placement ID from underline colour and absolute retained-grid origin. Their lightweight lifetime records are pruned with overwritten visible rows, evicted scrollback and retired alternate grids. When matching ImagePlacementIdentity::Placeholder, include the new grid_lifetime and producer_placement_id fields or use ..; the framework supplies their values.

Native scene and upload acknowledgement commit after the complete successful host frame, including the synchronized-output closure and cursor writes. A failure after the cell flush still retries from the previous committed graphics scene.

An explicit Context::request_full_repaint() or return from terminal handoff also invalidates host resource acknowledgements. The next frame restores retained sources and placements using their reserved IDs. Ordinary UI rebuilds keep unchanged native scenes intact. Dropping a backend deletes its owned native resources, including resources staged by an interrupted frame.

Receive paste content before PTY encoding ​

Set Terminal::on_paste(Callback<TerminalPasteEvent>) to handle local clipboard attachments, for example when a terminal host must upload them to a remote machine. Use TerminalKeyPolicy::AppCommandsThenTerminal, TerminalFirst, or TerminalOnly to enable terminal clipboard preflight. With TerminalPasteShortcutBehavior::Performable, Ctrl+V delivers ClipboardContent::Text, Image, or Files, preferring file lists and images over accompanying text. Ctrl+Shift+V also uses this callback. Empty clipboards keep forwarding the key to the child.

The callback owns delivery. The same paste does not also reach on_input. The event carries content: ClipboardContent and origin: PasteOrigin. PasteOrigin::Clipboard identifies framework clipboard shortcuts, mouse paste, and application clipboard commands. PasteOrigin::Terminal identifies text received from the outer terminal. An empty terminal paste reads the local clipboard while preserving the Terminal origin.

Apply file-drop path recognition only to terminal-origin text. A copied literal path from a framework clipboard gesture should remain text. Outer terminals can emit the same text for both their own paste gestures and file drops, so Terminal origin alone does not establish a native OS drag event. Terminals without on_paste retain their existing behavior, including rich-content key forwarding.

Use ctx.clipboard().read_content() for an explicit application paste command. It returns Option<ClipboardContent> and enforces paste_max_image_bytes for images. File-list entries are local paths; opening, limiting, transferring, and retaining their files remain the application host's responsibility.

Mouse pointer requests ​

TerminalScreen handles OSC 22 mouse pointer requests, including set, push, pop, and support queries. Each main and alternate screen retains a separate stack of up to 16 shapes. Resetting the screen clears both stacks. Replay exports preserve them, including entries restored by a later pop. Unknown shape names are ignored.

TerminalScreen::pointer_shape() and TerminalScreenHandle::pointer_shape() read the current request without taking a snapshot. TerminalRenderSnapshot::pointer_shape carries the request for snapshot-based widgets. A live Terminal uses the request of the pane under the pointer, independently of keyboard focus. An explicit .pointer_shape(...) on an element overrides the automatic terminal shape. See mouse pointer shapes.

When constructing TerminalRenderSnapshot with a struct literal, set pointer_shape: None to leave mouse pointer choice to the terminal widget, or include ..Default::default() for fields you do not set explicitly.

MPL-2.0