Skip to content

fix(terminal): replay restores the DEC modes the capped buffer evicted (#843) - #1041

Merged
Juliusolsson05 merged 3 commits into
mainfrom
fix/opencode-wheel-remount
Sep 19, 2026
Merged

Juliusolsson05 merged 3 commits into
mainfrom
fix/opencode-wheel-remount

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Part 1 of #843 (the mouse wheel is dead in an OpenCode terminal pane after it remounts). Part 2, routing Jump to Latest through the TUI server, follows in its own PR.

Why

attachAgentPty / attachTerminal replay the last 512 KiB of PTY output to a newly mounted xterm.

  • A full-screen TUI writes its modes once at startup. OpenCode 1.18.31, recorded for this PR, writes ?1049h ?2004h ?1000h ?1002h ?1003h ?1006h.
  • At 60 fps of repaint, the cap evicts that preamble within minutes.
  • The remounted xterm then sat on the normal buffer with mouse tracking off, so the wheel reached nobody. The pane looked right, because a full-frame repaint renders the same on either buffer.

Change

  • CappedTextBuffer gets an onEvict hook. It reports evicted text in stream order, including an oversized chunk's discarded head.
  • DecModeTracker folds the evicted bytes into the terminal state as of the replay's first byte. It follows xterm.js semantics, checked against the shipped @xterm/xterm bundle:
    • mouse protocols 9/1000/1002/1003 are exclusive, and resetting any of them turns tracking off;
    • encodings 1006/1016 are exclusive, a reset returns to default, and 1005/1015 are ignored as in xterm.js;
    • 47/1047/1049 select the alternate screen;
    • ESC c resets everything;
    • it also tracks the flags 1, 25, 1004 and 2004;
    • a sequence cut by an eviction boundary is carried over, as a flat copy so it can't retain a large evicted chunk (fix(#288): make sub-agent body truncation actually free memory (V8 slice trap + toolUseResult miss) #321).
  • TerminalReplayBuffer.replay() = the mode prefix + the tail. Both attach paths use it. read() stays byte-exact for sessions.terminalRead paging.
  • Not replayed: ?2026 synchronized output (per-frame; a prefix could freeze painting) and terminal queries (they would send stale answers to the program).

Why the state at the replay start, not the current state: the first fix prefixed the current modes and was reverted. It moved retained normal-screen output onto the alternate buffer, where the retained ?1049h wiped it (docs/superpowers/research/2026-09-08-post-merge-regression-audit.md).

Tests (real xterm headless, real OpenCode bytes)

  • testing/fixtures/terminal-replay-modes/: the real OpenCode 1.18.31 startup, recorded under node-pty and sanitized. The README gives provenance.
  • terminalReplayModes.test.ts compares the replayed terminal with one fed the live stream:
    • the recording with its preamble evicted (alternate screen, any mouse);
    • normal output before a retained ?1049h, which fails the reverted "current modes" design (mutation-checked);
    • mouse-protocol exclusivity and ESC c;
    • a sequence cut by a boundary;
    • a byte-exact replay when no mode was set.
  • sessionManager.screenGate.test.ts: through the real SessionManager at the real 512 KiB cap, attachAgentPty gives a remounted xterm the alternate screen and any mouse tracking. This fails with read() in the attach path.

src/main/sessions and sessionManager pass (196), and npx tsc -b is clean.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits September 19, 2026 01:33
Real OpenCode 1.18.31 startup bytes (recorded under node-pty and
sanitized) show the one-time preamble: ?1049h, ?2004h, ?1000h ?1002h
?1003h and ?1006h. Every case runs through a real headless xterm and
compares the replayed terminal with one that saw the live stream:
- the recording, with the preamble evicted, still replays onto the
  alternate screen with any-event mouse tracking. This is checked for the
  buffer on its own and through SessionManager.attachAgentPty at the real
  512 KiB cap;
- normal-screen output before a retained ?1049h stays on the normal
  screen, which is the case the reverted "current modes" fix broke;
- mouse protocols follow xterm's exclusive state (1000 then 1003, 1003
  then 1000, reset, ESC c);
- a sequence cut by an eviction boundary still counts;
- a session that set no mode replays byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#843)

A full-screen TUI writes its alternate-screen and mouse modes once at
startup, and 512 KiB of 60 fps repaint evicts them within minutes. A
remounted xterm was fed only the tail. It sat on the normal buffer with
mouse tracking off, so the wheel did nothing, and the pane looked fine
because a full-frame repaint renders the same on either buffer.

CappedTextBuffer now reports what it evicts. A DecModeTracker, which
models xterm's exclusive mouse protocols and encodings, the 47/1047/1049
alternate-screen aliases, ESC c, and cursor keys, cursor visibility,
focus and bracketed paste, folds those evicted bytes into the state as of
the replay's first byte. TerminalReplayBuffer.replay() prefixes that
state, and both attach paths use it. read() stays byte-exact for the
paged raw reads.

This is the state at the replay START, not the current state: the first
fix prefixed current modes and was reverted, because it moved retained
normal-screen output onto the alternate buffer (see the 2026-09-08
regression audit). Synchronized output (2026) and terminal queries are
deliberately not replayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the #1041 review (APPROVE, low findings, all reproduced there):
- ?2031 (theme-change notifications) is tracked. OpenCode sets it once,
  and without it a remounted pane stopped following app theme switches.
- A sequence straddling the eviction boundary is replayed whole: the
  held fragment goes between the prefix and the tail, instead of xterm
  printing "49h".
- An ESC that cuts a private-mode sequence short now starts the next
  sequence, as xterm parses it.
- ESC c keeps a hidden cursor hidden, matching the shipped xterm.
  Soft reset (DECSTR) is documented as not modelled.
- The shell attach path has a test at the real 256 KiB cap. Reverting
  attachTerminal to read() had passed every test.
- The fixture README states exactly what was edited: the user name.
  The wrapped temporary path could not be replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review (APPROVE) findings folded in 37e83e08:

  1. ?2031 (theme-change notifications) is tracked.
  2. A sequence straddling the eviction boundary is replayed whole (prefix + held fragment + tail), with a test.
  3. An ESC that cuts a sequence short restarts parsing on it, with a test.
  4. Soft reset (DECSTR) is documented as not modelled.
  5. ESC c keeps the cursor-visibility flag, as the shipped xterm does.
  6. A shell attach-path test at the 256 KiB cap. It fails with read().
  7. The fixture README now says exactly what was edited: the user name only. The wrapped temporary path can't be string-replaced, and it isn't sensitive.

Tests: src/main/sessions plus both SessionManager suites pass, and tsc is clean. I'll merge once CI is green.

@Juliusolsson05
Juliusolsson05 merged commit 82babd2 into main Sep 19, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/opencode-wheel-remount branch September 19, 2026 09:09
Juliusolsson05 added a commit that referenced this pull request Sep 19, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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