Skip to content

feat: persist and restore scrollback as a marked record - #52

Merged
simota merged 8 commits into
mainfrom
feat/scrollback-persistence
Jul 30, 2026
Merged

feat: persist and restore scrollback as a marked record#52
simota merged 8 commits into
mainfrom
feat/scrollback-persistence

Conversation

@simota

@simota simota commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

Restoring a session brought back the tabs and splits but not a single line of output, and said nothing about it. The layout coming back is itself a promise that the contents did — this closes the gap from both ends.

Honesty (ships even with persistence off). A pane restored with no record now says so, in one line that names the key which would change that. This is the part that works by default; the rest is opt-in.

Persistence (scrollback-persist = tail, default never). Each pane's scrollback tail is captured on clean quit and on idle checkpoints, and restored on launch above the live shell behind a labelled boundary row, with a gutter marking the restored rows. Restored rows are ordinary scrollback, so selection, copy and search work on them with no extra code.

Default never is deliberate: Ghostty restores no contents, so enabling by default would be an observable deviation, and persisting terminal output changes what a stolen disk yields. Spec: docs/specs/scrollback-persistence.md (§12 lists every deliberate deviation and what was withdrawn).

Design notes worth reviewing

The wire format works at the Row/Cell level, not the paged scrollback. Serialising PagedScrollback directly looks obvious and is wrong: GraphemeId indexes a process-global interner and HyperlinkId indexes Terminal::hyperlinks, so a file written with those ids decodes to different text in the next process. Both are resolved to their content and rebuilt on load.

Capture does not share persist_session's trigger. That runs on every structural change; re-encoding a megabyte per pane each time a split moves is not viable. Checkpoints fire 5s after output settles, with a 60s ceiling anchored on the start of the dirty streak so a sustained flood — the long build whose tail matters most — is still captured. A checkpoint also rewrites the topology document: a key minted but not recorded there is deleted by the next launch's collector, which is precisely the crash the checkpoint exists to survive.

Cached absolute row indices are invalidated by coordinate generation. record_rows/annotation_row are session-absolute, and both a column reflow and a scrollback clear renumber that space. Terminal already bumps a generation on exactly those and deliberately leaves it alone for ordinary eviction, so comparing against it is the complete staleness test. On mismatch the marking is dropped rather than acted on — the boundary itself is a row, so it survives reflow either way.

Storage posture. 0700 directory, 0600 files, excluded from Time Machine, nothing created at all while disabled, and no data-dir fallback (an unresolved data directory means persistence is unavailable, not "write into the working directory"). Snapshot keys are validated as 16 hex digits — they come from a user-writable file and are interpolated into a path. Scratch terminals, the quick terminal, remote panes and the alternate screen are never captured. Capture also requires window-save-state, since the reference that makes a snapshot restorable lives in the session file.

Restore is inert by construction. Rows are inserted as history rather than replayed as VT input, so a snapshot cannot move the cursor, set a title, or write to the pty.

Changes

  • Config (a947268): four noa-only keys plus scrollback-persist-encrypt; scrollback_dir(). Days and bytes are plain integers, following the crate's existing convention — there is no unit-suffix parser anywhere in noa's config, so 7d diagnoses rather than silently parsing as 7
  • Grid (a947268): the NOASB format, Terminal capture/restore, rewrap on width change (never separating a wide glyph from its spacer), discard_history_prefix
  • App + render (4783050): the store and its worker, capture triggers, launch collector, the record view, session schema v3, two palette commands, a Settings row, and the renderer's column-0 gutter
  • Review fixes (440f72c, 6fe1933, d1dfb59): see below
  • Stage 2 (a3b0bb7): AES-256-GCM under a login-keychain key, and [image] markers for kitty-graphics placeholder cells

Review

Ran /judge (Codex + Claude; agy failed to run — its sandbox blocked git diff and it exited 0 without an artifact). 28 findings after dedup, all grounded. Every HIGH and MEDIUM is fixed in 6fe1933/d1dfb59; two were resolved as documentation with the reasoning recorded, and one was rejected outright:

  • Rejected: trimming trailing blanks via Row::occupied() instead of value equality. Restored rows come from Row::from_cells with a conservative full-length watermark, so this would encode every column on re-capture and bloat snapshots — a regression dressed as a fix
  • Documented: scrollback-persist-max-age-days bounds file age, not content age. Bounding content age needs per-row timestamps and a format change

Three defects were found by running the thing rather than by reading it: the record separator being re-captured and accumulating one per relaunch, a non-terminating loop in rewrap at width 1 with a wide glyph (a test had to be killed to confirm it), and a unit test that minted a real generic password in the developer's login keychain as a side effect of cargo test.

