Skip to content

fix(terminal): replay a pane capture at the geometry it was taken at - #435

Merged
Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/report-the-captured-pane-geometry
Sep 19, 2026
Merged

Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/report-the-captured-pane-geometry

Conversation

@irisitymichaelgrundberg

@irisitymichaelgrundberg irisitymichaelgrundberg commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

A visible-frame capture repaints each row at an absolute position, counting up to the pane's height and out to its width. The terminal response said nothing about either, so a terminal smaller than the pane silently lost rows and neither side could tell. The capture now reports the geometry it was taken at, and the client replays once when the frame does not fit.

The problem

formatPaneSnapshot paints each row with ESC[<row>;1H, truncated to the pane's width. A terminal shorter than the pane clamps every address past its own height onto its last line, so the overflow rows overwrite one another and the rows they land on are gone. A terminal narrower than the pane wraps every painted row, and the wrap on the last one scrolls the whole frame up by a row.

I measured the height case against a real session on 1.28.2. A shell pane sized 100x50 ran for i in $(seq 1 45); do echo probe-line-$i; done. The visible-frame capture came back addressing rows 1 through 50. Replaying that same capture into @xterm/headless at two heights gives:

terminal height probe lines rendered last line visible frame
50 rows (matches the pane) 45 of 45 probe-line-45 correct
30 rows 28 of 45 probe-line-28 duplicated

Seventeen lines of output are unreachable, and the surviving frame is drawn twice.

The mismatch is not only a race between a resize and a capture. Session.resize declines a small viewport's request outright while a desktop viewport's size claim is live. It returns before touching the PTY whenever the desktop has been active within the 90 second DESKTOP_CLAIM_IDLE_MS:

if (isSmallViewport && this._desktopSizeClaims.size > 0) {
  if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
    return;
  }

A phone opening a session that a desktop tab is holding therefore gets a frame built for the desktop's rows, every time rather than occasionally. POST /api/sessions/:id/resize returns an empty body, so the phone believes its resize took.

The replay does not repair that second case, and I want that stated rather than implied. It re-sends the same resize, the server declines it the same way, and the capture comes back at the same height; resizeRetry then stops it after the one extra attempt and the frame is shown as it is. Repairing it means changing who owns the pane size while a desktop claim is live, which is a policy question this change does not touch. What the reported geometry buys there is that the client can see the mismatch at all rather than being blind to it. The replay does repair the race case, where the resize has landed by the second attempt.

Changes

  • src/mux-interface.tsPaneCaptureOptions.capturedGeometry, an out-parameter the implementation fills with the size the capture was really taken at.
  • src/tmux-manager.tscapturePaneBuffer writes it once, before either replay path returns, and only when queryPaneCursor actually produced geometry.
  • src/web/routes/session-routes.ts — the visible path now passes an options object where it passed undefined, purely so the geometry can come back on it. The response carries it as captureCols and captureRows, and both are absent unless the response really carries a capture. A body that was never positioned has no geometry to describe, and naming one would invite the client to repair damage that is not there. That covers two separate cases: a failed cursor query, which is what produces the absolute addressing in the first place, and a capture that reports geometry and still returns nothing, which is what the full-history path does for a pane holding nothing visible.
  • src/web/public/app.jsselectSession compares the reported geometry against its own size and replays once when the frame cannot fit. The comparison runs on a mux-visible response only: a full-history body is linear scrollback closed by a relative cursor move, and a byte-history body carries no row alignment at all, so a mismatch damages neither and a replay repairs neither. Two guards keep the replay to the pass that can converge. resizeRetry caps it at one attempt, and a pane already drawing at the size the client just requested is left alone, which is the signature of a clamp rather than a race. Separately, the unsent local-echo text is flushed to the session before the reload nulls the active id, so a replay firing while the user types cannot clear characters that never reached the PTY.
  • Tests: the reported geometry and its two absent cases in test/routes/session-routes.test.ts, the write ordering plus a rendering test for why the height is needed at all in test/tmux-capture-full-history.test.ts, and a new browser suite.

A note on six assertions I changed

test/routes/session-routes.test.ts had six assertions of the form toHaveBeenCalledWith(muxName, undefined). The undefined was only ever a proxy for "this is not a full-history request", and the visible path now passes an object. They assert expect.not.objectContaining({ fullHistory: true }) instead, which is what they were checking for. No coverage is lost.

Verification

Every CI gate passes locally:

  • npm run typecheck, npm run lint, npm run format:check, npm run check:frontend-syntax
  • npm run check:lockfile and npm run generate:cli-catalog -- --check
  • the boot smoke test, which boots and answers /api/status
  • npm test, green at 7015 tests across 369 files

test/capture-geometry-retry.browser.test.ts drives the real client in chromium and stubs only the terminal endpoint, because staging the mismatch against live tmux needs two viewports. Its stub derives source from the request the way the route does, so no case rests on a response shape production cannot produce. Seven cases, and I removed each guard in turn to confirm none of them passes vacuously:

case without its guard
captured pane taller, replays once and stops one fetch, no repair
replay stays at the scope its first pass used replays on a full-history pass
captured pane fits, no replay passes either way, it is the no-op guard
captured pane wider, replays once one fetch, no repair
full-history response left alone two fetches, a second whole-scrollback pull
pane already at the requested size left alone two fetches, and never converges
unsent typed text handed over before the replay nothing delivered, the characters are lost

I also checked the geometry reporting against a running server, not only in tests. A 100x50 pane returns captureCols: 100, captureRows: 50 on the visible path. After a resize to 24 rows it returns 24, and the capture portion of the payload addresses exactly 24 rows.

Running the browser suite in a tree that borrows another checkout's node_modules needs node scripts/prepare-test-vendor.mjs first. Without the vendored xterm bundles the page never defines Terminal, and the test fails on its wait for app.terminal rather than passing vacuously against a zero-row terminal.

Related

Follows #395, #396 and #397, which fixed the other ways a replayed frame and the terminal could disagree — row alignment in the full-history replay, fitting only once the font is measurable, and a detached window owning its own pane size. This is the remaining case: the frame is built correctly, for a pane the terminal is not.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

Companion: #436, which keeps the output a capture could not contain. The two are independent and merge cleanly in either order.

@irisitymichaelgrundberg
irisitymichaelgrundberg force-pushed the fix/report-the-captured-pane-geometry branch from cb8d5de to 42efd78 Compare September 15, 2026 16:14
@irisitymichaelgrundberg
irisitymichaelgrundberg marked this pull request as ready for review September 15, 2026 16:23
@Ark0N

Ark0N commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Both jobs are green on this head now, so if the draft flag is just "not finished yet", say what is left and I will wait. If it is habit, please mark it ready for review.

The practical reason I ask: the reviewer that goes over PRs here skips drafts, so both this and #436 are currently sitting outside the review queue rather than at the back of it. I am assembling 1.29.2 and these two are the kind of thing I would want in it, but I am not going to merge a pair of terminal capture changes unreviewed.

Two things I did check while triaging, so you have them:

Your companion note is correct. I merged each onto the current master in sequence, both orders, and there is no conflict. They are also clean alongside the other four open PRs, so ordering is not a constraint on any of this. (My first pass compared the two heads against each other directly and reported a conflict in config/test-suites.ts; that was my test being wrong, not your claim. Merged onto master the way they actually would be, it is clean.)

And master has moved since you opened this: 1.29.1 went out an hour ago and carries #376's auto-naming, which touches session.ts and app.js. Your branch still merges clean over it, so no rebase is needed, but it is worth knowing the base is not what it was this morning.

On the content, without pre-empting the review: the thing I appreciate most here is the paragraph saying the retry does not repair the desktop-claim case and that you want that stated rather than implied. That is the right call and the right way to write it up. Who owns the pane size while a desktop claim is live really is a policy question, and I would rather decide it deliberately than have it fall out of a retry loop. Reporting the geometry so the client can at least see the mismatch is a genuine improvement on being blind to it, and it is the part that makes the policy question answerable later.

Mark it ready when you are happy with it and it goes into the queue.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

Note on the "draft" status: I put both in draft while I reviewed them myself, only moving them out of draft when I think they are ready for your review.

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the measurements. The geometry a capture was taken at genuinely was missing from the response, and a terminal shorter than the pane really does lose the overflow rows.

One thing I want changed before this goes in.

The retry is not gated on data.source (src/web/public/app.js:6376, condition at :6485). The route already labels every response mux-visible, mux-full-history or history, and only the first carries an absolutely-addressed frame. A full=1 response is linear scrollback plus the relative cursor move from formatCursorRestore, and that move is relative precisely so the browser's row count need not match the pane's, so a row mismatch there is not damage and a replay cannot repair it. history is the byte stream, where captureRows is not a measurement at all, it is the session.ptyRows fallback.

I ran your browser harness with the stub reporting source: 'mux-full-history' and everything else unchanged: one selectSession produced two ?full=1 fetches. Since useFullHistory is true on the first select of every non-shell session per page, the most common trigger of the retry is the path where it cannot help, and _fullHistoryLoaded.delete at :6499 makes the second pass another full tmux scrollback capture plus another full reset and replay, up to 32 MB by default. For a phone opening a session a desktop tab is holding, the case you correctly say the retry cannot fix, that is a doubled full-history pull on every page load and every first tab switch, indefinitely.

What I would like:

const framePositionsRowsAbsolutely = data.source === 'mux-visible';
const capturedTallerThanTerminal =
  framePositionsRowsAbsolutely &&
  Number.isFinite(data.captureRows) &&
  data.captureRows > (this.terminal?.rows || 0);

with the same gate applied to sizeMovedUnderLoad in the condition at :6485, since a size that moved under a byte-stream or scrollback replay is healed by xterm's own reflow plus the SIGWINCH your trailing sendResize already sends. I applied exactly that here: your three browser cases stay green and the full-history probe drops to one fetch. A fourth case in that suite (stub reporting mux-full-history, asserting one fetch) would pin it.

Two smaller ones in the same area, worth folding into the same pass:

  • src/web/routes/session-routes.ts:2756: the ?? session.ptyCols fallback reports a geometry for a frame that was never positioned, which is the thing src/mux-interface.ts:155 says would be worse than reporting none, and which the if (opts && geometry) guard at src/tmux-manager.ts:3474 exists to prevent. When queryPaneCursor returns null the visible path returns the raw capture with no row addressing in it at all. Your stated reason for the fallback goes away once the source gate is in: a missing field on a mux-visible response then correctly means there is nothing to repair. session.ptyCols can also be wrong on its own terms, since _ptyCols is written only by resize() while the PTY is spawned at the size queried from tmux (src/session.ts:1646).
  • src/web/public/app.js:6376: captureCols is reported and then only logged. A pane wider than the terminal is the same race and mangles the same frame: formatPaneSnapshot truncates each row to the pane's width (src/tmux-manager.ts:617), so a narrower browser wraps every painted row, and the wrap on the last one scrolls the frame a row. Either compare cols too, or say in the comment why rows alone is the whole story.

One observation rather than an ask: there are two structural mismatches, not one. Besides the declined resize you documented, getTerminalDimensions() floors at 40x10 (src/web/public/terminal-ui.js:5038) while fitAddon.fit() does not, so any terminal shorter than 10 rows permanently reports captureRows 10 against fewer real rows and retries on every select without converging. Both cases share a signature the client can already see, the capture geometry equalling the size just requested, if you want to keep the retry to the case it repairs.

Everything else reads well. The full gate is green here at 7014 tests across 369 files, your new suite genuinely fails on master (2 of its 3 cases), the port is unique, the glob is registered in config/test-suites.ts, and the changeset and scope are right. Send the source gate with its test and I will merge. I will add the CLAUDE.md lines for the two new response fields and the one-attempt rule myself.

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for turning the source gate around quickly. The response now says what pane geometry a frame was built for, and the client replays once when that frame does not fit, which closes the last of the ways a replayed frame and the terminal could disagree.

All three asks landed: the comparison is gated on data.source === 'mux-visible', the ?? session.ptyCols fallback is gone along with the session.ts getters, cols are compared as well as rows, and the fifth browser case pins that a full-history response is left alone. I ran the gate (369 files, 7014 tests, green), typecheck, lint, format and the frontend syntax check. Your browser suite passes here at 5 of 5, and 3 of its 5 cases fail against master's app.js, so it earns its place. The branch still merges clean onto master even though master has moved two minor versions since you opened this.

One thing I want to change before it ships, and three smaller ones I will fold in at merge time.

The retry cannot converge for two of its three triggers, and pays a full extra replay each time (src/web/public/app.js:6509). The race case converges on the second pass, as you say. The other two never do: Session.resize declining a small viewport while a desktop claim is live (src/session.ts:3565), and getTerminalDimensions() flooring at 40x10 (src/web/public/terminal-ui.js:5038) while fitAddon.fit() does not, so a terminal narrower than 40 columns or shorter than 10 rows permanently reports a larger pane than it has. In both, the second pass re-fetches the 1 MiB tail, runs _resetTerminalForReplay() plus a full chunked rewrite (a visible re-flash), and because it goes through forceReload it also drops and reopens the WebSocket and deletes the persisted xterm snapshot, to land on exactly the frame it started with. For a phone used next to an open desktop tab, the case this change was written for, that is every tab switch after the first for as long as the desktop stays active. The clamp half is cheap to exclude: skip the retry when the reported geometry equals the size the load just requested (dimsAfterLoad), which is the clamp's signature and never the race's. I will apply that at merge unless you would rather send it with a test of its own, in which case say so and I will wait. The declined-resize half needs either a per-session memo of the pair that failed to converge or a resize response that reports whether the resize was applied, and that belongs with the pane-ownership policy question you deliberately left alone, so I will open it as a follow-up.

The full-history re-arm is unreachable (src/web/public/app.js:6524). The retry needs source === 'mux-visible', and the route only produces that for a request sent without full=1 (src/web/routes/session-routes.ts:2608), so useFullHistory is always false by the time that line runs. Keep the guard, it costs nothing, but the comment reads as if the full-history path retries, and two things inherit that reading: the first phase of test/capture-geometry-retry.browser.test.ts:179 stages a full=1 request answered with mux-visible, which the route cannot return, and the PR body still says a page load retries as a full-history pull. The second phase of that case, a tab-switch retry staying on the tail, is real and I would keep it exactly as it is.

A history response can still carry the geometry fields (src/web/routes/session-routes.ts:2750). capturePaneBuffer writes capturedGeometry (src/tmux-manager.ts:3477) before the full-history branch can return an empty string for a pane holding nothing visible (:3496), and an empty capture makes the source fall back to history with both fields still set. I checked it against the real handler: a full=1 request whose capture reports 100x50 and returns an empty string answers source: "history", captureCols: 100, captureRows: 50. Nothing acts on it today because the client ignores geometry on any other source, but the comment says both fields are absent in that case and the new test only covers the null-capture variant. Reporting them only when a capture actually came back would make the comment true again.

Two notes with no action needed. _onSessionClearTerminal (src/web/public/app.js:2551) and _onSessionNeedsRefresh for shell sessions (:2497) replay the same absolutely-addressed frame and do not consult the new fields, which is follow-up territory rather than anything this PR owes. And the changeset paragraph about omitting the fields rather than naming the PTY size describes the difference between your two commits rather than a change from 1.29.x, so I will trim it before it becomes CHANGELOG text.

Next step: I will apply the clamp guard, the comment and test wording, the changeset trim, and the CLAUDE.md lines for the two new response fields and the one-attempt rule, then merge. Thanks for the measurements and for stating plainly what the retry does not fix. That paragraph is what makes the policy question answerable later.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

All three landed, and the clamp guard comes with a test of its own, so you should not need to apply anything at merge beyond the CLAUDE.md lines.

The clamp guard. The retry now skips when the reported geometry equals dimsAfterLoad. I took your framing that the replay runs at that size, so it can only change the screen if the pane was drawing at some other one. The test needed a viewport where the floor genuinely binds, so I measured: at 320x200 proposeDimensions() returns 7 rows while getTerminalDimensions() returns 10, which is the clamp exactly. The case asserts the premise before the behaviour, so it cannot pass on a viewport where the floor does nothing, and it fails without the guard with expected 2 to be 1. You widened this correctly, by the way. I had only looked at the 10-row floor; the 40-column one does the same thing and the comment now names both.

The unreachable re-arm. You are right, and the comment now says so rather than implying a page load retries. The line stays for the invariant. The bigger problem was downstream, as you spotted: the stub answered full=1 with mux-visible, which the route cannot do. It now derives the source from the request the way the route derives it, and each case reaches a visible-frame response the way production does, by not being the first select of the page. The scope case you wanted kept is intact and now also catches a missing source gate, which it did not before.

Geometry on a history response. Confirmed against the real handler before changing anything: a full=1 request whose capture reports 100x50 and returns an empty string answered {"source":"history","cols":100,"rows":50}, exactly as you described. Both fields are now gated on hasLiveMuxBuffer, and there is a route test for the blank-pane variant alongside the null-capture one. The comment explains that a capture can report geometry and still hand back nothing, since that is the part that is not obvious from either file alone.

I also trimmed the changeset paragraph you flagged. You were right that it described the difference between my two commits rather than a change from 1.29.x; it now reads as release notes.

The declined-resize half is yours to open as a follow-up, and I agree it belongs with the pane-ownership question rather than here. Same for _onSessionClearTerminal and the shell _onSessionNeedsRefresh path replaying the same absolutely-addressed frame without consulting the fields.

Verified: full gate green at 369 files and 7015 tests, browser suite 6 of 6, and each of the three guards has a case that fails without it. Typecheck, lint, format and the frontend syntax check are clean.

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Perfect! Thanks a lot for your Contributions to the Project :-)

