Five items, two of which he could only see by running it, plus six smaller ones. Taking the two blockers first, because both were wrong in ways the existing tests could not catch. **Adopting the PTY's rows put the CLI's input line off-screen.** A phone that took a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container; xterm's own viewport then had nothing to scroll, so the bottom of the frame sat below the container with no gesture able to reach it. Output visible, typing invisible, for as long as the desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now: width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping the local row count keeps the composer at the bottom of a viewport that scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and the input line is inside the box. **`capture-geometry-retry.browser.test.ts` failed, and CI could not see it** because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp — `getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this work removes at the source, so it can never hold again at any viewport. The case survives on its own terms: a pane already drawing at the requested size must not be replayed. Its premise is now the #464 invariant itself, that the floored report and the terminal agree, which is a stronger guard because the clamp coming back fails it here rather than silently restoring the replay loop. The helper docblock that repeated the old premise is corrected too. **A session with no pane reported 120x40 and the client adopted it.** `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and nothing seeds them from the spawn geometry, so a dead-pane session still held the constructor defaults — clicking that tab resized the browser terminal to 120x40 and, on anything narrower, claimed another device owned the pane when none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows` getters are deleted rather than left available to be misused again. **The 40-column floor clipped the pane with nothing able to reach it.** The affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm and the PTY agree throughout, the terminal is simply wider than the box. It keys on what does not FIT now, MEASURED (`.xterm-screen` against the container, on the next frame, because the screen takes its width with the render) rather than derived from cell arithmetic. Measured at 360px: font 24 applies 40 columns and paints 560px, and all 200px of the overhang is reachable. `.pty-oversized` is renamed `.term-overflows-x`, because after this the old name describes only one of the two causes. **"Scroll sideways" did not work on touch for the sessions it targets.** `touch-action: pan-x` is cancelled before it starts by the `preventDefault()` `touchstart` calls on every 'content' tap. The terminal's own touchmove handler pans the container now, with the axis locked once per gesture so a diagonal cannot pan and scroll at once, and the CSS grants no `touch-action` at all — handing the browser a pan AS WELL would move the pane twice for one finger on the taps where that preventDefault does not run. Measured under real touch dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the buffer does not move with it, and a vertical swipe still scrolls the scrollback. Three defects in the above, found while checking it rather than by being told: - `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is true of a container that is not a scroller — a sideways swipe would have locked the axis, done nothing, AND suppressed the vertical scroll it should have been. Gated on the class as well. - The notice advised scrolling sideways whenever the PTY was wider, including when it still fitted and nothing scrolled. It is gated on measured overflow, and on a comparison against the width this container WOULD request rather than the one it currently holds — once adopted those are equal, so the second question answers itself false while the condition is still true. - `_syncTerminalOverflowAffordance` could throw out of `document.getElementById` before reaching its try block. It runs off every geometry change, so a cosmetic affordance could have taken the resize down with it. The smaller items: - `docs/architecture-invariants.md` no longer explains the equality guard as a clamp signature; it records what the clamp used to do and why it cannot any more. Edited by hand — that file is outside the Prettier glob, and letting Prettier near it rewrote eleven unrelated emphasis markers. - `throttledResize`'s HTTP fallback reads the reply. It is the path where a declined resize is least likely to be noticed, because no socket means no `{"t":"zc"}` frame either. - The changeset covers the whole release: the geometry work, the queued replay clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket output-gap reconcile, the build-generated service-worker precache and per-build cache key, and the crash-trail hygiene. - `@xterm/headless` is declared in the root devDependencies instead of being reached through workspace hoisting. - The output-gap marker is cleared after any response arrives, not only when the capture was non-empty: a server that answers with an empty capture HAS reconciled us, and leaving the marker set refetched on every reconnect. - `e587d845`'s message claimed a test asserted the failed-load copy against the built asset. It did not — that assertion lived in a probe deleted with the other scratch scripts, so the claim was false when it was written. There is a real test now, and it reads the source rather than `dist/`, because `dist/` is not committed and a test that skips when it is absent would pass for the wrong reason in CI. `Session.ptyGeometry` gets behavioural coverage against the real class in `session-resize-arbitration.test.ts` rather than a source guard, including the contrast — a pane that does exist still reports, and still follows a resize — so "always null" would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3.8 KiB
aicodeman
| aicodeman |
|---|
| patch |
fix(terminal): five ways the terminal silently stopped being correct
The PTY and the browser terminal must never disagree about size (#464). "Text gets muffled sometimes" was arithmetic, not a dropped frame. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took, so a browser terminal of a different width makes the erase come out short and each repaint paints over rows nothing cleared — the doubled lines and half-overwritten prose in the report. Four ways the two drifted apart, all silent: fitAddon.fit() sized xterm to the raw measurement while every server-facing path reported it floored at 40x10 (font size 44 on a 430px phone proposed 13 columns, the server was told 40, xterm stayed at 13); throttledResize and sendResize reflowed locally while deliberately withholding the SIGWINCH; the font setters moved the cell size and told the server nothing; and Session.resize declined small-viewport requests under an active desktop sizing claim without telling the asking client, because resize was write-only. syncTerminalGeometry() is now the one function that changes the terminal's size — it fits, floors and applies as a single step — and both transports answer a resize with the geometry the PTY actually holds, which the client adopts by columns only. A terminal left wider than the box that shows it now earns horizontal reach for as long as that lasts, whether the cause is another device's claim or the 40-column floor.
A replay clear must be in-stream, never reset()/clear(). xterm's write() is queued while Terminal.reset() is synchronous and does not reset the parser, so bytes queued just before a reset are parsed after it and fuse into the snapshot written next — write('p8'); reset(); write('rmissions') renders p8rmissions. All three replay paths now go through one queued \x1bc, and the gate pins that no module blanks the terminal with a clear()+reset() pair.
A renderer that stops painting now heals itself. iOS discards scheduled requestAnimationFrame callbacks when a PWA backgrounds, and xterm's RenderDebouncer only clears its handle from inside that callback, so one drop leaves every later refresh a no-op while the buffer keeps updating correctly. Codeman has exactly one xterm for the whole page load, so a single backgrounding wedged it until a reload. A watchdog cancels the stale handle and forces a repaint.
Every terminal capture carries a deadline that covers the response body. await fetch() settles on headers, so clearing the timer there left the multi-megabyte ?full=1 body unbounded — measured at 4026ms under a 1000ms deadline. A capture that outruns its deadline during a tab switch now falls back to the bounded tail rather than leaving a blank pane, a mute session and a tab stuck reporting aria-busy.
Output lost to a half-open WebSocket is reconciled. Terminal output frames carry no sequence number, so a socket that dies while SSE stays up leaves a hole nothing replays. The session is marked and the next successful open repaints it, with the marker cleared only once a repaint has actually happened.
The service worker's precache is generated by the build, and its cache key rotates per build. The list was hand-maintained with pre-hash names, so every entry 404'd in production and the failure was swallowed (15 of 23 verified failing against a running instance); caches.match now passes ignoreSearch: true, without which no precached entry was reachable behind the build's ?v= cache-bust. The old constant cache name meant activate never deleted anything, so assets from every past release accumulated forever.
Also: the crash trail is flattened and length-capped, so a server-controlled WebSocket close reason can no longer forge entries.