Skip to content

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.

{
"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:

  • size is [columns, rows] for the PTY grid.
  • total_rows is the total rows reported by libghostty-vt, including scrollback and viewport.
  • scrollback_rows is the number of rows currently available above the viewport.
  • title and working_directory are included only when non-empty.
  • cursor is zero-based within the visible viewport.
  • viewport_text and scrollback_text are the easiest fields to assert in tests.
  • viewport and scrollback contain compact styled spans for the same trimmed rows.

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.

Rows are arrays of spans:

  • "text" means text using default_style.
  • ["text", 0] means text using styles[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.

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.

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.

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.