@Ark0N

Ark0N commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the third round. The response now says what pane geometry a frame was built for, the client replays once when that frame does not fit, and all three of the earlier asks landed cleanly: the comparison is gated on data.source === 'mux-visible', the ?? session.ptyCols fallback and the session.ts getters are gone, cols are compared alongside rows, and the clamp guard arrives with a case that measures the floor before it asserts the behaviour. I reproduced your numbers here: full gate green at 369 files and 7015 tests, your browser suite 6 of 6, and 3 of those 6 fail against master's app.js.

One thing I would like fixed before this goes in, and it is one nobody has raised yet.

The retry throws away typed-but-unsent local echo text (src/web/public/app.js:6555). The retry re-enters selectSession with forceReload: true while the session is still active, so it takes the branch at :5928, which sets this.activeSessionId = null at :5941 before _cleanupPreviousSession(sessionId) runs at :5968. That function only flushes the overlay's pending text to the PTY inside if (this.activeSessionId) (:5647), so the flush is skipped and the unconditional this._localEchoOverlay?.clear() at :5665 drops pendingText on the floor.

Local echo is on by default on touch devices (src/web/public/terminal-ui.js:3383), and the retry fires the moment a tab switch finishes, so anything typed while the terminal was still loading is in the overlay right then. I checked it in chromium against this head rather than reasoning about it: with the overlay on, typing hello-unsent and then making the same call the retry makes leaves pendingText empty with no input request on either transport. The characters vanish from the screen and never reach the session.