cargo run -p noa-grid --example dump-snapshot -- <file>.nsb decodes a snapshot and prints each row's text and pen. The format is opaque, so a wrong-looking restored pane otherwise gives no way to tell whether the capture, the file, or the restore is at fault — the accumulation bug was found with it.

Test plan

  • cargo test --workspace --offline: green, 0 failures (run with the sandbox disabled — noa-ipc needs loopback bind)
  • cargo clippy --workspace --all-targets --offline: clean
  • ~1,650 lines of tests, ~33% of the diff. Notable: format round-trip fidelity across every Cell field, malformed/truncated/hostile input, the byte budget against a link that lives in the side table, path-traversal rejection of snapshot keys, collector orphan/expiry/budget/temp-sweep, and rewrap termination
  • Live verification on a real build with an isolated HOME: the idle checkpoint fires unprompted and writes 0600 in a 0700 directory; the record survives four relaunch generations with zero separators baked in and one file per pane; window-save-state = never creates no directory at all; turning the setting off purges immediately; and the coordinate-generation guard was measured (not assumed) to leave the marking live across every frame of a normal restore

Not done

  • The record badge. draw_toast_card takes no placement, so a persistent badge needs new drawing and positioning code — and screen-recording permission is currently revoked on this machine, so it could not be looked at. Adding an unverifiable visual element to a layer whose GPU traps are documented in CLAUDE.md is not a good trade. The boundary is already carried by the separator row, which labels itself
  • GUI visual confirmation of the five items in the spec's §12 tail (separator rendering in light and dark, gutter legibility, viewport position after restore, the Settings row, reflow appearance). Same reason — screencapture and CGEvent.postToPid are both refused by TCC
  • The prepend itself still runs on the winit thread; reads and inflates were moved off it and parallelised

https://claude.ai/code/session_011rXCoq61BVttWaYHCtJzvC

simota added 6 commits July 28, 2026 12:05
…keys

Adds the storage half of scrollback persistence
(docs/specs/scrollback-persistence.md), with no behavior change by
default: `scrollback-persist` defaults to `never`, matching Ghostty,
which restores window topology but never terminal contents.

noa-config gains four noa-only keys (`scrollback-persist`,
`-limit`, `-total-limit`, `-max-age-days`) plus `scrollback_dir()`.
Days and bytes are plain integers, following the crate's existing
convention — there is no unit-suffix parser anywhere in noa's config,
so `7d` diagnoses rather than silently parsing as 7.

noa-grid gains `snapshot`, a self-contained NOASB byte format, and two
`Terminal` methods to capture and restore a tail. The format works at
the materialized Row/Cell level rather than serializing the paged
scrollback, because two of the paged form's ids are process-scoped:
`GraphemeId` indexes a global interner and `HyperlinkId` indexes
`Terminal::hyperlinks`. Writing those to disk would decode to different
text in the next process, so both are resolved to their content and
rebuilt on load. Styles are re-interned per snapshot and the body is
deflated.

Capture reads the primary screen even while the alternate screen is
active: restoring a dead frame of a full-screen app into a fresh shell
would show a program that is no longer running, when what the pane
wants is the shell history underneath. Restore rewraps rows to the new
grid width so a snapshot taken in a narrow window does not come back
with its soft-wraps frozen, and never separates a wide glyph from its
spacer.
Closes the gap where restoring a session's layout implied its contents
came back too. Two independent defects, fixed independently:

Honesty (ships even with persistence off): a pane restored with no
record now says so, in a row naming the key that would change it.
Restoring the tabs and splits is itself a promise the empty pane
breaks, and the previous behavior left the user to guess whether they
had lost the output or merely not configured keeping it.

Persistence (opt-in via `scrollback-persist = tail`): each pane's
scrollback tail is captured on clean quit and on idle checkpoints, and
restored on launch above the live shell, separated by a labeled
boundary row and marked with a gutter so recovered history cannot be
read as this session's output. Restored rows are ordinary scrollback,
so selection, copy and search work on them with no extra code.

Capture deliberately does not share `persist_session`'s trigger: that
runs on every structural change, and re-encoding a megabyte per pane
each time a split moves is not viable. Checkpoints fire 5s after output
settles, with a 60s ceiling so a sustained flood — the long build whose
tail matters most — still gets captured. A checkpoint also rewrites the
topology document, because a key minted but not recorded there would be
deleted by the next launch's collector: precisely the crash the
checkpoint exists to survive.

