mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen". The screenshot is not a dropped frame or a frozen renderer — it is arithmetic. 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. A browser terminal of a different width makes each logical line occupy more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased: doubled lines, and short tool summaries sitting inside longer prose rows with the prose's tail still visible. Reproduced against this repo's own xterm before changing anything — a 120-column PTY against a 62-column terminal renders every wrapped line twice. `test/ terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching widths beside it, so the assertion cannot be satisfied by code that fixes nothing. Four ways the two drifted apart, none of them observable from either end: 1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every server-facing path reported those floored at 40x10. Measured in Chrome at 430px: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. Three call sites each did their own fit-then-floor, and two re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly there, so the container had moved. 2. `throttledResize` (keyboard up) and `sendResize` (session detached into its own window) reflowed locally and withheld only the SIGWINCH. That is the one combination that cannot be right: a reflow nothing is rendering for buys nothing and costs correctness. Both now withhold everything, and the keyboard's settle timer still sends the one resize that stops the PTY going stale. 3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry change — and told the server nothing at all, so raising the font on a phone left the CLI wrapping at the old column count. 4. `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim, and said nothing, because resize was write-only. `syncTerminalGeometry()` is now the one function that may change the terminal's size: it fits, floors and applies as a single step, so the numbers xterm holds are the numbers the server is told. A test sweeps every module for a bare `fit()` on the main terminal, and finds exactly one — the owner's own. For (4) the client cannot win, so it is told the truth instead: both transports answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal that keeps a shape the PTY refused does not render "too narrow", it renders garbled. Adopting can leave the pane wider than the screen and the container is `overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as long as the mismatch lasts: correct-and-reachable beats correct-and-clipped beats garbled. That rule sets both overflow axes and its own `touch-action` because mobile.css loads later and sets `.terminal-container { overflow: visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y computing to `auto` and hand the browser a vertical scroll container the terminal's touch handler knows nothing about. Verified in Chrome at 430px against a live server, with a desktop client holding the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y: hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own vertical scrolling. The pre-fix build was measured in the same harness for the control. Two things this deliberately does not do. It does not change who owns the pane size — the desktop still wins, and `_startMobileResizeRetry` still takes it back once that goes idle. And `throttledResize` still holds the PTY's shape for the whole keyboard animation rather than sending a SIGWINCH per step; that decision predates this and was not re-tested here. Also in this commit, Ark0N's third-pass review items on #431: - The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes` (32MB) — the largest body the frontend asks for anywhere. One carried no deadline at all and the other got the 15s tail budget. Both now take the full-history budget. - A `?full=1` capture that outruns its deadline falls back to the bounded tail. The pane is blanked before that fetch, so an abort used to leave a black rectangle, discard the queued live output and never reach `_connectWs`. A failed load now still opens the socket, says one dim line where the content would have been, and clears the tab's spinner — which nothing did, so a failed select left `aria-busy="true"` set forever. - `_wsOutputGapSession` is cleared at the repaint that settles it, not in a `finally` that also ran on the catch. A reconcile that threw, or hit the new deadline — the flaky link the marker exists for — dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either. - The replay-clear invariant is pinned in the gate, which is the drift this PR exists to fix: `_resetTerminalForReplay` must be a queued write and nothing else, and no module may blank the terminal with a `clear()+reset()` pair. - `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was never declared, and that is the one function in the app that must not throw. - panels-ui's two kill-all clears route through the same helper, and the xterm-version guard's comment says "resolved lockfile version" rather than "dependency RANGE", which is what it has pinned since the last round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
abd39318e6
commit
abf1d1f1ca
@@ -1673,6 +1673,69 @@ function sanitizeDiagEntry(msg) {
|
||||
.slice(0, DIAG_ENTRY_MAX_CHARS);
|
||||
}
|
||||
|
||||
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
|
||||
//
|
||||
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
|
||||
// frame at the width the PTY reported and walking the cursor up that many
|
||||
// ROWS. So a browser terminal whose width differs from the PTY's makes every
|
||||
// repaint arithmetic wrong: a logical line occupies more physical rows than
|
||||
// Ink counted, `eraseLines(n)` clears too few of them, and the new frame paints
|
||||
// over rows that were never erased. Measured against a real xterm — a PTY
|
||||
// believing 120 columns against a 62-column terminal renders each wrapped line
|
||||
// twice, and a shorter replacement line leaves the tail of the old one behind.
|
||||
// That is exactly the doubled rows and half-overwritten prose in the report.
|
||||
//
|
||||
// The floor exists because a PTY a handful of columns wide makes any CLI wrap
|
||||
// every word; it is NOT a display preference, so the browser terminal has to
|
||||
// honour it too. Three separate call sites used to fit xterm to the RAW
|
||||
// proposal and report the CLAMPED one, which is how the two drifted apart with
|
||||
// nothing to notice: resize is write-only, so nobody could see the disagreement.
|
||||
const TERMINAL_MIN_COLS = 40;
|
||||
const TERMINAL_MIN_ROWS = 10;
|
||||
|
||||
/**
|
||||
* The geometry to apply AND report — there is only ever one answer to both.
|
||||
* @param {{cols: number, rows: number}|null|undefined} proposed
|
||||
* @returns {{cols: number, rows: number}|null}
|
||||
*/
|
||||
function clampTerminalDimensions(proposed) {
|
||||
if (!proposed || !Number.isFinite(proposed.cols) || !Number.isFinite(proposed.rows)) return null;
|
||||
return {
|
||||
cols: Math.max(Math.trunc(proposed.cols), TERMINAL_MIN_COLS),
|
||||
rows: Math.max(Math.trunc(proposed.rows), TERMINAL_MIN_ROWS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two geometries are the same screen. Either being absent is a mismatch. */
|
||||
function terminalGeometryAgrees(a, b) {
|
||||
return !!a && !!b && a.cols === b.cols && a.rows === b.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do when the server reports the PTY's real geometry.
|
||||
*
|
||||
* The server is the authority: it owns the PTY the CLI is drawing for, and it
|
||||
* can refuse a resize outright (`Session.resize` ignores small-viewport
|
||||
* requests while a desktop connection holds an active sizing claim) without
|
||||
* the asking client ever being told. A terminal that keeps its own shape after
|
||||
* such a refusal renders garbage; one that adopts the PTY's shape renders the
|
||||
* truth, and may simply be wider than the screen can show.
|
||||
*
|
||||
* Correct-and-reachable beats correct-and-clipped beats garbled, so a pane
|
||||
* wider than the viewport also earns horizontal reach — see `.pty-oversized`.
|
||||
*
|
||||
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
|
||||
* @param {{cols: number, rows: number}|null} pty - what the server just reported
|
||||
* @returns {{adopt: boolean, oversized: boolean}}
|
||||
*/
|
||||
function reconcilePtyGeometry(local, pty) {
|
||||
if (!pty || !Number.isFinite(pty.cols) || !Number.isFinite(pty.rows)) {
|
||||
return { adopt: false, oversized: false };
|
||||
}
|
||||
if (terminalGeometryAgrees(local, pty)) return { adopt: false, oversized: false };
|
||||
return { adopt: true, oversized: !!local && pty.cols > local.cols };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
@@ -1690,4 +1753,11 @@ if (typeof window !== 'undefined') {
|
||||
FETCH_DEADLINE_MAX_MS,
|
||||
};
|
||||
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
|
||||
window.CodemanTerminalGeometry = {
|
||||
clampTerminalDimensions,
|
||||
terminalGeometryAgrees,
|
||||
reconcilePtyGeometry,
|
||||
TERMINAL_MIN_COLS,
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user