This is pre-existing for the one gesture that already takes that path, tapping the tab you are already on, so it is not something you introduced. What is new is that nothing the user did triggers it, and on a phone next to an active desktop tab it now fires on every tab switch. Given how this repo treats losing input, I would rather close it here than leave it.

What I would like: flush before nulling the id. Lifting the block at :5647 to :5661 into a small helper and calling it from both _cleanupPreviousSession and the forceReload branch at :5928 covers the retry and the tab-tap case in one go:

const echoText = this._localEchoOverlay?.pendingText || '';
if (echoText) {
  this._sendInputAsync(sessionId, echoText);
  const flushed = this._localEchoOverlay?.getFlushed() || { count: 0, text: '' };
  this._flushedOffsets?.set(sessionId, flushed.count + echoText.length);
  this._flushedTexts?.set(sessionId, (flushed.text || '') + echoText);
}

A seventh case in test/capture-geometry-retry.browser.test.ts would pin it: force app._localEchoEnabled = true, type, trigger a retry, assert the text reached the session.

Two small things I will fold in at merge, no action needed from you:

  • src/tmux-manager.ts:3427: capturedGeometry is only ever written, never reset, and the method returns early for test mode and for an invalid pane target without touching it. The single caller builds a fresh object per request so nothing is wrong today, but one line at the top of the method makes the out-parameter self-consistent for whoever adds the second caller.
  • The PR body still lists src/session.ts among the changed files and still says both fields fall back to the session's own size. Your last two commits removed all of that. The changeset is accurate, so the CHANGELOG is fine, only the body reads stale.