Storage posture, given this writes material that previously lived only
in RAM (exported credentials, echoed tokens, URLs with embedded PATs):
default off, `0700` directory, `0600` files, excluded from Time
Machine, nothing created at all while disabled. Snapshot keys are
validated as 16 hex digits — they come from a user-writable file and
are interpolated into a path. Scratch terminals, the quick terminal,
remote panes and the alternate screen are never captured. Four
independent caps (per-pane, total, age, orphan collection) bound the
directory.

Restore is inert by construction: rows are inserted as history rather
than replayed as VT input, so a snapshot cannot move the cursor, set a
title, or write to the pty.
Found by running it: the separator inserted at restore was being
captured back into the next snapshot, so every relaunch buried one more
in the history. Three launches left two stale separators — one of them
rewrapped into three rows by a width change — plus the previous
session's prompt, sitting in the middle of what should read as one
continuous record.

Capture now skips the pane's synthetic annotation row (the separator,
or the Stage 0 notice, whichever was inserted). The exclusion is a
session-absolute row so eviction cannot make it point at an unrelated
live row, and it is cleared once eviction passes it. Three generations
now leave zero separators in the record and keep only the real prompts.

Also widens the rule fallback: at 46 columns the separator lost both
rules and rendered as a bare sentence, which reads as output rather
than as a boundary.

Adds `noa-grid --example dump-snapshot`, which decodes a `.nsb` and
prints each row's text and pen. The format is opaque, so without it a
wrong-looking restored pane gives no way to tell whether the capture,
the file, or the restore is at fault — this whole bug was found with it.
Tri-engine review (codex + claude; agy failed to run) surfaced one
dominant defect class and several storage-posture gaps.

Stale absolute row indices. `record_rows`/`annotation_row` are
session-absolute, and both a column-count reflow and a scrollback clear
renumber that space wholesale — the gutter would paint over live rows,
capture's `skip_row` would delete a real row from the record (reviving
the separator-accumulation bug), and `Discard restored history` would
drop live history off the front. `Terminal` already bumps a coordinate
generation on exactly those operations and deliberately leaves it alone
for ordinary eviction, so comparing against it is the complete
staleness test. On mismatch the marking is dropped rather than acted
on; the boundary itself is a row, so it survives reflow and the record
stays legible. Measured on a real launch: the marking stays live across
every frame, so the guard costs nothing in the normal path.

