Skip to content

State JSON

State JSON captures terminal state as text-oriented data instead of raw pixels. It is designed for snapshot tests, debugging, and future library use where callers want terminal assertions without having to inspect images.

Write final state with Output <path>.json or checkpoint state with State <path>.json. See Outputs for when each one is written.

{
"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 objects may include:

FieldMeaning
fgForeground color as #rrggbb
bgBackground color as #rrggbb
boldBold text attribute
italicItalic text attribute
faintFaint text attribute
blinkBlink text attribute
inverseInverse text after style/color resolution
invisibleInvisible text attribute
strikethroughStrikethrough text attribute
overlineOverline text attribute
underlinesingle, 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.

The format is still early, but the design goal is stable: keep normal terminal output readable and diffable while preserving enough style data for meaningful terminal UI tests.