Skip to content

fix(terminal): load a shell from tmux's rendered history, not the byte recording - #601

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
timkjr:fix/shell-replay-from-tmux
Oct 13, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
timkjr:fix/shell-replay-from-tmux

Conversation

@timkjr

@timkjr timkjr commented Oct 10, 2026

Copy link
Copy Markdown

A shell's automatic loads (tab select, dropped-output recovery, clear terminal, a split/grid tile's load) replayed session.terminalBuffer, the raw PTY recording. That recording holds full-screen repaints (an ssh'd tmux, less, any TUI) drawn for the pane height of the moment. Replayed into a terminal with fewer rows, each repaint's last newline pushes its top row into scrollback, so one line tmux holds once shows up dozens of times (26 copies in the case that found this, after the pane went from 24 to 23 rows). Remote SSH shells hit it constantly, since their inner tmux repaints often.

  • Shells now load a bounded window of tmux's rendered history instead (SHELL_LOAD_QUERY: full=1&tail=1MiB&lines=10000), the same source the scroll-to-top pull from fix(terminal): let a Shell pane's scroll-up reach tmux history #494 already uses. Agent sessions are unchanged.
  • lines=10000 bounds the synchronous capture-pane: measured at ~8 ms per 1000 lines, so ~0.1 s here vs ~0.8 s for a 100k-line history.
  • That bound is shallower than xterm's scrollback, so the server now reports the cut. The capture's existing display-message query also reads #{history_size} (PaneCaptureOptions.capturedHistorySize), and the route marks a full capture truncated / 'tail' when tmux holds more than it read. The banner keeps offering Load full history and scroll-to-top keeps pulling older lines. The byte cap still reports 'capped', and keeps precedence.
  • The banner no longer names an amount it cannot know ("less than 1 KB more") for a line cut; it says "Earlier output may still be retained".

Testing: route, capture, banner, flush-budget and tile tests updated/added; typecheck, lint, frontend syntax, format check and the full suite green on this branch. Running on my own deployment.

…e recording

A shell's automatic loads (tab select, dropped-output recovery, clear
terminal, a split/grid tile's load) replayed `session.terminalBuffer`,
the raw PTY recording, ahead of the visible frame. That recording holds
full-screen repaints (an ssh'd tmux, less, any TUI: cursor home, then the
rows joined by newlines, prompt on the last row) drawn for the pane
height of the moment. Replayed into a terminal with fewer rows, each
repaint's last newline scrolls and pushes its top row into scrollback,
so one line tmux holds once shows up dozens of times (26 copies in the
case that found this, after the pane went from 24 to 23 rows). Remote
SSH shell sessions hit it constantly: their inner tmux repaints often.

Shells now load a bounded window of tmux's rendered history instead
(`SHELL_LOAD_QUERY`: full=1&tail=1MiB&lines=10000), the same source the
scroll-to-top pull already used. `lines=10000` bounds the synchronous
capture-pane, measured at ~8 ms per 1000 lines (~0.1 s here vs ~0.8 s
for a 100k-line history). Because that bound is shallower than xterm's
scrollback, the server now reports a cut: the capture's existing
display-message query also reads `#{history_size}`
(`PaneCaptureOptions.capturedHistorySize`), and the route marks a full
capture `truncated` / 'tail' when tmux holds more than it read, so the
banner keeps offering Load full history and scroll-to-top keeps pulling
older lines. The byte cap still reports 'capped'. The banner no longer
names an amount it cannot know ("less than 1 KB more") for such a cut.

Agent sessions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, @timkjr, and welcome to Codeman. The PR moves a shell's automatic loads (tab select, dropped-output recovery, clear, a tile's load) off the raw PTY byte recording and onto a bounded window of tmux's rendered history, and has the server report when that line bound left older lines behind. The diagnosis of the repaint duplication is spot on, the measurements hold up (I got about 98 ms for a 10,000-line capture and 463 ms for 60,000 lines on tmux 3.4), and the full suite plus the static checks are green. A few things need changing before it can go in.

1. Nested-tmux shells lose their scrollback on every automatic load (src/web/public/constants.js:128, used at src/web/public/app.js:3215, :3316, :9341 and src/web/public/terminal-tile.js:775)

Remote SSH cases and Docker cases run the shell inside a second tmux (tmux -L codeman-remote on the host, or the in-container tmux), and that tmux's client puts the local pane into the alternate screen. tmux keeps no history for a pane in the alternate screen, so capture-pane -S -10000 returns one screen plus whatever the pane held before ssh attached. I reproduced it on tmux 3.4 with private sockets: after 300 lines inside a nested tmux the outer pane reported alternate_on=1 and history_size 29 (unchanged from before nesting), and the capture held 22 of the 300 lines.

The route returns a full capture on its own (src/web/routes/session-routes.ts:3143), and the shell paths skip the _replayWouldShrinkBuffer downgrade guard. So a tab switch, a drop recovery, a clear or a tile load now replaces the browser's scrollback with a single screen. truncated stays false (tmux reports almost no history), so no banner shows, and the scroll-to-top pull reads the same empty history. Before this PR those loads replayed the byte recording, which still carried the scrollback; it was only duplicated after a height change. The same happens in a local shell where someone runs tmux or ssh -t host tmux by hand.

Ask: add #{alternate_on} to the same display-message query that now reads #{history_size}, and keep the old tail= byte-recording load for a shell whose pane is in the alternate screen (decided in the route, or returned as a flag the client falls back on). Please add a route test for that branch. If you would rather fix the duplicates for nested shells in another way, say so here first, because that changes the trade-off for the remote case you mention in the description.

2. An unbounded full capture is reported as a 'tail' cut (src/web/routes/session-routes.ts:3172)

captureHistoryLines() always returns a number (the configured tmuxHistoryLimit when lines is absent), so the historyLimitLines !== undefined check never filters anything. On tmux older than 3.7, lowering the history limit only changes the global default, and live panes keep their deeper history. Every ?full=1 on such a pane, including the one Load full history sends, now comes back truncated: true, truncationReason: 'tail', and the banner offers a button that can never return more. I confirmed it with a throwaway route test (history_size 50,000, limit 10,000, ?full=1). Ask: only report the cut when the bound came from the client and is shallower than the ceiling (captureOpts.historyLimitLines < tmuxHistoryLimit && capturedHistorySize > captureOpts.historyLimitLines), with a test case for a request without lines.

3. Two docs still describe the old shell load (CLAUDE.md:279, docs/architecture-invariants.md:212)

Both say shell selection and drop recovery load "a bounded 1 MiB ?tail=". Please update them to SHELL_LOAD_QUERY, the new 'tail' report for a lines= cut, and the reason (repaints replayed at another height). CLAUDE.md is excluded from Prettier on purpose, so edit it by hand and do not run prettier --write on it.

Smaller things (fine to fold in, or I can do them at merge time):

  • src/web/public/terminal-tile.js:775: a bounded grid tile's shell load sends lines=10000 instead of its own _historyLinesQuery(), and the split's Pane B now sends lines= too, while the paragraph you edited at docs/architecture-invariants.md:826 still says Pane B sends none. Either use _historyLinesQuery() for the bounded tile or adjust that sentence.
  • src/web/public/constants.js:1918: when the 10,000-line window is also over 1 MiB, the banner's amount counts only the bytes the tail cut, while tmux holds more beyond the window.

Once 1 to 3 are addressed I will take another look and merge. Thanks again for digging into this one, the write-up made the bug easy to follow.

…ate screen, report a lines cut only below the ceiling

Review follow-ups for loading a shell from tmux's rendered history.

tmux adds no history while a pane is in the alternate screen, and a
nested tmux client (remote SSH and Docker cases, or tmux run by hand)
holds its pane there, so a full capture of it is one screen. The
display-message query that reads #{history_size} now also reads
#{alternate_on} (PaneCaptureOptions.capturedAlternateOn), and a request
carrying `alt=recording` (only SHELL_LOAD_QUERY sends it) is answered
the old way on such a pane: the visible frame over the tailed byte
recording. Agents, scroll pulls and Load full history are unchanged.
alternate_on cannot tell a nested tmux from vim or less, so a local
shell with one of those open takes the same fallback while it is open.

A full capture without `lines=` was reported as a 'tail' cut whenever
the pane held more than the configured ceiling (possible on tmux before
3.7), offering a Load full history that could never return more. The
cut is now reported only for a client bound below the ceiling.

The response carries `historyLinesCut` when that bound left lines in
tmux, and the banner then says earlier output may be retained instead
of naming the bytes a later tail cut, which undercount what tmux holds.
A bounded grid tile's shell load sends its own `lines=` (via
SHELL_LOAD_BASE_QUERY). CLAUDE.md, docs/architecture-invariants.md and
docs/api-reference.md describe the shell load, the cut report and the
fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timkjr

timkjr commented Oct 11, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Everything is in 063e6fc, on top of the original commit.

  1. The display-message query now also reads #{alternate_on}. A request carrying alt=recording (only SHELL_LOAD_QUERY sends it) is answered the old way on such a pane: the visible frame over the tailed byte recording. The route makes the decision; it's a client flag rather than a mode check, since session-routes.ts can't branch on shell (cli-registry-no-id-branching). Route tests cover alt on, alt off or unreported, and no opt-in. One data point from a beta instance with a stock-config nested tmux, 300 lines printed inside it: the fallback recovered 78 distinct lines and the full capture 39. So the recording holds more than one screen but not the whole scrollback, since the inner tmux turns a burst into repaints. It's still the better of the two, so I kept it as you suggested. alternate_on also can't tell a nested tmux from vim or less, so a local shell with one of those open takes the same fallback while it's open; the docs say so.
  2. Only a client bound below the ceiling is reported now. There's a test for full=1, full=1&tail=, and lines= at the ceiling.
  3. CLAUDE.md, architecture-invariants (Full-scrollback replay, plus the tile paragraph) and api-reference describe SHELL_LOAD_QUERY, the 'tail' report, and why.

Both smaller ones are in too: a bounded tile's shell load sends its own _historyLinesQuery() (via SHELL_LOAD_BASE_QUERY), and the Pane B sentence now says what it sends. The response also carries a new historyLinesCut flag, and the banner names no amount when it's set.

On #602: with both in, a remote shell's local pane is in the alternate screen too (it's attached to the remote tmux). As written, this fallback would discard #602's remote splice on every remote shell tab switch. The adjustment is to skip the fallback when the remote scrollback override was used, and to read the cut from the remote history_size instead of the local one. Happy to write it on whichever lands second.

Typecheck, lint, format, frontend syntax, browser-excludes and the full npm test are green here (10,677 passed).

@Ark0N
Ark0N merged commit 36f8afd into Ark0N:master Oct 13, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…-visible (#601 merge-time fix)

The geometry-retry comment in selectSession and the #435 invariant said the
route never answers full=1 with mux-visible; a shell load's alt=recording on
an alternate-screen pane now is. The conclusion holds (a useFullHistory pass
never sends it), so only the stated reason changes. Also lists
SHELL_LOAD_BASE_QUERY in terminal-tile.js's @dependency header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…ack from the remote tmux

A full-history load of a remote SSH session reads the scrollback from the
remote tmux in one bounded ssh round trip and splices it above the local
frame. A transport failure (ssh 255, timeout, unparseable output) starts a
30 s per-host back-off; a missing remote session is per-session only.

Conflict resolved in session-routes.ts: #602's remote scrollback read runs
before #601's capture helper and its alternate-screen fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…602 integration)

A remote session's local pane hosts the remote tmux client, so it is always
in the alternate screen. #601's alt=recording fallback therefore threw away
the scrollback #602 had just spliced in from the remote tmux on every remote
shell load. The fallback now applies only when no remote scrollback was
read (the read failed, or the session is not remote).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…in a remote history load

Conflict resolved in tmux-manager.ts: #609's screen-aware
joinScrollbackAndFrame keeps #602's merge-time SGR reset before the frame,
and #609's join tests expect it. The #601/#602 route test now mocks the
remote read in #609's { scrollback, screen } shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…t with PageUp/PageDown from the first wheel event

Conflict resolved in terminal-tile.js's @dependency header: #601's
SHELL_LOAD_BASE_QUERY/SHELL_LOAD_QUERY and #527's pageKeysForGesture and
app.tabAlerts are all listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
… open .md from the terminal in the rendered viewer

Conflicts resolved against #604, #606 and #601:
- constants.js: the rooted link pattern takes html-md's `markdown` and
  `html?` with #606's m4v/ogv; #604's relative pattern gets the same list
  (link-provider-regex pins the two equal); #604's findFilePathLinks /
  folder helpers and html-md's rendersAsPage() are both kept, and
  window.CodemanFilePaths exports all of them.
- terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links)
  now also goes through rendersAsPage(), so every link kind routes .html to
  the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS.
- CLAUDE.md, api-reference, architecture-invariants: both sides' text kept.
- test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other
  panels-ui harnesses.

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