Skip to content

perf(terminal): tab switch to a fullscreen Claude tab loads only its current screen - #588

Open
Ark0N wants to merge 1 commit into
fix/history-notice-lazyfrom
perf/fullscreen-tab-switch
Open

Ark0N wants to merge 1 commit into
fix/history-notice-lazyfrom
perf/fullscreen-tab-switch

Conversation

@Ark0N

@Ark0N Ark0N commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Stacked on #583. This PR targets #583's branch, so the diff below is only the tab-switch change. Once #583 merges, retarget it to master (and rebase if #583 was squash-merged).

When you switch back to a terminal tab, the web UI reloads that session's terminal from the server. For a fullscreen Claude tab, every switch after the first downloaded and replayed a 1 MB tail of old screen redraws, which cost about 1.3 to 1.7 s in the browser plus 136 to 1,070 ms on the server. This PR makes those switches load only the current screen, a few KB, which is what a page load already does. It builds on #583, which adds the paneHistoryLines field this relies on. No server or API changes beyond that field.

Why the tail was wasted work

  • A fullscreen Claude pane runs in the terminal's alternate screen. tmux keeps no scrollback for it (#{history_size} is 0), because Claude keeps the conversation itself and the wheel scrolls it there.
  • After a session's first load per page, selectSession fetched ?tail=1048576: the last 1 MiB of the server's raw output stream plus the current frame. For these panes that stream is earlier redraws of the same screen. One measured pane gave 450 non-blank scrollback rows, only 44 of them distinct.
  • An idle tab paid for it twice. The app first repaints its cached copy for an instant first paint, then repaints the fetched one, and the two rarely match.

What this PR changes

  • Every terminal response (tab switch, server-triggered refresh, scroll-to-top re-pull) records paneHistoryLines per session (_notePaneHistory).
  • On a tab switch to a non-shell session whose last report was 0, selectSession requests full=1 instead of the tail. For such a pane that capture is the visible frame. That is the same request and the same rendering a page load already uses for every TUI session.
  • Only a reported 0 counts. A response without the field (an older server, a byte-history fallback) forgets the entry, so the switch goes back to the tail.
  • A pane that starts keeping history (Claude switched to its inline view) reports a positive count on the next response, and the switch after that uses the tail again.
  • Not changed: shells, the tile grid, the server-triggered refresh path and its downgrade guard (a refresh must not wipe rows the user may be reading), and the first load of each session per page.

Evidence

All 11 live sessions on the measured instance were fullscreen Claude panes with 0 tmux scrollback. Real tail and full payloads from two of them, replayed through the real selectSession in Chromium, alternating the two tabs 12 times, old logic vs new:

old (tail) new (full=1)
median switch, browser side 1.26 to 1.66 s 0.10 to 0.19 s
median parse/replay 1.1 to 1.5 s 2 to 3 ms
data per switch ~1 MB (1.6 MB as JSON) ~5 KB
server time (prod, Server-Timing) 136 to 1,070 ms 89 to 330 ms

The geometry fields were stripped for this run, so the old path was not penalised by a resize retry it would not take on the user's own screen.

Unstubbed, on an isolated instance with two real fullscreen Claude sessions:

  • Every switch after the first requested full=1.
  • The frame rendered correctly, with the composer and caret in place.
  • Typed text reached the pane (checked with tmux capture-pane).

Side effect worth knowing

After a switch, the local scrollback of such a tab is empty instead of holding repeated frames. That matches what a page load already shows. It is also what lets _maybePageCliTranscript turn a gesture into PageUp/PageDown for the CLI's own transcript when wheel forwarding is off: that fallback only engages when there is no local scrollback. Shift+Wheel on such a tab now scrolls nothing locally rather than through repeats.

Reviewing the diff

The logic is about 50 lines in src/web/public/app.js:

  • _paneHistoryLines (constructor) and _notePaneHistory (new method).
  • Three one-line record calls.
  • The useFullHistory condition in selectSession.
  • The cleanup in _clearHistoryTruncation.

The rest is tests and docs:

  • test/pane-history-memory.test.ts (CI gate) runs the real methods in a vm.
  • test/fullscreen-tab-switch-capture.browser.test.ts (browser suite, registered in config/test-suites.ts) drives the real client with a stubbed terminal route: hollow vs inline panes, a pane gaining history, and an unknown report. Disabling the new condition fails it.
  • test/history-truncation-notice.test.ts: the static pin on the useFullHistory expression is updated.

Risk

  • Switching to a pane that just started keeping history costs one full capture. This is the case where the pane was hollow at the last report but no longer is. It is bounded by the server's existing byte cap and is the same canonical load a page load does.
  • The full capture is a linear replay ending in a relative cursor move, not an absolutely positioned frame. So the geometry-retry repair (which only applies to mux-visible) does not run for these switches. This is unchanged from how page loads already behave.

Testing

  • npm test (the CI gate): 10,381 passed.
  • typecheck, lint, format:check, check:frontend-syntax and check:browser-excludes clean.
  • Browser suite: the new test plus capture-geometry-retry and capture-load-window pass (11 tests).

…s screen

After a session's first load per page, a tab switch fetched a 1 MiB tail of
the server's byte stream. For a fullscreen claude pane (alternate screen,
tmux `history_size` 0) that tail is old repaints of one frame, and an idle
tab parsed it twice (the cached copy, then the fresh one).

Every terminal response now records `paneHistoryLines` per session, and
`selectSession` takes `full=1` whenever the last report was 0. For such a
pane that capture is the visible frame, a few KB, which is also what a page
load already shows. A response without the field forgets the entry, so
unknown never counts as empty; a pane that starts keeping history goes back
to the tail on the next switch. Shells and the refresh path are unchanged.

Measured with real prod payloads through the real client in chromium:
median switch 1.26-1.66 s -> 0.10-0.19 s, parse ~1.1-1.5 s -> 2-3 ms.

Co-Authored-By: Claude Opus 5.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.

2 participants