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:
Rounak Datta
2026-09-22 13:07:33 +05:30
co-authored by Claude Opus 5
parent abd39318e6
commit abf1d1f1ca
17 changed files with 934 additions and 124 deletions
+20
View File
@@ -3779,6 +3779,22 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;
/**
* The geometry the CLI is actually drawing for.
*
* Exposed because `resize()` can decline a request outright (arbitration
* below) and the asking client has no other way to find out: a browser
* terminal that keeps a shape the PTY refused renders garbled output rather
* than wrong-sized output, because Claude Code's repaints are computed from
* the width it was told (issue #464). Both transports report these back.
*/
get ptyCols(): number {
return this._ptyCols;
}
get ptyRows(): number {
return this._ptyRows;
}
/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
@@ -3864,6 +3880,10 @@ export class Session extends EventEmitter {
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
// Declined. The caller is told nothing here on purpose — the decision
// belongs to the session, not the socket — but the caller MUST report
// `ptyCols`/`ptyRows` back afterwards so the asking client can adopt
// the shape it did not get. Both transports do; see issue #464.
return;
}
this._mobileSizeOverride = true;
+107 -33
View File
@@ -73,10 +73,15 @@ const _crashDiag = {
// the storage quota and silently kill every later breadcrumb. Flatten and
// cap. CodemanDiag is loaded before app.js, but guard anyway — a
// diagnostic that can throw is worse than no diagnostic.
const flat =
typeof CodemanDiag !== 'undefined' && CodemanDiag.sanitizeDiagEntry
? CodemanDiag.sanitizeDiagEntry(msg)
: String(msg == null ? '' : msg).replace(/[\r\n\u2028\u2029]+/g, ' ').slice(0, 300);
// Bound to a local FIRST: `CodemanDiag?.x` still throws a ReferenceError
// when the identifier was never declared, and this is the one function in
// the app that must never throw.
const diag = typeof CodemanDiag !== 'undefined' ? CodemanDiag : null;
const flat = diag?.sanitizeDiagEntry
? diag.sanitizeDiagEntry(msg)
: String(msg == null ? '' : msg)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.slice(0, diag?.DIAG_ENTRY_MAX_CHARS ?? 300);
const entry = `${new Date().toISOString().slice(11,23)} ${flat}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
@@ -2451,8 +2456,14 @@ class CodemanApp {
// placeholder beats a messy screen dump there.
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = (await termRes.json())?.data ?? {};
// The no-param form is capped only by `terminalBufferMaxBytes` (32MB by
// default), so it is the largest body the frontend asks for anywhere —
// it gets the full-history budget, not the tail one.
const termCapture = await this._fetchTerminalCapture(
`/api/sessions/${this.activeSessionId}/terminal`,
{ full: true }
);
const termData = termCapture.json?.data ?? {};
if (termData.terminalBuffer) {
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
}
@@ -2711,6 +2722,13 @@ class CodemanApp {
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above.
this._syncStickyScrollBaseline();
// ⚠️ HERE, not in the `finally`. The marker means "this session lost
// output", and only a repaint that actually happened settles it. Clearing
// on every exit meant a reconcile that threw — or hit the new fetch
// deadline, which is the flaky-link case the marker exists for — dropped
// the gap silently, and nothing ever retried it. Left set, the next
// ws.onopen has another go.
this._markTerminalBufferReconciled(sessionId);
// Re-position local echo overlay at new prompt location
this._localEchoOverlay?.rerender();
// Resize PTY to match actual browser dimensions (critical for OpenCode
@@ -2723,11 +2741,6 @@ class CodemanApp {
console.error('needsRefresh reload failed:', err);
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
// Any completed reload for this session IS the reconcile, whoever asked
// for it — handleInit's SSE-reconnect branch and selectSession both land
// here or do the same work. Leaving the marker set would make the next
// ws.onopen replay the whole buffer a second time.
this._markTerminalBufferReconciled(sessionId);
}
}
@@ -2752,7 +2765,10 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`);
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
// so it needs the full-history budget. Defaulting to the tail budget
// gave the largest payload the smallest deadline.
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
const headersReceivedAt = capture.headersAt;
const termData = capture.json?.data ?? {};
@@ -3087,7 +3103,11 @@ class CodemanApp {
// Only after an unintentional close — a first connect has no gap, and
// refetching there would duplicate the buffer selectSession just wrote.
if (this._wsOutputGapSession === sessionId) {
this._wsOutputGapSession = null;
// NOT cleared here. `_onSessionNeedsRefresh` clears it once it has
// actually repainted; a reconcile that fails or is skipped (a buffer
// load already in flight, a tab switch) leaves the marker set so the
// next open retries. Re-entry is safe: `_terminalRefreshOwner` makes
// a second reconcile for the same session a no-op.
_crashDiag.log(`WS REOPEN: reconciling output gap for ${sessionId}`);
// Fire-and-forget: this is recovery, and a failure here must not stop
// the socket coming up. _onSessionNeedsRefresh already guards against
@@ -3116,6 +3136,10 @@ class CodemanApp {
// Input ACK — the server applied (or deduped) this seq; drop it from
// the durable queue so it can never be re-delivered/lost.
this._onWsInputAck(msg.seq, msg);
} else if (msg.t === 'zc') {
// Resize confirm — the geometry the PTY actually holds, which is not
// always the one this client asked for (issue #464).
this._onPtyGeometryReport(sessionId, msg.c, msg.r);
}
} catch {
// Ignore malformed messages
@@ -6482,11 +6506,14 @@ class CodemanApp {
// For that just-created-session case we flush (not discard) queued SSE events.
let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
// Hoisted out of the try: the catch needs to know whether the pane was
// blanked before the fetch, because only then is there nothing on screen.
let clearedBeforeFresh = false;
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit();
this.syncTerminalGeometry();
// Also push the new dimensions to the PTY. Without this, codex/codeman
// sees the size that was set the last time the throttled resize handler
@@ -6563,7 +6590,8 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
let clearedBeforeFresh = false;
// `clearedBeforeFresh` is declared above the try, because the catch reads
// it — re-declaring it here would shadow that and silently break it.
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
@@ -6612,12 +6640,26 @@ class CodemanApp {
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
{ full: useFullHistory }
);
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture;
try {
capture = await this._fetchTerminalCapture(
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
{ full: useFullHistory }
);
} catch (err) {
// The deadline made a slow link reachable for the first time, and the
// pane was already blanked above — so an abort here used to leave a
// black rectangle, discard the queued live output, and never reach
// `_connectWs`. Degrade to the bounded tail instead: less history, but
// a working tab. Only for the full-history pull; the tail has nothing
// smaller to fall back to, and a second failure is the honest floor.
if (err?.name !== 'AbortError' || !useFullHistory) throw err;
_crashDiag.log('FULL CAPTURE ABORTED → tail');
// It never loaded, so the next select must be allowed to try again.
this._fullHistoryLoaded.delete(sessionId);
capture = await this._fetchTerminalCapture(tailUrl);
}
const headersReceivedAt = capture.headersAt;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
@@ -6819,17 +6861,28 @@ class CodemanApp {
// and, because it goes through `forceReload`, a dropped and reopened
// WebSocket plus 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 every select would
// retry without ever converging. A race never produces this equality: its
// whole premise is that the pane was still at the size we asked it to
// leave. The other non-converging case, `Session.resize` declining a
// small viewport while a desktop claim is live, does not produce it
// either — that pane sits at the DESKTOP's size — so it still costs the
// one capped attempt, and stopping it needs the pane-ownership policy
// this does not touch.
// A race never produces this equality: its whole premise is that the pane
// was still at the size we asked it to leave. So the equality means the
// pane already IS what we asked for and a retry would capture the same
// frame twice.
//
// ⚠️ This used to also be the signature of a CLAMP, and that is now fixed
// at the source rather than worked around here (issue #464).
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did
// not, so a terminal under 40 columns or 10 rows reported a pane
// permanently bigger than itself and every select retried without ever
// converging. `syncTerminalGeometry()` now applies that floor to xterm as
// well, so the browser terminal IS the size it reports and the clamp can
// no longer manufacture a mismatch — which also means the repair below is
// reached only by cases it can actually repair.
//
// The other non-converging case, `Session.resize` declining a small
// viewport while a desktop claim is live, does not produce this equality
// either — that pane sits at the DESKTOP's size. It no longer needs
// repairing from here: the server reports the geometry the PTY actually
// holds ({"t":"zc"} / the resize response) and `_onPtyGeometryReport`
// adopts it, so the terminal matches the pane that is being drawn instead
// of replaying against one that never existed.
const captureMatchesRequestedSize =
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
@@ -6996,8 +7049,29 @@ class CodemanApp {
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
this._setTerminalLoadState(sessionId, selectGen, 'failed');
console.error('Failed to load session terminal:', err);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// The history did not load. That is not a reason to leave the tab dead:
// ⚠️ the socket is what carries LIVE output, and it is opened at the end
// of the happy path, so bailing here left the session mute until the user
// switched away and back.
this._connectWs(sessionId);
// Only when the pane was blanked for a replay that never came. A pane
// still holding its previous content is stale, not empty, and stacking a
// notice on top of readable output is worse than the staleness.
if (clearedBeforeFresh && this.terminal) {
this.terminal.write(
'\r\n\x1b[2m Could not load this session\u2019s history. Live output continues below.\x1b[0m\r\n'
);
}
// ⚠️ CLEAR, not 'failed'. `_setTerminalLoadState` only marks the TAB, and
// nothing ever cleared it on this path — so the tab kept its spinner and
// `aria-busy="true"` forever, telling every reader and every screen reader
// that a load was still running when it had already given up.
this._clearTerminalLoadState(sessionId, selectGen);
}
}
+70
View File
@@ -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,
};
}
+22 -27
View File
@@ -581,11 +581,10 @@ const KeyboardHandler = {
this._settleRestoreScroll = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
try {
app.fitAddon.fit();
} catch {}
}
// Floored fit, not a bare fitAddon.fit(): _shrinkPaddingToFit measures
// the leftover gap under the LAST row, so it has to run against the
// geometry xterm will actually keep (issue #464).
app.syncTerminalGeometry?.();
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
@@ -601,25 +600,23 @@ const KeyboardHandler = {
}, this.VIEWPORT_SETTLE_MS);
},
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
/**
* Send the settled terminal dimensions to the server (one-shot, for keyboard
* open/close — `throttledResize` deliberately holds the PTY's shape for the
* whole animation, so this is what stops it going stale).
*
* ⚠️ Delegates rather than computing its own numbers. This used to re-read
* `proposeDimensions()` and floor only what it POSTed, so on a phone with the
* keyboard up — where the proposal is routinely under ten rows — the PTY was
* told ten and xterm kept six, which is the #464 divergence. Worse, it read
* the proposal AFTER `_shrinkPaddingToFit()` had moved the container, so even
* unfloored its answer could differ from the fit above it. `sendResize` fits,
* floors and applies in one step, and additionally gets the WS fast path and
* the detached-session yield this hand-rolled POST never had.
*/
_sendTerminalResize() {
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
try {
const dims = app.fitAddon.proposeDimensions();
if (dims) {
const cols = Math.max(dims.cols, 40);
const rows = Math.max(dims.rows, 10);
app._lastResizeDims = { cols, rows };
// Declare the viewport type so resize arbitration can ignore this
// while a desktop connection is sizing the same session.
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
}
} catch {}
if (typeof app === 'undefined' || !app.activeSessionId) return;
app.sendResize?.(app.activeSessionId)?.catch?.(() => {});
},
/**
@@ -676,10 +673,8 @@ const KeyboardHandler = {
const currentPadding = parseInt(main.style.paddingBottom) || 0;
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
// Floored, like every other fit of the main terminal (#464).
app.syncTerminalGeometry?.();
}
} catch {}
},
+4 -2
View File
@@ -512,8 +512,10 @@ class NotificationManager {
}
// Re-fit terminal and send resize to PTY so this client's dimensions win.
// Fixes broken layout when switching between desktop and mobile on the same session.
if (this.app?.fitAddon && this.app?.activeSessionId) {
this.app.fitAddon.fit();
// sendResize fits (floored) as its first synchronous step, so the bare
// fit that used to precede it was both redundant and a chance to leave
// xterm at the unfloored proposal (#464).
if (this.app?.activeSessionId) {
this.app.sendResize(this.app.activeSessionId);
}
}
+5 -4
View File
@@ -5276,8 +5276,10 @@ Object.assign(CodemanApp.prototype, {
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.renderMuxSessions();
this.terminal.clear();
this.terminal.reset();
// Not a replay path, so the ordering hazard does not apply here — but
// there is one way to clear this terminal and this is it, so a future
// caller cannot copy a clear()+reset() pair out of here into one.
this._resetTerminalForReplay();
this.toast('All sessions and tmux killed', 'success');
}
} else {
@@ -5286,8 +5288,7 @@ Object.assign(CodemanApp.prototype, {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.terminal.clear();
this.terminal.reset();
this._resetTerminalForReplay();
this.toast('All tabs removed, tmux still running', 'info');
}
} catch (err) {
+3 -4
View File
@@ -155,10 +155,9 @@ Object.assign(CodemanApp.prototype, {
if (xtermViewport && scrollTop !== undefined) {
xtermViewport.scrollTop = scrollTop;
}
// Refit terminal to new container size
if (this.terminal && this.fitAddon) {
this.fitAddon.fit();
}
// Refit terminal to new container size. Through the one owner so the
// floor that is reported to the PTY is also the one xterm holds (#464).
this.syncTerminalGeometry?.();
});
},
+1 -1
View File
@@ -3097,7 +3097,7 @@ Object.assign(CodemanApp.prototype, {
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
if (!settleRailWidth) this.syncTerminalGeometry?.();
}
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
// rail-aware, so it has to run AFTER the two attributes above — the
+36
View File
@@ -3817,6 +3817,42 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* A PTY wider than this screen (issue #464). Another device holds the session's
sizing claim, so the browser terminal has adopted the PTY's width: the text is
rendered CORRECTLY, it simply does not fit. Without horizontal reach the right
columns sit behind .terminal-container's overflow:hidden with no gesture that
can get to them — correct-but-unreachable is no better than garbled.
Present only while the mismatch is; _setPtyOversized() owns the class. */
.terminal-container.pty-oversized {
/* ⚠️ BOTH axes, explicitly, and touch-action here rather than only on the
.touch-device variant below. mobile.css loads after this file and sets
`.terminal-container { overflow: visible; touch-action: none }` — a bare
`overflow-x` would then leave overflow-y computing to `auto` (CSS promotes
a `visible` paired with a non-visible axis), handing the browser a vertical
scroll container the terminal's own touch handler does not know about. */
overflow-x: auto;
overflow-y: hidden;
/* pan-x ONLY: the terminal's touchmove handler still owns vertical scrolling. */
touch-action: pan-x;
}
/* xterm's own element is width:100% above, so the container would see no
overflow to scroll even though .xterm-screen is wider than both. */
.terminal-container.pty-oversized .xterm {
width: max-content;
min-width: 100%;
}
/* The inner elements carry touch-action: none of their own (both here and in
mobile.css), so the container's pan-x is not enough on its own. */
.touch-device .terminal-container.pty-oversized,
.touch-device .terminal-container.pty-oversized .xterm,
.touch-device .terminal-container.pty-oversized .xterm-viewport,
.touch-device .terminal-container.pty-oversized .xterm-screen,
.terminal-container.pty-oversized .xterm,
.terminal-container.pty-oversized .xterm-viewport,
.terminal-container.pty-oversized .xterm-screen {
touch-action: pan-x;
}
/* Touch devices: prevent browser from claiming the touch gesture before
our JS touchmove handler fires. Without this, the browser starts native
scrolling during the first few px of finger travel and ignores our
+1 -1
View File
@@ -154,7 +154,7 @@ Object.assign(CodemanApp.prototype, {
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.fitAddon?.fit();
else this.syncTerminalGeometry?.();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
+202 -50
View File
@@ -587,12 +587,12 @@ Object.assign(CodemanApp.prototype, {
if (isMobileSafari) {
// Wait for layout, then fit multiple times to ensure proper sizing
requestAnimationFrame(() => {
this.fitAddon.fit();
this.syncTerminalGeometry();
// Double-check after another frame
requestAnimationFrame(() => this.fitAddon.fit());
requestAnimationFrame(() => this.syncTerminalGeometry());
});
} else {
this.fitAddon.fit();
this.syncTerminalGeometry();
}
// Whenever that first fit runs — on this line, or a frame or two later on
// the mobile-Safari branch above — it measures whatever font the browser has
@@ -999,10 +999,6 @@ Object.assign(CodemanApp.prototype, {
this._resizeTimeout = null;
this._lastResizeDims = null;
// Minimum terminal dimensions to prevent vertical text wrapping
const MIN_COLS = 40;
const MIN_ROWS = 10;
const throttledResize = () => {
if (this._tabRailResizeOwnsObserver) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
@@ -1022,10 +1018,6 @@ Object.assign(CodemanApp.prototype, {
}
this._resizeTimeout = setTimeout(() => {
this._resizeTimeout = null;
// Fit xterm.js to final container dimensions
if (this.fitAddon) {
this.fitAddon.fit();
}
// Flush any stale flicker buffer before clearing viewport
if (this.flickerFilterBuffer) {
if (this.flickerFilterTimeout) {
@@ -1034,24 +1026,35 @@ Object.assign(CodemanApp.prototype, {
}
this.flushFlickerBuffer();
}
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
// Hold the PTY's shape while the virtual keyboard is up: a SIGWINCH per
// step of the OS animation makes Ink re-render at a row count that is
// about to change again, and shifts the accessory toolbar mid-typing.
// KeyboardHandler's settle timer sends ONE resize once the animation
// stops (`_sendTerminalResize`), so the PTY is not left stale.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
// Same yield as sendResize: never resize a PTY whose session is showing
// in its own window. Dragging the dashboard's border must not reshape it.
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
// This block used to fit unconditionally and skip only the SIGWINCH,
// which is the one combination that cannot be right: it moves xterm to
// a shape the PTY is never told about, and Claude Code computes its
// repaints from the shape it was told. Withhold both, or neither —
// a reflow nothing is rendering for buys nothing and costs correctness.
const dims = this.activeSessionId && !keyboardUp && !detachedElsewhere ? this.syncTerminalGeometry() : null;
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
// and xterm does not hold — the write-only guess this whole change exists
// to remove. An unmeasurable terminal has nothing to say; the next
// resize event says it.
if (dims) {
const { cols, rows } = dims;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
// Clear viewport + scrollback ONLY when dimensions actually change.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// syncTerminalGeometry() reflowed content: lines at old width may wrap to
// more rows, pushing overflow into scrollback. Ink's cursor-up count is
// based on the pre-reflow line count, so ghost renders accumulate there.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
@@ -3380,11 +3383,13 @@ Object.assign(CodemanApp.prototype, {
* is validated against xterm 6.x and CANNOT be covered by the CI gate:
* `_renderService` is only constructed by `Terminal.open()`, which needs a
* real DOM, and the gate runs in node. `test/xterm-private-api.test.ts` pins
* the dependency RANGE instead, so a major bump fails there and sends someone
* to re-check this by hand; `test/terminal-resilience.test.ts` covers the
* decision half. If the path ever goes stale the watchdog silently stops
* healing — that is the failure mode to watch for, and why the range guard
* exists at all.
* the RESOLVED lockfile version instead, so ANY bump fails there — not only a
* major — and sends someone to re-check this by hand; the declared `^6.0.0`
* range was the wrong assertion in both directions, since 6.4.0 could rename a
* private field while resolving inside it. `test/terminal-resilience.test.ts`
* covers the decision half. If the path ever goes stale the watchdog silently
* stops healing — that is the failure mode to watch for, and why the version
* guard exists at all.
*/
_startRenderLivenessWatchdog() {
this._stopRenderLivenessWatchdog();
@@ -5232,7 +5237,7 @@ Object.assign(CodemanApp.prototype, {
setFontSize(size) {
this.terminal.options.fontSize = size;
document.getElementById('fontSizeDisplay').textContent = size;
this.fitAddon.fit();
this._refitAfterCellSizeChange();
localStorage.setItem('codeman-font-size', size);
// Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont();
@@ -5261,9 +5266,9 @@ Object.assign(CodemanApp.prototype, {
// without needing a tab switch. The fit below still runs, so the terminal
// is never left unfitted if the wait is slow.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
if (this.terminal?.options?.fontFamily === resolved) this._refitAfterCellSizeChange();
});
this.fitAddon?.fit();
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) {
@@ -5306,9 +5311,9 @@ Object.assign(CodemanApp.prototype, {
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
// below still runs, so the terminal is never left unfitted.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
if (this.terminal?.options?.fontWeight === fontWeight) this._refitAfterCellSizeChange();
});
this.fitAddon?.fit();
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
for (const [, entry] of this.teammateTerminals || []) {
@@ -5399,19 +5404,95 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
* The geometry this terminal would report right now, floors applied.
* Reads only — `syncTerminalGeometry()` is what makes it true of xterm.
* @returns {{cols: number, rows: number}|null}
*/
getTerminalDimensions() {
const MIN_COLS = 40;
const MIN_ROWS = 10;
const dims = this.fitAddon?.proposeDimensions();
// Never throws. `proposeDimensions()` reads a rendered element and throws
// on a terminal that has been disposed or detached mid-resize, which is an
// ordinary outcome on a tab switch — and this is called from the settle
// timer and the resize observer, where an exception takes the rest of the
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
try {
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
} catch {
return null;
}
},
/**
* Fit xterm to its container and return the geometry that was APPLIED.
*
* ⚠️ THE ONLY function that may change the terminal's size, and the only
* source of the numbers sent to the server. `fitAddon.fit()` on its own is
* not enough and the gap is issue #464: fit() resizes xterm to
* `proposeDimensions()` RAW, while every server-facing path reported those
* dimensions floored at 40x10. Whenever the floor bit — a phone with the
* keyboard up routinely proposes under ten rows — the PTY was told one shape
* and xterm held another, and Claude Code then computed every repaint for a
* screen that did not exist. See the note in constants.js for what that
* renders as, and why the floor is not negotiable at either end.
*
* Three call sites each used to do their own fit-then-clamp
* (`throttledResize`, `sendResize`, KeyboardHandler's one-shot), which is
* three chances to disagree; two of them also re-read `proposeDimensions()`
* after the fit, so a container that moved in between — `_shrinkPaddingToFit`
* runs exactly there — changed the answer without touching xterm.
*
* The second resize only happens when the floor actually bites, so the
* ordinary path still reflows once, as before.
*
* @returns {{cols: number, rows: number}|null} null when the terminal cannot be measured
*/
syncTerminalGeometry() {
if (!this.fitAddon || !this.terminal) return null;
try {
this.fitAddon.fit();
} catch {
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
}
const dims = this.getTerminalDimensions();
if (!dims) return null;
return {
cols: Math.max(dims.cols, MIN_COLS),
rows: Math.max(dims.rows, MIN_ROWS),
};
return this._resizeTerminalTo(dims) ? dims : null;
},
/**
* Re-measure after something changed the CELL size, and tell the server.
*
* ⚠️ A font change is a geometry change. Bigger glyphs mean fewer columns in
* the same box, and the PTY is drawing for a column count nobody updated:
* `setFontSize`, `setFontFamily` and `setFontWeight` all refitted the terminal
* and sent NOTHING, so raising the font on a phone could drop the browser
* below the columns the CLI was still wrapping at until some unrelated resize
* event happened along. That is issue #464 reached through the font menu.
*
* With no session there is no PTY to tell, and a session detached into its own
* window is not this terminal's to resize — `sendResize` makes that call, and
* fits as its first synchronous step, so this never fits twice.
*/
_refitAfterCellSizeChange() {
if (this.activeSessionId) {
this.sendResize(this.activeSessionId)?.catch?.(() => {});
return;
}
this.syncTerminalGeometry();
},
/**
* Make xterm exactly `dims`. Idempotent, and never throws at a caller — a
* terminal disposed mid-resize is an ordinary outcome on a tab switch.
* @returns {{cols: number, rows: number}|null} the applied geometry
*/
_resizeTerminalTo(dims) {
if (!this.terminal || !dims) return null;
if (this.terminal.cols === dims.cols && this.terminal.rows === dims.rows) return dims;
try {
this.terminal.resize(dims.cols, dims.rows);
return dims;
} catch {
return null;
}
},
/**
@@ -5421,21 +5502,25 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<boolean>} Whether dimensions changed from the last send
*/
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
// One PTY cannot hold two sizes. A detached session is owned by its own
// window, and the dashboard's terminal is narrower than that window because
// the session rail takes width the popup does not have — so both sizing it
// makes the CLI draw frames that fit neither, which garbles the popup. The
// dashboard yields; the solo window sizes what it alone displays.
// (_maybeRefetchFullHistory already stands aside for the same reason.)
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
// xterm right, and only the SERVER write is the dashboard's to withhold —
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
// performs its one settle-time refit through this call and has no fallback.
// ⚠️ BEFORE the fit, never after. This used to fit first and withhold only
// the server write, on the reasoning that the local reflow keeps the
// dashboard's own xterm right. It does not: it leaves this xterm at a shape
// the PTY was never told about, which is the #464 divergence exactly — and
// the popup that DOES own the PTY is drawing for its own width, so the
// dashboard's reflow is to a size nothing is rendering for. Withholding the
// resize means withholding all of it. tab-rail-resize performs its one
// settle-time refit through this call and has no fallback, which is correct:
// a pane it does not own is not its to refit either.
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
const dims = this.getTerminalDimensions();
// Fit, floor, and apply in one step so the numbers below are the numbers
// xterm is actually holding.
const dims = this.syncTerminalGeometry();
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
@@ -5468,14 +5553,81 @@ Object.assign(CodemanApp.prototype, {
}
const body = { ...dims, viewportType };
if (options.force) body.force = true;
await fetch(`/api/sessions/${sessionId}/resize`, {
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Same report the WS path gets as a {"t":"zc"} frame. An older server
// answers `{}`, which reconciles to a no-op rather than throwing.
try {
const applied = (await res.json())?.data ?? {};
this._onPtyGeometryReport(sessionId, applied.cols, applied.rows);
} catch {
/* a body that is not JSON tells us nothing about the PTY; keep our own geometry */
}
return changed;
},
/**
* Adopt the geometry the server says the PTY actually has.
*
* ⚠️ The server is the authority and this client is not always obeyed.
* `Session.resize` declines a small-viewport request outright while a desktop
* connection holds an active sizing claim, and says nothing — resize was
* write-only until #464. A terminal that keeps its own shape after such a
* refusal does not render "too narrow", it renders GARBLED: Claude Code wraps
* its frame at the width it was told and walks the cursor up that many rows,
* so a mismatch makes its erase count come out short and each repaint paints
* over rows it never cleared. Measured against a real xterm — a PTY believing
* 120 columns against a 62-column terminal draws every wrapped line twice.
*
* Adopting can leave the pane wider than the viewport, 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; nothing here is worth trapping content behind.
*
* Self-resolving: `_startMobileResizeRetry` re-sends this device's dimensions
* on a timer, so the pane comes back to this screen once the desktop goes
* idle, and the next report clears the class and the notice with it.
*/
_onPtyGeometryReport(sessionId, cols, rows) {
if (!this.terminal || sessionId !== this.activeSessionId) return;
const local = { cols: this.terminal.cols, rows: this.terminal.rows };
const { adopt, oversized } = window.CodemanTerminalGeometry.reconcilePtyGeometry(local, { cols, rows });
if (!adopt) {
this._setPtyOversized(false);
return;
}
if (!this._resizeTerminalTo({ cols, rows })) return;
// The numbers we would report next are now the PTY's, not the container's:
// without this the dedupe in throttledResize/sendResize compares against a
// request that was refused and suppresses the retry that recovers the pane.
this._lastResizeDims = { cols, rows };
this._setPtyOversized(oversized);
},
/**
* Let the reader reach a pane wider than their screen, and say why once.
*
* Chrome for a condition that is not happening is clutter, so both the scroll
* affordance and the notice exist only while the mismatch does. The notice is
* once per transition, not per report: reports arrive on every resize, and a
* toast that repeats is noise about a situation the reader can already see.
*/
_setPtyOversized(oversized) {
const container = document.getElementById('terminalContainer');
if (container) container.classList.toggle('pty-oversized', !!oversized);
if (oversized === this._ptyOversized) return;
this._ptyOversized = oversized;
if (oversized) {
// 53 characters: measured at one line on a 430px phone. The longer
// wording wrapped to two, which is a lot of the terminal to cover for a
// notice about a condition that resolves itself.
this.showToast('Another device is setting the width — scroll sideways', 'info');
}
},
/**
* Send input to the active session.
* @param {string} input - Text to send (include \r for Enter)
+6 -1
View File
@@ -2115,7 +2115,12 @@ export function registerSessionRoutes(
const session = findSessionOrFail(ctx, id, req);
session.resize(cols, rows, { viewportType, force });
return {};
// Answer with the geometry the PTY ACTUALLY holds, which is not always the
// one asked for: `Session.resize` declines small-viewport requests while a
// desktop connection holds an active sizing claim. A browser terminal left
// at a shape the PTY refused renders garbled output, not merely wrong-sized
// output, so the client adopts these (issue #464).
return { cols: session.ptyCols, rows: session.ptyRows };
});
// ========== Get Last Response (from transcript JSONL) ==========
+13
View File
@@ -22,6 +22,9 @@
* {"t":"c"} — clear terminal
* {"t":"r"} — needs refresh (reload buffer)
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
* {"t":"zc","c":N,"r":N} — resize confirm: the geometry the PTY now holds, which
* is NOT always the one requested (see Session.resize
* arbitration). Clients adopt it — issue #464.
* Client -> Server:
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
* optional reliable-delivery tags: the server applies each
@@ -240,6 +243,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
// Report the geometry that actually took. Resize used to be
// write-only, so a client whose request was declined by the
// arbitration above — or floored, or overridden by another device
// — had no way to find out, and went on rendering a CLI's repaints
// against a screen shape that did not exist (issue #464). Sent
// unconditionally: it is ~30 bytes on a debounced, rare message,
// and always-send means the client needs no "did it take?" state.
if (socket.readyState === 1) {
socket.send(`{"t":"zc","c":${session.ptyCols},"r":${session.ptyRows}}`);
}
}
} catch {
// Ignore malformed messages