services/virtual-terminal.ts has a family of real defects that #654 deliberately left alone, each a case where the emulator's handler differs from a real terminal.
Filing them as one issue rather than seven, because a capture shows none of them is reachable mid-turn, so they share a single priority and a single unblocking condition.
Verified state
Checked 2026-08-12 against origin/main @ 86eeb2a1, and against a capture of Claude Code v2.1.228 spawned at the streamer's real 120x40 (threadbase-streamer docs/2026-08-12-claude-pty-capture.md).
The defects
CSI 2J and CSI 3J are treated identically as total grid annihilation (:249-260, this.grid = [[]]). A real terminal clears only the viewport on 2J; 3J clears scrollback. Two existing tests assert the destructive behaviour (__tests__/unit/services/virtual-terminal.test.ts:490, :556), so changing it is a test rewrite as well as a behaviour change.
CSI 1J (erase from start of screen to cursor) falls through the if/else if silently, with no counter increment.
CSI 0J truncates the grid to row + 1, permanently deleting scrollback below the cursor.
CSI S (:285-288) splices the grid's oldest rows instead of appending blanks at the viewport bottom, destroying scrollback. Not a cursor bug — the splice's implicit shift compensates exactly, so do not carry that claim forward. The loss is also invisible: shrinking grid.length shifts the derived viewport origin by the same amount, so the viewport looks unchanged.
CSI L / M / T splice absolute scrollback with no viewport bottom.
CSI r (DECSTBM) is parsed and discarded (:295-296); no scroll region is modelled.
ESC 7 / ESC 8 / ESC M (DECSC/DECRC/RI) are silently dropped at :193-194, and CSI s / u sit in IGNORED_CSI — there is no saved-cursor state in the class at all.
Why they are all one priority
The capture's CSI histogram, read for presence and absence only:
during the turn: G B C m H K
startup only (1x): r J c ESC7 ESC8
never emitted: S T L M A D f
S, T, L, M never appear. r, J (all 2J), ESC7 and ESC8 are a single terminal-init sequence — ESC7 ESC[r ESC8, contiguous at offsets 0, 2 and 5 — and a startup 2J annihilates an empty grid, which is harmless. Bare ESC[r resets margins to the full screen rather than setting a region, so ignoring it reaches the correct end state; no parameterised r appears anywhere.
Nothing here is reachable mid-turn, so the queue is uniformly downgraded rather than reordered.
Three separate attempts were made to promote one of these items on the strength of a count of ~30. All three were the same artifact: turn.raw concatenates 36 snapshots, each a full ring-buffer dump, so every snapshot replays the startup bytes. Verified per-snapshot — each contains exactly one 2J at the same offset, and the first gap between occurrences is exactly snap-000.raw's length. Read that histogram for presence and absence; never for magnitude.
What would change this
One capture of a tool-heavy turn — reading files, triggering a hook, producing scrolling output. The existing capture is a single output-dominated turn with no tool use, so anything tool-triggered is unsampled, and L/M in particular are most plausible around file diffs.
Absence in one capture is not absence in general.
If any of these turns out to fire mid-session, it comes back out of this issue as its own P2 with the measured evidence.
Not included
CSI A/B/H/f were fixed in #654 and are not part of this. Note that A is never emitted either — its clamp is defensive rather than reachable, in the same category as the above, except that it rode along free.
Plan and full rationale: threadbase-streamer#537 → docs/plans/2026-08-12-viewport-relative-cursor-positioning.md, "Explicitly OUT of scope".
services/virtual-terminal.tshas a family of real defects that #654 deliberately left alone, each a case where the emulator's handler differs from a real terminal.Filing them as one issue rather than seven, because a capture shows none of them is reachable mid-turn, so they share a single priority and a single unblocking condition.
Verified state
Checked 2026-08-12 against
origin/main@86eeb2a1, and against a capture of Claude Code v2.1.228 spawned at the streamer's real 120x40 (threadbase-streamerdocs/2026-08-12-claude-pty-capture.md).The defects
CSI 2JandCSI 3Jare treated identically as total grid annihilation (:249-260,this.grid = [[]]). A real terminal clears only the viewport on2J;3Jclears scrollback. Two existing tests assert the destructive behaviour (__tests__/unit/services/virtual-terminal.test.ts:490,:556), so changing it is a test rewrite as well as a behaviour change.CSI 1J(erase from start of screen to cursor) falls through theif/else ifsilently, with no counter increment.CSI 0Jtruncates the grid torow + 1, permanently deleting scrollback below the cursor.CSI S(:285-288) splices the grid's oldest rows instead of appending blanks at the viewport bottom, destroying scrollback. Not a cursor bug — the splice's implicit shift compensates exactly, so do not carry that claim forward. The loss is also invisible: shrinkinggrid.lengthshifts the derived viewport origin by the same amount, so the viewport looks unchanged.CSI L/M/Tsplice absolute scrollback with no viewport bottom.CSI r(DECSTBM) is parsed and discarded (:295-296); no scroll region is modelled.ESC 7/ESC 8/ESC M(DECSC/DECRC/RI) are silently dropped at:193-194, andCSI s/usit inIGNORED_CSI— there is no saved-cursor state in the class at all.Why they are all one priority
The capture's CSI histogram, read for presence and absence only:
S,T,L,Mnever appear.r,J(all2J),ESC7andESC8are a single terminal-init sequence —ESC7 ESC[r ESC8, contiguous at offsets 0, 2 and 5 — and a startup2Jannihilates an empty grid, which is harmless. BareESC[rresets margins to the full screen rather than setting a region, so ignoring it reaches the correct end state; no parameterisedrappears anywhere.Nothing here is reachable mid-turn, so the queue is uniformly downgraded rather than reordered.
Three separate attempts were made to promote one of these items on the strength of a count of ~30. All three were the same artifact:
turn.rawconcatenates 36 snapshots, each a full ring-buffer dump, so every snapshot replays the startup bytes. Verified per-snapshot — each contains exactly one2Jat the same offset, and the first gap between occurrences is exactlysnap-000.raw's length. Read that histogram for presence and absence; never for magnitude.What would change this
One capture of a tool-heavy turn — reading files, triggering a hook, producing scrolling output. The existing capture is a single output-dominated turn with no tool use, so anything tool-triggered is unsampled, and
L/Min particular are most plausible around file diffs.Absence in one capture is not absence in general.
If any of these turns out to fire mid-session, it comes back out of this issue as its own P2 with the measured evidence.
Not included
CSI A/B/H/fwere fixed in #654 and are not part of this. Note thatAis never emitted either — its clamp is defensive rather than reachable, in the same category as the above, except that it rode along free.Plan and full rationale: threadbase-streamer#537 →
docs/plans/2026-08-12-viewport-relative-cursor-positioning.md, "Explicitly OUT of scope".