Skip to content

Serve PTY scrollback from on-disk pty.log instead of a 256KB ring#275

Merged
edwin-zvs merged 3 commits into
mainfrom
pty-scrollback-from-disk
May 30, 2026
Merged

Serve PTY scrollback from on-disk pty.log instead of a 256KB ring#275
edwin-zvs merged 3 commits into
mainfrom
pty-scrollback-from-disk

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Problem

Scrolling up in a PTY-wrapped session (codex / claude / antigravity) loses content or breaks formatting after ~2 pages of page-up. Zarvis sessions don't have the symptom.

Root cause: each session kept a 256 KiB in-memory ring (PTY_RING_CAP in crates/daemon/src/session.rs) of raw PTY bytes. pty_replay returned a snapshot of that ring. The TUI's shadow vt100 parser was fed only from that snapshot, so once the user scrolled past the 256 KiB worth of bytes the ring held, there was nothing to render. Older bytes had been evicted at write time.

The existing ultracode session on disk illustrates it: 2.8 MB pty.log but only the last 256 KiB ever reached the TUI — an 11× gap, exactly the "~2 pages back" boundary.

Zarvis sidesteps this entirely because its scrollback is the structured ItemHistory, not raw PTY bytes.

Fix

Switch pty_replay to read the tail of the on-disk pty.log directly (storage.read_pty_tail), bounded by a new constant PTY_REPLAY_CAP = 8 MiB. The TUI feeds the bytes into its vt100 parser whose SCROLLBACK_MAX = 5000 rows is now the real ceiling on what survives in scrollback — exactly what the row-count cap was meant to be. Disk reads happen once per attach.

The in-memory ring was the only consumer of PtyState::push / snapshot and the startup rehydration, so they're all gone. PtyState keeps the size field for parser sizing on attach. Disk append is unchanged.

What this does not fix

The secondary codex-only issue (in-place \x1b[2J\x1b[H redraws not pushing rows to vt100 scrollback) is a separate concern and not addressed here. With the cap raised from 256 KiB to 8 MiB, that issue becomes the next thing the user hits, but it's a much smaller fraction of the symptom space than the ring cap was.

Test

Two new daemon tests:

  • pty_replay_returns_full_disk_tail_not_just_old_ring — writes 1 MiB (4× the old ring) to pty.log; verifies pty_replay returns the full 1 MiB.
  • pty_replay_caps_at_replay_max_for_huge_logs — writes PTY_REPLAY_CAP + 1 MiB; verifies the response is exactly PTY_REPLAY_CAP bytes AND that it's the tail of the file (most recent), not the head.
cargo test -p agentd --bin agentd   # 53 passed (51 prior + 2 new)
cargo test -p agentd-cli            # 188 passed
cargo build                          # clean

🤖 Generated with Claude Code

edwin-zvs and others added 3 commits May 30, 2026 08:45
Phase 1 of the web UI polish pass — small, high-leverage fixes for the
four most-noticed rough edges (scroll jumps on click, the flash/lurch on
session load, the confusing Insert/Enter buttons, and sluggishness):

- Scroll anchoring: drop `scroll-behavior: smooth` on the transcript so
  programmatic "stick to bottom" lands instantly; preserve the session
  list's scrollTop across its innerHTML rebuild so clicks/background
  updates no longer teleport it to the top; rAF-coalesce scrollToBottom
  with a passively-tracked at-bottom flag (no forced reflow per token).
- Composer: collapse Insert/Enter to a single "Send" for chat sessions
  (byte-identical there); show a secondary "Type" only for PTY-backed
  sessions; add titles/aria + a Shift+Enter hint; restore typed text if
  a send fails (guarded against session switches).
- Performance: throttle the header matrix loop before its layout read
  and gate it on document.hidden (~60 -> ~8fps; idle when backgrounded).
- Polish/a11y: composer button hover/active/focus-visible states, a
  global :focus-visible ring, a global prefers-reduced-motion block,
  on-accent contrast fixes (#fff -> #06160c), and operator-row alignment.

All changes are confined to crates/daemon/assets/index.html and were
adversarially reviewed for regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously each session kept a 256 KiB in-memory ring of raw PTY bytes
and `pty_replay` returned a snapshot of that ring. After ~2 pages of
page-up in a codex/claude/antigravity session the user had scrolled past
what the ring held — older bytes had been evicted at write time and the
shadow vt100 parser had nothing to render, so content was lost or the
formatting fell apart. Zarvis didn't show the symptom because its
scrollback is a structured `ItemHistory`, not raw PTY bytes.

Switch `pty_replay` to read the tail of the on-disk `pty.log` (bounded
by `PTY_REPLAY_CAP = 8 MiB`). The TUI feeds the bytes into its vt100
parser, whose `SCROLLBACK_MAX = 5000` rows is now the real ceiling on
what survives in scrollback — exactly what the row-count cap was meant
to be. Disk reads happen once per attach.

The ring was the only consumer of `PtyState::push` / `snapshot` and the
startup `read_pty_tail` rehydration, so they're all gone. Disk append is
unchanged. `PtyState` keeps the size field for parser sizing on attach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two gaps in the prior commit:

- `pty_replay_returns_empty_when_pty_log_missing` — a brand-new session
  has no `pty.log` yet; replay must return an empty body rather than
  surfacing the underlying read error.
- `pty_replay_preserves_pty_size_through_round_trip` — the refactor
  moved the bytes off `PtyState` but still reads `size` from the same
  lock. Lock that down so a future tidy-up doesn't silently start
  returning `None`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@edwin-zvs
edwin-zvs merged commit 8a30660 into main May 30, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the pty-scrollback-from-disk branch May 30, 2026 19:19
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