State JSON
State JSON records terminal text, styles, and cursor position for snapshot tests and debugging.
Write final state with Output <path>.json or checkpoint state with State <path>.json. See
Outputs for when each one is written, and
Terminal testing for how to use these files in tests.
Top-level shape
Section titled “Top-level shape”{ "size": [80, 24], "total_rows": 31, "scrollback_rows": 7, "cursor": { "x": 2, "y": 10, "visible": true }, "default_style": { "fg": "#dddddd", "bg": "#102040" }, "styles": [{ "fg": "#5a56e0" }, { "fg": "#ffcc66", "bold": true }], "viewport_text": "> echo ok\nok\n> ", "scrollback_text": "cargo build\n...", "viewport": [[["> ", 0], "echo ok"], ["ok"], [["> ", 0]]], "scrollback": [["cargo build"], ["..."]]}Important fields:
sizeis[columns, rows]for the PTY grid.total_rowsis the total rows reported bylibghostty-vt, including scrollback and viewport.scrollback_rowsis the number of rows currently available above the viewport.titleandworking_directoryare included only when non-empty.cursoris zero-based within the visible viewport.viewport_textandscrollback_textare the easiest fields to assert in tests.viewportandscrollbackcontain compact styled spans for the same trimmed rows.
Text fields
Section titled “Text fields”viewport_text contains visible viewport text after trailing blank rows are trimmed.
scrollback_text contains rows above the viewport, also trimmed. Non-empty text fields include a
trailing newline because they represent terminal rows rather than a single logical string.
For many tests, these fields are enough:
let state: serde_json::Value = serde_json::from_str(&json)?;assert!(state["viewport_text"].as_str().unwrap().contains("Saved profile"));Use styled spans only when color or attributes are part of the behavior being tested.
Styles and spans
Section titled “Styles and spans”Rows are arrays of spans:
"text"means text usingdefault_style.["text", 0]means text usingstyles[0].- Empty rows are represented as
[]. - Adjacent cells with the same style are merged into one span.
- Trailing empty rows are omitted because they are implied by
size.
default_style is fully expanded so plain strings are self-describing. Entries in styles are
deltas from default_style; fields that match the default are omitted.
Style indices are local to one capture. Betamax numbers styles in the order they first appear, reading from the top of the screen, so the same style can have a different index in another capture. Compare resolved styles, not index numbers; see Snapshot styles from Rust.
Style objects may include:
| Field | Meaning |
|---|---|
fg |
Foreground color as #rrggbb |
bg |
Background color as #rrggbb |
bold |
Bold text attribute |
italic |
Italic text attribute |
faint |
Faint text attribute |
blink |
Blink text attribute |
inverse |
Inverse text after style/color resolution |
invisible |
Invisible text attribute |
strikethrough |
Strikethrough text attribute |
overline |
Overline text attribute |
underline |
single, double, curly, dotted, dashed, or none |
Boolean fields are omitted when false. underline is omitted when it is none.
Why this encoding
Section titled “Why this encoding”The chosen encoding keeps no-style text short while still preserving styles:
[["plain text"], [["styled", 0], " normal again"]]Earlier options were rejected because they were either too verbose for style-heavy output or too hard to read in diffs:
- Cell-by-cell JSON repeated default style data and made ordinary terminal output noisy.
- Fully referential spans with row/column positions were compact for sparse styles but verbose when styles changed often.
- Object spans such as
{ "text": "foo", "style": 1 }were readable but much larger than["foo", 1]. - YAML, TOML, and JSONC each add parsing tradeoffs. Betamax writes strict JSON so tests can consume snapshots with standard parsers.
Snapshot styles from Rust
Section titled “Snapshot styles from Rust”Because style numbers depend on what appears earlier on the screen, a snapshot of viewport and
styles changes in places that look the same. Take this screen, where the header is blue and the
selected row is green:
"styles": [{ "bg": "#487fd4" }, { "fg": "#52aa60", "bold": true }],"viewport": [[["Tasks", 0]], [["> run test", 1]]]If the header stops being blue, the selected row becomes the first styled text and its number
changes from 1 to 0:
"styles": [{ "fg": "#52aa60", "bold": true }],"viewport": [["Tasks"], [["> run test", 0]]]The diff then reports the selected row as changed, and checking that it is not means looking up
both numbers. TerminalState::styled_viewport and styled_scrollback return rows with each style
written next to its text, so this change touches only the header:
[["Tasks"], [["> run test", { "fg": "#52aa60", "bold": true }]]]Snapshot these rows instead of viewport and styles:
let state = artifacts.final_state.unwrap();insta::assert_json_snapshot!(state.styled_viewport());The JSON files Betamax writes keep the indexed form, which stays compact for style-heavy screens.
Terminal title and window presentation
Section titled “Terminal title and window presentation”The optional title field is the app’s current OSC terminal title, omitted when empty. It is
independent of Set WindowTitle, runtime WindowTitle, and whether app-title presentation is
enabled. Tape-authored display titles do not change JSON or state assertions. State outputs contain
one checkpoint or final value; animated media and frame sequences preserve visible title history.