The declined-resize half stays mine to open as a follow-up, as agreed, and I will write the CLAUDE.md and architecture-invariants lines for the two new response fields and the one-attempt rule.

Send the echo flush with its case and I will merge.

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

The echo flush is in with its case, and you found a real one. I reproduced it before touching anything, because a bug about losing input deserves better than agreeing with the reasoning: with the overlay on, typing hello-unsent and then making the call the replay makes left nothing at all crossing into the delivery layer. The new case fails against the previous head with expected [] to include 'hello-unsent', which is the whole of what the user typed.

The fix. The flush moved into _flushLocalEchoTo(sessionId), called from _cleanupPreviousSession and from the forceReload branch before it nulls the id. I took the session as a parameter rather than reading activeSessionId inside, because the two callers mean different tabs: cleanup flushes to the one being left, the branch to the one being reloaded. Reading the field after the null is exactly what dropped the text, so the helper is built so that reading it is not an option.

Your account of the mechanism was right in every step, including that it predates this branch. What I would add is why it is worth closing here rather than filing: before, it took a deliberate gesture on a tab you were already looking at, and now it fires on its own while someone is typing into a terminal that has not finished loading. Those are different bugs wearing the same stack trace.

The seventh case. It forces the overlay on through the setting rather than the flag alone, so it survives the recompute every select runs, and it asserts the characters really are sitting unsent before triggering the replay. Headless chromium reports no touch support, so without that the case would pass on a build where typing goes straight to the PTY and there is nothing to lose. It records what crosses into _sendInputAsync, since that seam is precisely what the text failed to cross.

