Skip to content

feat(terminal): replay full tmux scrollback on terminal reload + CRLF normalization#148

Merged
Ark0N merged 3 commits into
Ark0N:masterfrom
aakhter:cod-164-scrollback-replay-crlf
Jul 12, 2026
Merged

feat(terminal): replay full tmux scrollback on terminal reload + CRLF normalization#148
Ark0N merged 3 commits into
Ark0N:masterfrom
aakhter:cod-164-scrollback-replay-crlf

Conversation

@aakhter

@aakhter aakhter commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

A full page reload (GET /api/sessions/:id/terminal without ?tail=) previously only replayed Codeman's in-memory byte buffer — scrollback that had been pushed off the buffer was lost. This PR adds two complementary fixes:

COD-47: Full tmux scrollback replay

  • GET /api/sessions/:id/terminal (no ?tail=) now calls capture-pane -p -e -S -, capturing the entire tmux scrollback, not just the visible frame.
  • Tab switches (?tail=N) keep using the fast visible-frame path — no regression.
  • Shell-mode sessions apply a VPA-strip gate before returning the buffer (skips Ink redraw bloat).
  • MuxInterface.capturePaneBuffer gains an optional opts.fullHistory flag to request the deep capture.

COD-138: CRLF normalization of scrollback replay

Without this, full-history capture causes a "staircase" rendering bug: capture-pane -S - returns scrollback with bare \n line endings; xterm.js running with convertEol: false (correct for the live PTY stream, which carries real \r\n) treats each bare \n as "move down without carriage return," shifting each replayed line one column to the right.

Fix: new pure helper normalizeScrollbackEol() converts \r?\n\r\n (idempotent on CRLF; preserves lone \r overwrites; adds/removes no rows) applied at the raw-return seam. The absolute-cursor snapshot path (visible-frame / tab-switch) is unchanged.

Test plan

  • test/tmux-capture-full-history.test.ts (new, 5 tests) — full-history flag + VPA-strip gate
  • test/tmux-scrollback-eol.test.ts (new, 6 tests) — CRLF normalization invariants (no bare LF, CRLF idempotency, lone-CR preservation)
  • test/routes/session-routes.test.ts (13 new terminal-route tests added, 63 total pass)
  • tsc --noEmit: 0 errors
  • npm run build: clean
  • Prettier: clean

aakhter and others added 3 commits July 10, 2026 11:08
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.

- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
  full-history returns raw linear scrollback (skips the single-screen
  formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
  payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
  line-aligned) and returns source/fullSize/truncated metadata.

Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
A shell terminal could render output diagonally (each line shifted one
column right) after a full page reload or a cursor-query-failure replay.

Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -)
and its cursor-query-failure fallback returned raw scrollback, which tmux
joins with a BARE \n. The browser xterm uses convertEol:false (correct for
the live PTY stream, which carries real \r\n), so each bare \n dropped a
row without returning the cursor to column 0 -> staircase. The visible /
tab-switch path (formatPaneSnapshot) was immune because it repaints each
row with an absolute cursor CSI.

Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent
on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied
at both raw-return seams. The absolute-positioned snapshot path is unchanged.

Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a
preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across
tmux-scrollback-eol + tmux-capture-full-history + tmux-manager +
routes/session-routes; build, tsc, prettier, frontend-syntax clean.
…p + bound the capture (PR Ark0N#148)

- Replace the 'missing ?tail means reload' overload with an explicit ?full=1
  query param: the frontend's first buffer load after a page load (selectSession)
  now requests full=1, tab switches keep ?tail=, and the legacy no-param callers
  (response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame
  path — the COD-47 feature was previously unreachable from a real reload.
- When the full-history capture succeeds, return it ALONE instead of prepending
  the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the
  byte history, and ED2 clears only the viewport so the concat replayed the whole
  conversation twice in xterm scrollback. The history+clear+frame concat stays
  for the visible-frame/tab-switch path.
- Pass an explicit execSync maxBuffer for the full-history capture (configured
  terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly
  the multi-MB captures the feature exists for; log ENOBUFS concisely instead of
  dumping the truncated stdout.
- Bound the capture itself via -S -<N> derived from the configured tmux
  history limit (was unbounded -S -), and add -J so lines hard-wrapped at the
  capture-time pane width reflow in the browser xterm.
- Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the
  regex normalization passes) so multi-MB captures don't stall the event loop
  normalizing bytes that get sliced away.
- Tests: route tests updated for ?full=1 semantics (capture-alone response,
  config-forwarded capture bounds, byte-history fallback, no-param requests
  stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N>
  flags and explicit maxBuffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Ark0N Ark0N merged commit e84a383 into Ark0N:master Jul 12, 2026
2 checks passed
@Ark0N

Ark0N commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Merged — thank you @aakhter! Full-scrollback replay makes reloads dramatically more useful. Review wired the trigger end-to-end (an explicit ?full=1 requested by the initial page load — the no-tail heuristic never fired from the real frontend paths), sized execSync's maxBuffer from config (the 1MB default silently killed exactly the big captures the feature exists for), bounded the capture, and returned it alone (the byte-buffer prepend replayed the whole history twice). 🙏

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.

3 participants