Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience

fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches
This commit is contained in:
Codeman maintainer
2026-09-23 11:32:14 +02:00
33 changed files with 2221 additions and 172 deletions
+436 -48
View File
@@ -551,6 +551,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
}
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
// device, not just touch: the rAF-discard behaviour is worst on an iOS PWA
// but a stale handle wedges the debouncer identically anywhere it happens.
this.terminal.onRender(() => {
this._lastRenderAt = Date.now();
});
this._startRenderLivenessWatchdog();
// CJK IME input — textarea in index.html, just wire up send
this._cjkInput = null;
if (typeof CjkInput !== 'undefined') {
@@ -587,12 +595,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
@@ -764,6 +772,32 @@ Object.assign(CodemanApp.prototype, {
let longPressStartX = 0;
let longPressStartY = 0;
let touchStartY = 0;
let touchStartX = 0;
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
// diagonal drag cannot pan and scroll at the same time.
let panAxis = null;
/**
* Can this gesture pan sideways? Only while the terminal is wider than
* the box showing it (`.term-overflows-x`, set by
* `_syncTerminalOverflowAffordance`).
*
* ⚠️ This has to be done in JS. `touch-action: pan-x` alone does nothing
* for the sessions the affordance targets: `touchstart` calls
* preventDefault() for every 'content' tap — the normal case for a
* mouse-tracking TUI sitting at the bottom of its buffer — which cancels
* the browser's pan before it starts. Measured under touch emulation, a
* 140px horizontal swipe reached scrollLeft 141 without that
* preventDefault and 0 with it. It only ever worked for shell sessions,
* while scrolled up, or with a mouse.
*/
const canPanHorizontally = () =>
// Both halves. The class is what makes the container a scroller at all
// (`overflow-x: auto`); without it `scrollLeft` silently stays 0, and a
// gesture locked to 'x' on that basis would do nothing AND suppress the
// vertical scroll it should have been. The measurement is the second
// half because sub-pixel cell widths can leave a stray pixel of
// scrollWidth on a terminal that fits perfectly well.
container.classList.contains('term-overflows-x') && container.scrollWidth - container.clientWidth > 1;
let tapStartedWithTerminalFocus = false;
let tapStartIntentCache = null;
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
@@ -783,6 +817,8 @@ Object.assign(CodemanApp.prototype, {
touchLastX = ev.touches[0].clientX;
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
touchStartX = touchLastX;
panAxis = null;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -851,8 +887,15 @@ Object.assign(CodemanApp.prototype, {
}
if (ev.touches.length === 1 && isTouching) {
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
didScroll = true;
const touchX = ev.touches[0].clientX;
if (!didScroll) {
const travelY = Math.abs(touchY - touchStartY);
const travelX = Math.abs(touchX - touchStartX);
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
if (travelY >= TAP_THRESHOLD || sideways) {
didScroll = true;
panAxis = sideways && travelX > travelY ? 'x' : 'y';
}
}
// Below the tap threshold, treat the gesture as a potential tap:
// don't preventDefault (iOS needs click synthesis to show the
@@ -862,6 +905,15 @@ Object.assign(CodemanApp.prototype, {
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
if (panAxis === 'x') {
// Pan the container, and touch nothing the vertical path owns —
// no pixelAccum, no velocity, so touchend cannot turn a sideways
// swipe into a momentum fling down the scrollback.
container.scrollLeft -= touchX - touchLastX;
touchLastX = touchX;
touchLastY = touchY;
return;
}
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -999,10 +1051,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 +1070,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 +1078,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).
@@ -1084,11 +1139,24 @@ Object.assign(CodemanApp.prototype, {
}
}
if (!sentViaWs) {
// ⚠️ The reply carries the geometry that actually took, and this
// is the path where a declined resize is LEAST likely to be
// noticed: no socket means no `{"t":"zc"}` frame either, so
// discarding it here left the one transport that cannot hear the
// answer also not asking for it.
const resizedSessionId = this.activeSessionId;
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
})
.then(async (res) => {
const applied = (await res.json())?.data ?? {};
this._onPtyGeometryReport(resizedSessionId, applied.cols, applied.rows);
})
.catch(() => {
/* a resize that never landed tells us nothing about the PTY */
});
}
}
}
@@ -3349,7 +3417,107 @@ Object.assign(CodemanApp.prototype, {
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
/**
* Watchdog for a frozen renderer.
*
* iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
* the background — not deferred, never delivered. xterm's RenderDebouncer
* only clears its `_animationFrame` handle from INSIDE that callback, so once
* one is dropped the handle stays permanently non-undefined and every later
* `refresh()` returns on its first line. Parsing is decoupled from rendering,
* so bytes keep filling the buffer correctly and nothing throws: the terminal
* is simply frozen until the page is reloaded.
*
* Codeman is more exposed than an app that mounts a terminal per session —
* there is exactly ONE xterm instance for the whole page load, so a single
* backgrounding can wedge it for the rest of the session.
*
* The heal is what `_innerRefresh` would have done: cancel the stale handle,
* clear the field, and request a full repaint (which schedules a fresh rAF).
* Cancelling a genuinely pending handle is harmless — the full repaint that
* follows covers whatever it was going to draw.
*
* Discipline for reaching into xterm privates, and it is not optional: every
* access is optional-chained and the whole body is wrapped, so a shape change
* upstream degrades to a no-op. A self-heal that can break the terminal it is
* healing is worse than no self-heal.
*
* ⚠️ The field path (`_core._renderService._renderDebouncer._animationFrame`)
* 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 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();
this._lastRenderAt = Date.now();
this._lastTerminalWriteAt = 0;
this._renderLivenessTimer = setInterval(() => {
try {
if (typeof CodemanRenderLiveness === 'undefined') return;
const kick = CodemanRenderLiveness.shouldKickRenderer({
wroteAt: this._lastTerminalWriteAt || 0,
renderedAt: this._lastRenderAt || 0,
now: Date.now(),
// A hidden terminal legitimately stops rendering (xterm pauses it),
// so only a VISIBLE one that owes us a frame counts as frozen.
visible: document.visibilityState === 'visible' && !!this.terminal?.element?.isConnected,
});
if (!kick) return;
const kicked = this._kickRenderer();
_crashDiag.log(`RENDER STALL: kick=${kicked}`);
// Treat the kick as the render for accounting purposes either way, so a
// terminal we cannot heal logs once per stall rather than every tick.
this._lastRenderAt = Date.now();
} catch {
/* a watchdog must never throw into the interval */
}
}, RENDER_LIVENESS_POLL_MS);
},
_stopRenderLivenessWatchdog() {
if (this._renderLivenessTimer) {
clearInterval(this._renderLivenessTimer);
this._renderLivenessTimer = null;
}
},
/**
* Do what xterm's dropped `_innerRefresh` would have done. Never throws.
* @returns {boolean} true if a stale handle was found and cleared.
*/
_kickRenderer() {
try {
const renderService = this.terminal?._core?._renderService;
const debouncer = renderService?._renderDebouncer;
if (!debouncer || typeof renderService.refreshRows !== 'function') return false;
const handle = debouncer._animationFrame;
if (handle === undefined) return false; // not wedged — nothing to clear
try {
cancelAnimationFrame(handle);
} catch {
/* a stale handle may no longer be cancellable; clearing it is the point */
}
debouncer._animationFrame = undefined;
renderService.refreshRows(0, Math.max(0, (this.terminal.rows || 1) - 1));
return true;
} catch {
return false;
}
},
batchTerminalWrite(data) {
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
this._lastTerminalWriteAt = Date.now();
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
@@ -5223,7 +5391,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();
@@ -5252,9 +5420,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) {
@@ -5297,9 +5465,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 || []) {
@@ -5390,19 +5558,99 @@ 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),
};
if (!this._resizeTerminalTo(dims)) return null;
// The floor can leave this terminal wider than the box that shows it, and
// that clips columns with no gesture to reach them (issue #464, item 4).
this._scheduleOverflowAffordanceSync();
return dims;
},
/**
* 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;
}
},
/**
@@ -5412,21 +5660,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
@@ -5459,14 +5711,150 @@ 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 `.term-overflows-x` 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 } = window.CodemanTerminalGeometry.reconcilePtyGeometry(local, { cols, rows });
// Columns only, and the local row count is kept — see reconcilePtyGeometry
// for why adopting rows put the CLI's input line below the container with
// nothing able to scroll to it.
if (adopt && this._resizeTerminalTo({ cols, rows: local.rows })) {
// 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: local.rows };
}
// Is the PTY at a width this container did not ask for? Compared against
// what we WOULD request, not against what the terminal currently holds:
// once adopted those two are equal, so the second question answers itself
// false and the condition would look resolved while it is still true.
// The floor widens this terminal too, and that is the reader's own font
// setting rather than another device — hence the comparison, not `>`.
const wanted = this.getTerminalDimensions();
this._paneWidthRefused = !!wanted && Number.isFinite(cols) && cols !== wanted.cols;
this._scheduleOverflowAffordanceSync();
},
/**
* Measure on the NEXT frame, coalesced.
*
* `terminal.resize()` updates the buffer synchronously but the screen element
* takes its new width with the render, so measuring in the same tick reads
* the size the terminal just left. Coalesced because a settling container
* fires several resizes and only the last one's measurement is the truth.
*/
_scheduleOverflowAffordanceSync() {
if (typeof requestAnimationFrame !== 'function') {
this._syncTerminalOverflowAffordance();
return;
}
if (this._overflowAffordanceFrame) return;
this._overflowAffordanceFrame = requestAnimationFrame(() => {
this._overflowAffordanceFrame = null;
this._syncTerminalOverflowAffordance();
});
},
/**
* Let the reader reach a pane wider than the box that shows it.
*
* ⚠️ Keyed on what actually does not FIT, not on a PTY mismatch. Two
* different causes put the terminal wider than its container and both leave
* columns unreachable behind `.terminal-container`'s clip:
*
* - another device holds the sizing claim, so this terminal adopts a width
* it did not ask for; and
* - the 40-column floor. On a 360px phone, font 18 applies 40 columns and
* paints 433px, and font 24 paints 578px — 218px, 38% of the pane, with no
* gesture that could reach it. `increaseFontSize` goes to 24 and applies
* immediately, so that is two taps away, and the PTY agrees with the
* terminal throughout: a mismatch test would never fire.
*
* Measured rather than derived from cell arithmetic, because the cell width
* is fractional and the container's padding is not ours to assume. One pixel
* of slack keeps sub-pixel rounding from flapping the class.
*/
_syncTerminalOverflowAffordance() {
// ⚠️ Nothing in here may throw. It runs off every geometry change, which is
// the resize path, and the affordance is cosmetic: a terminal that cannot
// be measured — disposed mid-resize, or a harness with no real DOM — must
// lose the scroll affordance, never the resize.
let container = null;
let overflows = false;
try {
container = document.getElementById('terminalContainer');
const screen = container?.querySelector('.xterm-screen');
if (container && screen) {
overflows = screen.getBoundingClientRect().width - container.clientWidth > 1;
}
} catch {
/* unmeasurable; fall through with the affordance off */
}
container?.classList.toggle('term-overflows-x', overflows);
// The notice tells the reader to scroll sideways, so it is only true advice
// once there is something to scroll. A wide PTY on a screen wide enough to
// show it needs no explanation and gets none.
if (!overflows || !this._paneWidthRefused) {
this._paneOwnedElsewhere = false;
return;
}
this._notePaneOwnedElsewhere();
},
/**
* Say, once, that this pane's width belongs to another device.
*
* Once per transition, not per report: reports arrive on every resize, and a
* toast that repeats is noise about a situation already on screen. Silent
* when it resolves — the pane simply reflows back to this screen.
*/
_notePaneOwnedElsewhere() {
if (this._paneOwnedElsewhere) return;
this._paneOwnedElsewhere = true;
// 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)