The PR body. Fixed, and thank you for reading it. It still listed src/session.ts, still described the fallback, and still claimed a page load retries as a full-history pull, which the source gate made impossible two commits ago. It now also carries a table of all seven browser cases with what each one does without its guard, since that is the thing worth checking rather than the count.

I left src/tmux-manager.ts:3427 alone, as you said no action was needed. Agreed on the reasoning: one caller building a fresh object per request is what makes it safe today rather than anything the method promises, and resetting the out-parameter is the kind of thing whoever adds the second caller should not have to discover.

Verified: full gate green at 369 files and 7015 tests, browser suite 7 of 7, and each of the four guards on this branch has a case that fails without it. Typecheck, lint, format and the frontend syntax check are clean.

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Thanks for this one. The diagnosis is exact and the part most people would get wrong, fencing the comparison to a mux-visible response, is the part you got right: a full-history body closes with a relative cursor move and cannot be damaged by a size mismatch, and replaying one would pull the whole scrollback a second time on the first select of every non-shell session per page. I also like that the response omits the geometry rather than inventing one, and that hasLiveMuxBuffer covers the full-history capture that reports a size and still returns nothing.

Two things before I merge.

1. The replay has no memory that it did not help (src/web/public/app.js:6556). resizeRetry caps the recursion inside one select, but nothing records that a previous attempt failed to converge, so a session whose pane is permanently bigger than the terminal replays on every select for the life of the page. I measured it by adding a three-select case to your own browser suite against the 200-row stub: the fetch counter reads 2, 4, 6. That is the case your description names as happening every time rather than occasionally (a phone whose resize Session.resize declines while a desktop claim is live), and it is not the only one: any pane Codeman cannot size to the browser lands there, for instance one with a second tmux client attached. Each extra pass costs a second capture-pane (which is execSync, so it blocks the server event loop), a _resetTerminalForReplay() plus chunked rewrite, a deleted xterm snapshot and cache entry, and a dropped and reopened WebSocket, because _connectWs always disconnects first.

The repo already has the pattern: _fullHistoryRepullUseless (app.js:551 creates it, :5787 adds, :5795 clears, :5756 reads). Please mirror it. In the resizeRetry pass, when the geometry still does not fit, add the session to a _geometryRetryUseless Set; clear it whenever a pass sees geometry that matches; and add !this._geometryRetryUseless?.has(sessionId) to the gate. That keeps the race repair, which converges on the first attempt, and pays for the diagnosis once per session instead of once per tab switch. The more principled fix, having POST /api/sessions/:id/resize answer with the size that was actually applied so the client never has to capture again to learn it was declined, is a good follow-up but not something I want in this PR.

2. Please rebase (config/test-suites.ts:30). The branch is conflicting, which means GitHub ran no CI on it at all. The conflict is one line: you and #436 both appended a glob to BROWSER_TEST_GLOBS, so keeping both lines resolves it. Everything else auto-merges, including app.js where #436 landed in the same function, and I checked the merged file carries both changes and parses. Worth landing on top of that and seeing the gate green, since #436 touched the same load path.

Two smaller things I will fold in at merge unless you would rather do them:

  • The changeset is forty lines of rationale, and that body is what CHANGELOG.md and the release notes print. One user-facing paragraph is what I want there; the rationale is already in the PR description and the code comments, which is the right home for it.
  • docs/architecture-invariants.md (full-scrollback-replay) and the matching CLAUDE.md bullet should gain the three new rules: a visible capture reports its geometry and the response omits it when no frame was positioned, the comparison runs on mux-visible only, and the replay is capped at one attempt and must not re-arm _fullHistoryLoaded for a pass that did not consume it.