The byte budget counted only row bodies. Style, link and grapheme
tables are written alongside them and are not bounded by cell count —
one OSC 8 URI can reach the parser's 12 MiB ceiling on its own and blow
a 1 MiB budget with a handful of rows. The budget is now checked
against the real encoded size, and a single link is capped at 4 KiB
(the cell's text is kept either way).

`emit_logical_line` did not terminate at width 1 with a wide glyph:
backing off to keep the lead with its spacer left the split at the row
start, consuming nothing. Confirmed by a test that had to be killed.

Storage posture. No more `PathBuf::from(".")` fallback — an unresolved
data directory now means persistence is unavailable, rather than
writing terminal output into the process's working directory and
chmodding a directory noa did not create (which also stops spawning the
worker thread when the feature is off). Capture is gated on
`window-save-state` too, since the key that makes a snapshot reachable
is written by `persist_session`, which is a no-op while session state
is disabled — writing unreachable records is cost with no benefit.
Turning the feature off, or setting the budget to zero, now purges what
is already on disk instead of waiting for the next launch's collector.

The manual checkpoint command now pairs with `persist_session` like the
timer does, or the next launch's collector deletes the file the user
explicitly asked for. The checkpoint ceiling is anchored on the start
of the dirty streak rather than the last checkpoint, so a pane that
never goes quiet is still captured before its first one, and is clamped
from below so a burst after a long idle is not captured mid-stream.

The record gutter is no longer drawn while the alternate screen is
active — its rows are a different coordinate space and would collide.
Encoding moves off the terminal lock. Capture now clones rows under the
lock (a memcpy) and hands them to the persist worker, which does the
style interning and deflate. The io thread blocks on that same mutex to
drain the pty, so the previous arrangement — deflate inside the locked
section, once per pane, at every checkpoint and at quit — stalled the
one path this repo is measured on. The comment claiming the lock was
held "only for the encode" is now true.

Startup no longer serializes per-pane I/O. Referenced snapshots are read
and inflated in parallel before the restore loop, so the winit thread
pays one pane's latency instead of every pane's; only the prepend, which
touches the terminal, stays on it.

Storage lifecycle. Interrupted writes leave a `.tmp` holding the same
plaintext the snapshot does, and the collector only looked at `.nsb` —
so turning the feature off did not actually drain the directory. Backup
exclusion is applied on every `ensure_dir` rather than only at creation,
since its result is discarded and one transient failure would otherwise
put every later snapshot into Time Machine forever. Closing a pane, tab
or window now deletes that pane's snapshot instead of leaving it for the
next launch's sweep — closing a tab is the most direct way someone says
they are done with that output.

Restore no longer adopts the saved key. Two instances restoring the same
session would claim the same file and overwrite each other; each pane
now mints its own and drops the file it was restored from once its first
capture lands, which keeps crash-resilience through the gap and leaves
one file per pane in steady state (verified across four relaunches).

Enabling the feature says so. The config file is the documented way to
turn it on and had no acknowledgement at all; it now logs what is being
written and where, once per enable (spec §9).

Also: the inflate ceiling derives from the configured budget and rejects
rather than truncating; decoded hyperlink ids are bounded against the
table that came with them and the remap runs unconditionally, so a
corrupt file cannot adopt a live URI; and a record boundary clears the
preceding row's wrap flag so rewrap cannot glue the separator onto real
output.

`a_skipped_row_below_the_eviction_point_is_ignored` passed `usize::MAX`,
which is above every valid row — it tested the wrong branch. It now
evicts for real, and two companion tests cover the above-range and
lands-on-a-live-row cases.
Stage 2 of docs/specs/scrollback-persistence.md, less the badge.

`scrollback-persist-encrypt` seals each snapshot with AES-256-GCM under
a 256-bit key generated on first use and kept in the login keychain,
marked non-syncable so a record of one machine's terminal output never
appears on another. The container wraps noa-grid's format from outside,
so the grid crate stays free of any notion of a keychain and a snapshot
decodes identically whether or not it was ever sealed. Reading branches
on the file rather than on the config, so toggling the setting does not
strand what is already stored. When encryption is on and no key can be
obtained, the write is declined rather than silently falling back to
plaintext — the user asked for encryption specifically.

Off by default, because it is a real trade rather than a free win: lose
the keychain entry and the records are unreadable. `security-framework`
3.7 does not expose `kSecAttrAccessible`, so the key gets the login
keychain's default (readable only while unlocked) plus an explicit
no-sync; recorded as a deviation.

Kitty-graphics placeholder cells are replaced with an `[image]` marker
at capture. The image itself lives in a store that is not persisted and
would not exist in the next process, so serializing the placeholder
scalars restores a run of tofu, while dropping them silently erases the
fact that anything was there.

The crypt tests deliberately go through a key-supplied split rather than
the public entry points: an earlier version called `seal()` directly and
minted a real keychain item on the developer's machine as a side effect
of `cargo test`.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3b0bb7ba4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +436 to +439
if let Some(key) = surface.scrollback_key.clone()
&& let Some(persister) = self.scrollback_persister.as_ref()
{
persister.discard(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delete the source snapshot when discarding history

When this command is used immediately after restoration, surface.scrollback_key is still None; the on-disk file that supplied the displayed record is stored in surface.superseded_scrollback_key. Consequently, the command removes the rows visually but leaves the sensitive snapshot and its session reference intact, so a crash followed by relaunch restores the supposedly discarded history. Delete the superseded key and persist the updated session as part of this operation.

Useful? React with 👍 / 👎.

Comment on lines +170 to +172
// This pane's own file now holds everything the restored one did.
if let Some(superseded) = superseded {
persister.discard(superseded);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain the old snapshot until its replacement succeeds

For the first capture after restoring a pane, persister.save only queues an asynchronous write, yet the valid source snapshot is immediately queued for deletion. If encoding, encryption/keychain access, or the filesystem write fails—or the process crashes before the worker finishes—the source can be deleted while the session has already switched to the new key, leaving no restorable record. The old key should only be removed after the worker confirms that the replacement was durably written.

Useful? React with 👍 / 👎.

Comment on lines +217 to +218
let scrollback_persist_disabled = previous.scrollback_persist.persists()
&& !(applied.scrollback_persist.persists() && applied.window_save_state.restores());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Purge snapshots when the per-pane limit becomes zero

When a config reload changes only scrollback-persist-limit from a positive value to 0, persistence mode and window-save-state remain enabled, so this condition is false and existing snapshots are not deleted. They can remain on disk indefinitely while panes are quiet, despite zero being documented and handled by capture as an instruction to retain nothing. Include the positive-to-zero limit transition in the immediate purge condition.

Useful? React with 👍 / 👎.

@simota
simota merged commit 52575c1 into main Jul 30, 2026
1 check passed
@simota
simota deleted the feat/scrollback-persistence branch July 30, 2026 23:24
@simota simota mentioned this pull request Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant