mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
+436
-48
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user