One nit you can take or leave: the if (useFullHistory) this._fullHistoryLoaded.delete(sessionId) line at app.js:6579 is unreachable and your comment says so. The reasoning is worth keeping; when we want an invariant enforced here the habit is a static test over the source, not a branch no test can cover.

Everything I ran is green on your head: typecheck, lint, format:check, check:frontend-syntax, check:public-assets, the full npm test (369 files, 7015 tests), and your browser suite (7 of 7). I also reverted app.js to the merge base and re-ran that suite: four of the seven fail, so the new cases are doing real work.

Once the retry memo and the rebase are in, this goes in.

A visible-frame capture repaints each row at an absolute position, counting up
to the pane's height. A terminal shorter than that clamps every address past its
own height onto its last line. The overflow rows then overwrite one another, and
the rows underneath are lost. Replaying a real 50-row capture into a 30-row
terminal rendered 28 lines of a 45-line command and drew the frame twice.

Nothing in the response said what height the frame was built for, so the client
could not detect this. A capture now reports the geometry it was really taken at
through `capturedGeometry` on `PaneCaptureOptions`, and the terminal response
carries it as `captureCols` and `captureRows`. When the captured pane is taller
than the terminal, or the size that produced the capture did not survive the
load, `selectSession` replays once at the size that stuck. `resizeRetry` caps
that at one attempt, so two competing fits cannot trade replays forever.

The retry re-arms the full-history flag only when the pass that ran had consumed
it. A tab switch takes the bounded tail, so its retry takes the tail too:
clearing the flag unconditionally would upgrade that switch into a fresh
scrollback capture the user never asked for, which the route's own comments put
at tens of megabytes.

What this repairs is a capture that won a race against the resize meant to
precede it. It does not repair a capture whose pane was too tall because
`Session.resize` declined the resize outright, which it does for a small
viewport while a desktop viewport's size claim is live. The retry re-sends the
same declined resize and captures the same pane, and `resizeRetry` then stops
it. Repairing that means changing who owns the pane size, which is a policy
question this does not touch. The reported geometry still helps there, because
the client can see the mismatch at all rather than being blind to it.

Follows Ark0N#395, Ark0N#396 and Ark0N#397, which fixed the other ways the replayed frame and
the terminal could disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only a visible-frame capture positions its rows absolutely, so only that frame
can be damaged by a terminal of the wrong size. A `full=1` body is linear
scrollback closed by a relative cursor move, which is relative precisely so the
browser's row count need not match the pane's, and a `history` body is the byte
stream, which carries no row alignment to protect. The geometry comparison ran
on all three, so it fired most often on the one response it cannot help:
`_fullHistoryLoaded` is empty on the first select of every non-shell session per
page, and a session whose pane a desktop tab holds too tall to ever fit then
paid a second whole-scrollback capture, reset and replay on every page load and
every first tab switch.

`framePositionsRowsAbsolutely` gates both the captured-geometry comparison and
`sizeMovedUnderLoad`. A size that moved under a byte-stream or scrollback replay
is healed by xterm's own reflow plus the SIGWINCH the trailing `sendResize`
already sends.

A pane WIDER than the terminal damages the same frame a second way, so
`captureCols` is now compared rather than only logged. `formatPaneSnapshot`
paints each row out to the pane's own width, so a narrower browser wraps every
painted row, and the wrap on the last one scrolls the whole frame up by a row.

The terminal response no longer falls back to `session.ptyCols`/`ptyRows` when
the capture reported no geometry. The cursor query is what produces the absolute
addressing in the first place, so a capture that lost it returned a raw frame
that was never positioned, and a byte-history response was never positioned
either. Naming the session's own PTY size there described a frame that does not
exist and invited a repair for damage that is not present. `_ptyCols` is also
written only by `resize()` while the PTY is spawned at the size queried from
tmux, so it can be wrong on its own terms. Both fields are now absent instead,
and the `Session` getters added for that fallback go with it.

Two browser cases cover the new behaviour and each fails without its fix: a
`mux-full-history` response with both dimensions mismatched asserts one fetch
(two without the gate), and a `mux-visible` response wider than the terminal
but short enough to fit asserts two (one without the width comparison).

Corrects a claim in the comment above `capturedGeometry` in tmux-manager.ts.
Both replay paths do not address rows absolutely; the full-history one ends in a
relative move, which is the whole reason the gate is right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three follow-ups to the source gate, each one measured rather than reasoned.

A pane already drawing at the size the client just requested is left alone. The
replay runs at `dimsAfterLoad`, so it can only change what is on screen if the
pane was drawing at some other size; when the reported geometry already IS that
size, the second pass captures the identical frame and pays a full reload to do
it, including a visible re-flash, a dropped and reopened WebSocket and a deleted
xterm snapshot. That equality is the signature of a clamp rather than a race:
`getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does not, so a
terminal narrower than 40 columns or shorter than 10 rows reports a pane
permanently bigger than itself and replayed on every tab switch without ever
converging. A race never produces the equality, since its premise is that the
pane was still at the size it was asked to leave. The declined-resize case does
not produce it either, so that one still costs the single capped attempt and
needs the pane-ownership question this does not touch.

The full-history re-arm is unreachable and now says so. A pass that consumed the
flag sent `full=1`, and the route answers `full=1` with `mux-full-history` or
`history`, never `mux-visible`, so the source gate already rules out every such
pass. The line stays for the invariant, but its comment no longer reads as if a
page load retries, and the suite pins that it does not.

The response no longer reports geometry for a body that carries no capture. The
full-history path writes `capturedGeometry` from the cursor query and then
returns '' for a pane holding nothing visible, which drops the source to
`history` with the geometry already recorded: a `full=1` request whose capture
reported 100x50 and returned nothing answered `source: "history"` with both
fields set. Nothing acted on it, because the client ignores geometry on any
other source, but the field said a frame had been drawn at a size when none had.

The browser stub now derives `source` from the request the way the route does,
rather than answering `full=1` with `mux-visible`, which the route cannot
produce. Each case reaches a visible-frame response the way production does, by
not being the first select of the page. Three cases pin the new behaviour and
each fails without its guard: the clamp case sees two fetches instead of one,
the scope case and the full-history case both see a replay the gate forbids, and
the width case sees one fetch instead of two.

The changeset now describes the change from 1.29.x rather than the difference
between the two commits on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a touch device the characters the user has typed live only in the local-echo
overlay until Enter; they have never reached the PTY. The replay re-enters
`selectSession` with `forceReload` on the session that is still active, and that
branch nulled `activeSessionId` before `_cleanupPreviousSession` ran. The flush
there is guarded on a session it can still see, so it was skipped, and the
unconditional `_localEchoOverlay.clear()` that follows took the characters with
it. Measured in chromium against the previous head: typing into the overlay and
then making the call the replay makes left `pendingText` empty with nothing
crossing into the delivery layer on either transport.

The flush moves into `_flushLocalEchoTo(sessionId)`, called from both
`_cleanupPreviousSession` and the `forceReload` branch before it nulls the id.
The session is a parameter because the two callers mean different ones: cleanup
flushes to the tab being left, the branch to the tab being reloaded.

This was reachable before this branch, through the one gesture that already
takes the `forceReload` path on an active session. What is new is that nothing
the user does triggers it. The replay fires on its own the moment a tab switch
finishes, which is exactly when someone typing into a still-loading terminal has
text in the overlay, and on a phone beside an active desktop tab that is every
tab switch.

A seventh browser case pins it: it forces the overlay on, since headless
chromium reports no touch support and the case would otherwise pass vacuously,
asserts the typed characters really are sitting unsent, then triggers the replay
and asserts they reached the session. Without the fix it fails with nothing
delivered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`resizeRetry` caps the recursion inside one select and says nothing about the
next one, so a pane this browser cannot size reported the same mismatch on
every select and bought the same failed repair each time: two fetches per tab
switch for the life of the page, measured as a running count of 2, 4, 6 across
three selects. That is the case this branch describes as happening every time
rather than occasionally, a phone whose resize `Session.resize` declines while a
desktop claim is live, and it is not the only one — any pane Codeman cannot size
lands there, including one a second tmux client is also holding. Each wasted
pass costs another `capture-pane`, which is `execSync` and blocks the server's
event loop, plus a reset and chunked rewrite, a discarded snapshot and cache
entry, and a dropped and reopened WebSocket.

`_geometryRetryUseless` mirrors the existing `_fullHistoryRepullUseless`: a
retry pass whose frame still does not fit adds the session, geometry that fits
removes it, and the replay gate consults it. The proof has to come from a retry
pass rather than a first one, because the retry ran at the size that stuck and
the pane ignored it. Clearing on a fitting frame is what stops a pane that
becomes sizeable again, once the desktop tab closes or its claim goes idle, from
staying permanently unrepaired. The race case never reaches the latch, since it
converges on its first attempt.

The new browser case walks all of that: three selects reading 2, 3, 4 instead of
2, 4, 6, then a fitting frame, then a mismatch diagnosed afresh. Without the
gate it fails on the second switch with `expected 4 to be 3`.

Rebased onto master, which has moved to 1.30.0 and taken Ark0N#436. The one conflict
was `config/test-suites.ts`, where both branches appended a glob to
`BROWSER_TEST_GLOBS`; both are kept. Everything else merged clean, Ark0N#436's own
changes to the same buffer-load path included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@irisitymichaelgrundberg
irisitymichaelgrundberg force-pushed the fix/report-the-captured-pane-geometry branch from fb1f939 to 95dc6fe Compare September 19, 2026 08:57
@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor Author

Both are in, and CI has finally run: two jobs green on 95dc6fe9, which is the first time GitHub has been able to check this branch at all.

The retry memo. _geometryRetryUseless mirrors _fullHistoryRepullUseless exactly as you laid it out: a retry pass whose frame still does not fit adds the session, geometry that fits removes it, and the gate consults it. I reproduced your 2, 4, 6 first, then wrote the case around it. It now walks 2, 3, 4 across three tab switches, and without the gate it fails on the second one with expected 4 to be 3.

Two decisions inside that worth naming. The proof has to come from a retry pass rather than a first one, because the retry is the one that ran at the size that stuck and watched the pane ignore it; latching on a first-pass mismatch would swallow the race repair, which is the case that does converge. And the case does not stop at the latch: it then feeds a fitting frame and asserts the memo lifts, then a mismatch again and asserts it is diagnosed afresh. Without that half, closing the desktop tab that was holding the pane would leave the session permanently unrepaired, which is a worse bug than the one being fixed.

I agree the resize endpoint answering with the size actually applied is the real fix, and that it does not belong here. It would also retire captureMatchesRequestedSize and most of this memo, since the client would never have to capture to learn it was declined.

One thing I mirrored rather than improved: neither Set is cleared in _cleanupSessionData, so both leak an id per closed session for the life of the page. Bounded and tiny, and I would rather fix the pair in one go than fix mine here and leave its twin looking deliberate.

The rebase. Done, and the conflict was the one line you called: both branches appended a glob to BROWSER_TEST_GLOBS, and both are kept. Everything else merged clean. I checked app.js specifically, since #436 landed in the same function — _bufferLoadFinishOpts and _syncStickyScrollBaseline are intact and the geometry work sits alongside them. The full gate on the rebased tree is 390 files and 7374 tests green, up from 369 and 7015 before, which is master's own growth rather than anything here.

Your two smaller items are yours to fold in, as offered: the changeset trim and the docs/architecture-invariants.md plus CLAUDE.md lines. Say the word if you would rather I did either.

Taking the nit: you are right that an invariant belongs in a static test over the source rather than a branch nothing can cover. I left the line as it is for this PR rather than change the shape of something you are about to merge, but if you want the static test instead of the unreachable guard, that is a small follow-up and I am happy to write it.

Browser suite is 8 of 8, and each of the five guards on this branch has a case that fails without it.

@Ark0N

Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

This is going in. Five rounds, and it earned every one of them.

The thing that made this worth the depth: the diagnosis was exact and it was the part most people would have got wrong. A visible capture repaints each row at an absolute position, so a terminal smaller than the pane damages the frame two separate ways, and neither is visible as corruption you could reason backwards from. Too short clamps the overflow addresses onto the last line and silently eats the rows underneath. Too narrow wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Your 50-row-pane-into-a-30-row-terminal measurement (28 of 45 lines, survivors drawn twice) is the kind of number that makes a fix reviewable instead of plausible.

The new Playwright suite failing five of eight against the merge base is what I look for and rarely get. It is the difference between a test that describes the fix and a test that would have caught the bug.

What I am folding in at merge, all small:

The changeset. Rewritten to one user-facing paragraph, the same shape as #436. The version you had still described two guards and never mentioned _geometryRetryUseless, which the last commit added, so it would have shipped a description the final commit superseded straight into the CHANGELOG.

Number.isFinite(data.captureRows) on sizeMovedUnderLoad. You had it on both of the other two comparisons and the comment above them already states the rule, but this one was deriving from data.source alone. mux-visible turns out not to be sufficient: when the display-message cursor query fails, capturePaneBuffer skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body mux-visible. A size that moved during such a load would then buy a full forced reload to repair a frame that was never positioned.

The invariants and CLAUDE.md lines, as promised. Three rules written down: a visible capture reports its geometry and the response omits it when nothing was positioned (so consumers test Number.isFinite, never truthiness), the comparison runs on mux-visible only and why the full-history path must stay out of it, and the one-attempt cap plus the per-session latch.

The follow-ups the thread already agreed on are still follow-ups: the declined-resize policy question, a resize endpoint that answers with the size it actually applied, the static test replacing the unreachable _fullHistoryLoaded re-arm, and clearing the three per-session Sets on session delete. That last one is the right shape as you described it, all three together rather than just the new one.

On #451, your other open PR: it is not stalled. The trailing trim is obviously right, but the shared leading-indent strip is a default this project would be choosing on its own with no native-terminal precedent, and it rewrites content in cases that are not TUI margins (git log bodies, indented Python out of cat, git diff context lines where the leading space is the marker). That is much easier to ship later than to take away, so I have someone characterising the real cases before I decide. You will get a straight answer rather than silence.

@Ark0N
Ark0N merged commit 60c9af0 into Ark0N:master Sep 19, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 19, 2026
…d capture geometry

#454: the behaviour the PR adds had no test, so a regression test drives
runGrok() at tabCount 3 and asserts three quick-start POSTs with sequential
w<n>-<case> names (verified to fail against master's session-ui.js). Each
caller now reads the count BEFORE its opening banner and announces it there,
the way runClaude() already did, so a launch no longer prints two headers and
a launch with another session already active still says how many are starting.
runClaude() calls the shared _readTabCount() instead of its own copy of the
1..20 clamp, and that helper optional-chains the element read, since hoisting
it above each caller's try block would otherwise let a missing #tabCount throw
where the launch-error path cannot report it.

#435: sizeMovedUnderLoad derived from data.source alone. `mux-visible` is not
sufficient: a failed display-message cursor query makes capturePaneBuffer skip
the snapshot repaint and return the raw capture, which the route still labels
mux-visible, so a size that moved during such a load bought a full forced
reload to repair a frame that was never positioned. It now tests
Number.isFinite(data.captureRows) like its two siblings.

Plus the invariants and CLAUDE.md lines promised on #435: a visible capture
reports its geometry and omits it when nothing was positioned, the comparison
runs on mux-visible only, and the replay is capped at one attempt and latches
per session when it cannot converge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Sep 19, 2026
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