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
+27
View File
@@ -4098,6 +4098,29 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;
/**
* The geometry the CLI is actually drawing for, or null when nothing is
* drawing.
*
* 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 this back.
*
* ⚠️ NULL WITHOUT A PANE, never the field values. `resize()` writes
* `_ptyCols`/`_ptyRows` only when `ptyProcess` is set, and nothing seeds them
* from the spawn geometry, so a session with a dead pane — or one created
* through the API and never started — still holds the constructor defaults
* of 120x40. Reporting those made a client adopt a size no process had ever
* been told, and on anything narrower than 120 columns it claimed another
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
* report with no finite numbers as no evidence, which is the truth here.
*/
get ptyGeometry(): { cols: number; rows: number } | null {
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
}
/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
@@ -4183,6 +4206,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;
+306 -45
View File
@@ -66,7 +66,23 @@ const _crashDiag = {
// concurrent clients (desktop + phone) don't clobber each other.
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
// Entries are joined with '\n' into ONE localStorage value and beaconed to
// the server, and some call sites interpolate text this client does not
// control (a WebSocket close `reason` arrives from the server). A newline
// in there forges extra entries in the trail; an unbounded string can fill
// 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.
// 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();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
@@ -731,6 +747,10 @@ class CodemanApp {
this._wsReady = false; // True when WS is open and ready for I/O
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
// Session whose socket dropped unintentionally, so output produced during
// the outage is missing from its buffer. Output frames carry no sequence
// number, so the only recovery is to refetch on the next successful open.
this._wsOutputGapSession = null;
// Terminal write batching with DEC 2026 sync support
this.pendingWrites = [];
@@ -2485,8 +2505,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);
}
@@ -2604,6 +2630,81 @@ class CodemanApp {
}
}
/**
* Fetch a terminal capture under a deadline.
*
* Every terminal fetch used to run with no timeout at all, including
* `?full=1`, which _maybeRefetchFullHistory itself calls "unbounded-ish work:
* at the default history limit it can be megabytes". On a stalled mobile link
* that request hangs on the browser default with no retry, and the load-state
* machinery stays armed behind it.
*
* The budget scales with what is being asked for and with how many captures
* are already running (see CodemanFetchDeadline): a full scrollback on a slow
* uplink legitimately needs longer than a tail, and eight tabs resuming must
* not all expire together because each assumed it had the link to itself.
*
* An abort surfaces as a rejected promise, which every caller already handles —
* they wrap these in try/catch and log. That is the point: a timeout becomes a
* recoverable error instead of an indefinite hang.
*
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
* settles on response HEADERS, not the body, so clearing the deadline when it
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
* for — completely unbounded. Measured against a server that sends headers
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
* deadline. Reading the body inside the helper is what makes the deadline
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
* is scoped the same way, so a body still streaming counts toward the budget
* of a capture starting beside it.
*
* Returns the PARSED envelope plus the response headers, because two callers
* read `server-timing`, and `headersAt` because those same callers measure
* header-vs-body time and can no longer observe that moment themselves.
*
* @param {string} url
* @param {{full?: boolean}} [opts]
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
*/
async _fetchTerminalCapture(url, opts = {}) {
const deadlineMs =
typeof CodemanFetchDeadline !== 'undefined'
? CodemanFetchDeadline.terminalFetchDeadlineMs({
full: !!opts.full,
inflight: this._terminalCaptureInflight || 0,
})
: 45000;
// AbortSignal.timeout() is not on every browser Codeman supports, so drive
// it from a controller and always clear the timer — an uncancelled one
// would abort a LATER request that reused this controller's signal.
//
// Degrade to a plain fetch where AbortController is missing rather than
// throwing: a capture with no deadline is the behaviour every caller had
// before this helper existed, while a ReferenceError here would take out
// terminal replay entirely. The deadline is a safety net, not a dependency.
const canAbort = typeof AbortController === 'function';
const controller = canAbort ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
try {
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
const headersAt = performance.now();
// Still inside the deadline: an abort here rejects the body stream, which
// is exactly the case a header-only timeout could not reach.
const json = await res.json();
return { json, headers: res.headers, headersAt };
} catch (err) {
if (err?.name === 'AbortError') {
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
}
throw err;
} finally {
if (timer !== null) clearTimeout(timer);
this._terminalCaptureInflight = Math.max(0, (this._terminalCaptureInflight || 1) - 1);
}
}
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
@@ -2622,17 +2723,18 @@ class CodemanApp {
// TUI modes still recover the whole picture, with the downgrade guard for
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let res = await fetch(
let capture = await this._fetchTerminalCapture(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
{ full: useFullHistory }
);
let headersReceivedAt = performance.now();
let data = (await res.json())?.data ?? {};
let headersReceivedAt = capture.headersAt;
let data = capture.json?.data ?? {};
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = performance.now();
data = (await res.json())?.data ?? {};
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
@@ -2645,8 +2747,11 @@ class CodemanApp {
// meaningless across it — distance from the bottom is what survives.
const before = this.terminal.buffer?.active;
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
this.terminal.clear();
this.terminal.reset();
// One queued clear, not clear()+reset(): both of those are synchronous
// and skip xterm's write queue, so live bytes still parsing would land
// after them and fuse into the buffer written below. See
// _resetTerminalForReplay.
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
@@ -2674,6 +2779,16 @@ class CodemanApp {
this.sendResize(this.activeSessionId);
}
}
// ⚠️ HERE: after a response arrived, and NOT in the `finally`. The marker
// means "this session lost output", and only a reconcile that actually
// completed settles it. Clearing on every exit meant one that threw — or
// hit the fetch deadline, which is the flaky-link case the marker exists
// for — dropped the gap silently with nothing to retry it.
// ⚠️ Outside the `if (data.terminalBuffer)` too: a server that answers
// with an empty capture HAS reconciled us, there was simply nothing to
// replay. Leaving the marker set there refetched on every reconnect for
// the life of the page.
this._markTerminalBufferReconciled(sessionId);
} catch (err) {
console.error('needsRefresh reload failed:', err);
} finally {
@@ -2681,6 +2796,17 @@ class CodemanApp {
}
}
/**
* Drop the "this session lost output" marker.
*
* Called from every path that repaints a session's buffer from the server, so
* the ws.onopen reconcile fires once and only when nothing else already did
* the work. See the ws.onclose note for what the marker means.
*/
_markTerminalBufferReconciled(sessionId) {
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
}
async _onSessionClearTerminal(data) {
if (data.id === this.activeSessionId) {
// Skip if selectSession is already loading the buffer — clearTerminal arriving
@@ -2691,12 +2817,16 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch(`/api/sessions/${data.id}/terminal`);
const headersReceivedAt = performance.now();
const termData = (await res.json())?.data ?? {};
// 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 ?? {};
this.terminal.clear();
this.terminal.reset();
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
// cannot do this job.
this._resetTerminalForReplay();
if (termData.terminalBuffer) {
// Strip any DEC 2026 markers and write raw content
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
@@ -3021,6 +3151,22 @@ class CodemanApp {
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
this._onWsReady(sessionId);
// Reconcile the output hole this drop left (see the ws.onclose note).
// 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) {
// 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
// running while a buffer load is in flight and against a tab switch
// landing this session's history in another session's terminal.
void this._onSessionNeedsRefresh({ id: sessionId });
}
}
};
@@ -3042,6 +3188,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
@@ -3068,6 +3218,30 @@ class CodemanApp {
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
);
// Output frames carry no sequence number, so a dropped socket leaves a
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
// SSE backpressure drain, never here.
//
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
// branch already reconciles on reconnect. The uncovered case is the WS
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
// onclose. Detecting a half-open socket takes up to the ping+pong window,
// and that whole span produces output nothing writes to the terminal.
//
// Reaching onclose at all means the drop was NOT intentional
// (_disconnectWs nulls this handler first), so mark the gap and let the
// next successful open reconcile from the server's buffer.
//
// Scoped to the session that actually lost bytes: a user who switches
// sessions during an outage gets a clean intentional disconnect for the
// new one, and its freshly-loaded buffer must not be refetched because a
// DIFFERENT session's socket dropped.
this._wsOutputGapSession = sessionId;
const stillActive = this.activeSessionId === sessionId;
if (plan.action === 'give-up') {
this._wsState = stillActive ? 'fallback' : 'disconnected';
@@ -5946,9 +6120,29 @@ class CodemanApp {
}
}
/**
* Clear the terminal for a replay, IN STREAM.
*
* xterm's `write()` is asynchronously queued (the WriteBuffer parses in ~12ms
* slices) while `Terminal.reset()` is synchronous and, by upstream's own
* documentation, "does not clear input buffers and does not reset the parser,
* thus the terminal will continue to apply pending input data". So bytes
* queued just before a `reset()` are parsed AFTER it and fuse into whatever
* snapshot is written next — measured upstream as `p8rmissions` rendered
* where `bypass permissions` belonged.
*
* A queued clear cannot race that way: it lands after the leftovers and
* before the snapshot, whatever the queue held. This function used to follow
* the sync `reset()` with a queued `\x1b[3J\x1b[H\x1b[2J`, which already got
* that right for CONTENT. RIS (`\x1bc`) additionally resets modes, charsets,
* scroll regions and SGR state, so leftover bytes cannot park the terminal in
* alt-screen or an odd scroll region and survive the clear.
*
* Callers may write the replacement content in as many chunks as they like —
* ordering within the queue is what matters, not writing it all at once.
*/
_resetTerminalForReplay() {
this.terminal.reset();
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
this.terminal.write('\x1bc');
}
_recordTerminalLoadTiming(timing) {
@@ -6012,9 +6206,9 @@ class CodemanApp {
this._fullHistoryRepullInFlight = true;
try {
const requestStartedAt = performance.now();
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
const headersReceivedAt = performance.now();
const payload = (await res.json())?.data ?? {};
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
const buffer = payload.terminalBuffer;
const timing = {
@@ -6027,7 +6221,7 @@ class CodemanApp {
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
resetAndParseMs: 0,
totalMs: 0,
serverTiming: res.headers?.get?.('server-timing') || '',
serverTiming: capture.headers?.get?.('server-timing') || '',
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
@@ -6395,11 +6589,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
@@ -6476,7 +6673,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');
@@ -6525,17 +6723,32 @@ class CodemanApp {
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
const headersReceivedAt = performance.now();
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);
return;
}
const data = (await res.json())?.data ?? {};
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
@@ -6615,7 +6828,7 @@ class CodemanApp {
cacheResetAndParseMs,
freshResetAndParseMs,
selectToReplayCompleteMs: performance.now() - _selStart,
serverTiming: res.headers?.get?.('server-timing') || '',
serverTiming: capture.headers?.get?.('server-timing') || '',
};
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
@@ -6632,6 +6845,11 @@ class CodemanApp {
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
);
}
// This load repainted the session from the server, so any pending
// output-gap marker is already satisfied. Selecting a session runs BEFORE
// _connectWs, so without this the socket opening afterwards would replay
// the whole buffer again on top of the one just written.
this._markTerminalBufferReconciled(sessionId);
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
@@ -6726,17 +6944,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;
@@ -6903,14 +7132,46 @@ 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) {
// Three short lines, none over 25 columns, because the narrowest
// terminal this app will render is the 40-column floor and a notice
// that wraps there leaves a lone '.' on a line of its own — measured at
// 320px, where a single 52-character sentence did exactly that.
// Each line is one fact: what failed, that the session is still alive,
// and what to do. The last says RELOAD rather than "reopen the tab",
// because `selectSession` early-returns when the session is already
// active, so clicking the tab you are already on retries nothing.
this.terminal.write(
'\r\n\x1b[2m History did not load.\r\n Live output continues.\r\n Reload to try again.\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);
}
}
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// A dead session has no buffer to reconcile; leaving the marker set would
// make a later socket for a REUSED id reconcile against nothing.
this._markTerminalBufferReconciled(sessionId);
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) {
+196
View File
@@ -1585,6 +1585,187 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
return result;
}
// ── Renderer liveness ──────────────────────────────────────────────────────
//
// 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:
//
// refresh() {
// if (this._animationFrame !== undefined) return; // <- stale forever
// this._animationFrame = requestAnimationFrame(() => this._innerRefresh());
// }
// _innerRefresh() { this._animationFrame = undefined; ... } // never runs
//
// So after one backgrounding the handle is permanently non-undefined and EVERY
// later render request returns on line one. Parsing is decoupled from
// rendering, so bytes keep filling the buffer correctly and nothing throws —
// the terminal is simply frozen. Closing and reopening fixes it because that
// constructs a new Terminal, and therefore a new debouncer.
//
// Codeman is MORE exposed than a per-session-terminal app: there is exactly one
// xterm instance for the whole page load, so a single backgrounding can wedge
// it until a full reload.
//
// This is the pure decision half. The signature that distinguishes this from
// every other way a terminal can look stuck is that bytes were WRITTEN and the
// element is VISIBLE, yet onRender has not fired since:
//
// frozen = wroteAt > renderedAt && now - wroteAt >= threshold && visible
//
// Deliberately NOT a "no output at all" check: a quiet terminal is the normal
// state and must never be kicked. And `visible` is required because a hidden
// terminal legitimately stops rendering (xterm pauses it), so kicking there
// would fire constantly on every backgrounded tab.
const RENDER_STALL_MS = 4000;
// How often the watchdog checks. Deliberately coarse: the failure it catches is
// permanent until healed, so detecting it a second late costs nothing, while a
// tight interval would burn a wakeup per second on every idle phone.
const RENDER_LIVENESS_POLL_MS = 2000;
/**
* Should the renderer be kicked? Pure so the CI gate can cover it — the DOM
* half (cancelling the stale handle) lives in terminal-ui.js.
*
* @param {{wroteAt:number, renderedAt:number, now:number, visible:boolean,
* thresholdMs?:number}} s
* @returns {boolean}
*/
function shouldKickRenderer(s) {
if (!s || !s.visible) return false;
const wroteAt = Number(s.wroteAt) || 0;
const renderedAt = Number(s.renderedAt) || 0;
const now = Number(s.now) || 0;
// Nothing written yet — a fresh terminal has no render to be missing.
if (wroteAt <= 0) return false;
// A render landed at or after the last write: the pipeline is alive.
if (renderedAt >= wroteAt) return false;
const threshold = Number.isFinite(s.thresholdMs) && s.thresholdMs > 0 ? s.thresholdMs : RENDER_STALL_MS;
return now - wroteAt >= threshold;
}
// ── Fetch deadlines ────────────────────────────────────────────────────────
//
// No terminal fetch carried any deadline, including `?full=1`, which the code
// itself describes as "unbounded-ish work: at the default history limit it can
// be megabytes". On a stalled mobile link that request hangs on the browser
// default with no retry and no path back to a usable terminal short of a
// reload.
//
// A single fixed timeout is wrong in both directions — too short for a full
// scrollback capture on a slow uplink, too long for a small tail on a dead
// connection. So the deadline is scaled by what is actually being asked for,
// and by how many captures are already in flight: on a slow link those bytes
// must drain before this request's own bytes start moving, and its timer is
// already running the whole time.
const FETCH_DEADLINE_TAIL_MS = 15000;
const FETCH_DEADLINE_FULL_MS = 45000;
const FETCH_DEADLINE_MAX_MS = 120000;
/**
* Deadline in ms for a terminal capture.
*
* @param {{full?:boolean, inflight?:number}} s - `full` = the ?full=1 capture;
* `inflight` = captures already running (this one included or not, it only
* scales the budget).
* @returns {number}
*/
function terminalFetchDeadlineMs(s) {
const full = !!(s && s.full);
const base = full ? FETCH_DEADLINE_FULL_MS : FETCH_DEADLINE_TAIL_MS;
const inflight = Math.max(0, Number(s && s.inflight) || 0);
// Each already-queued capture gets the newcomer one more base budget to wait
// through. Linear rather than clever: the point is only that eight tabs
// resuming do not all time out together because each assumed it was alone.
return Math.min(FETCH_DEADLINE_MAX_MS, base * (1 + inflight));
}
// ── Diagnostics hygiene ────────────────────────────────────────────────────
//
// The crash trail is joined with '\n' into ONE localStorage value and beaconed
// to the server, and at least one call site interpolates server-controlled text
// (a WebSocket close `reason`). An embedded newline there forges extra entries
// in the trail; an unbounded string can fill the storage quota. Both are cheap
// to close, and the trail is something a user may be asked to paste into an
// issue.
const DIAG_ENTRY_MAX_CHARS = 300;
/** Flatten a diagnostic message to one bounded, newline-free line. */
function sanitizeDiagEntry(msg) {
return String(msg == null ? '' : msg)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.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 WIDTH after
* such a refusal renders garbage, because Ink wraps its frame and counts its
* erase rows at the width it was told.
*
* ⚠️ COLUMNS ONLY. Rows are deliberately left alone, and adopting them was a
* real regression: a phone that took a desktop's 43 rows into a viewport with
* room for 18 painted an `.xterm-screen` far taller than its container, and
* because xterm's own viewport then had nothing to scroll, the bottom of the
* frame — the CLI's input line — sat below the container with no gesture that
* could reach it. Output visible, typing invisible, for as long as the claim
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
* decide how much is on screen at once, and keeping the local row count keeps
* the composer at the bottom of a viewport that scrolls.
*
* @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, cols: number|null}}
*/
function reconcilePtyGeometry(local, pty) {
if (!pty || !Number.isFinite(pty.cols)) return { adopt: false, cols: null };
if (!local || !Number.isFinite(local.cols) || local.cols === pty.cols) return { adopt: false, cols: null };
return { adopt: true, cols: pty.cols };
}
if (typeof window !== 'undefined') {
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
@@ -1594,4 +1775,19 @@ if (typeof window !== 'undefined') {
buildSplitPickerSessions,
SPLIT_PANE_MIN_WIDTH,
};
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
window.CodemanFetchDeadline = {
terminalFetchDeadlineMs,
FETCH_DEADLINE_TAIL_MS,
FETCH_DEADLINE_FULL_MS,
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
@@ -3112,7 +3112,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
+31
View File
@@ -3862,6 +3862,37 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* The terminal is wider than the box that shows it (issue #464). Two causes,
one affordance: another device holds the sizing claim so this terminal has
adopted a width it did not ask for, or the 40-column floor has widened it
past a narrow container. Either way the text is rendered CORRECTLY and simply
does not fit, and without horizontal reach the right-hand columns sit behind
.terminal-container's clip with no gesture that can get to them — measured at
360px, font 24: 218px of the pane, 38% of it, unreachable.
Present only while that is true; _syncTerminalOverflowAffordance() owns it. */
.terminal-container.term-overflows-x {
/* ⚠️ BOTH axes, explicitly. mobile.css loads after this file and sets
`.terminal-container { overflow: visible }` — 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;
}
/* 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.term-overflows-x .xterm {
width: max-content;
min-width: 100%;
}
/* ⚠️ NO `touch-action: pan-x` here, deliberately. The terminal's own touchmove
handler pans this container (see `canPanHorizontally` in terminal-ui.js),
because `touchstart` preventDefault()s every 'content' tap and that cancels
a native pan before it can start. Granting the browser pan-x as well would
double-handle the gestures where that preventDefault does NOT run — a tap on
a scrolled-up viewport — moving the pane twice for one finger. The
`touch-action: none` the other rules set is what keeps JS the sole owner. */
/* 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
+36 -17
View File
@@ -18,7 +18,16 @@
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
*/
const CACHE_NAME = 'codeman-v1';
// Build identity. scripts/build.mjs rewrites this declaration after it content-
// hashes the assets; the literal below is what dev serves, and dev wants a
// stable key.
//
// Why the cache key MUST carry it: `activate` deletes every cache whose key is
// not the current one, so the old constant key meant that cleanup never deleted
// anything — hashed assets from every release ever deployed accumulated in one
// bucket until the origin hit its storage quota.
const BUILD_ID = 'dev';
const CACHE_NAME = `codeman-${BUILD_ID}`;
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
@@ -27,27 +36,27 @@ const CACHE_NAME = 'codeman-v1';
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
// Content-hashed assets. scripts/build.mjs rewrites this declaration with the
// filenames it actually emitted; dev has no hashing, so the empty literal below
// is correct there and the unhashed modules are simply cached on first use by
// the runtime handler further down.
//
// This list used to be maintained by hand with the PRE-hash names, which the
// build then renamed — so in production every entry 404'd and the silent
// `.catch()` in install swallowed all of it. Measured against a running
// instance: 15 of 23 entries failed. Offline still worked, because the fetch
// handler caches every successful GET at runtime, but the precache warmed
// nothing while looking like it did. Deriving it from the same manifest that
// renames the files is the only thing that keeps the two from drifting again.
const HASHED_ASSETS = [];
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
'/styles.css',
'/mobile.css',
'/constants.js',
'/app.js',
'/api-client.js',
'/terminal-ui.js',
'/session-ui.js',
'/settings-ui.js',
'/panels-ui.js',
'/notification-manager.js',
'/mobile-handlers.js',
'/keyboard-accessory.js',
'/voice-input.js',
...HASHED_ASSETS.map((p) => '/' + p),
'/vendor/xterm.min.js',
'/vendor/xterm-addon-fit.min.js',
'/vendor/xterm-addon-unicode11.min.js',
'/vendor/xterm-zerolag-input.js',
'/vendor/xterm-predictive-echo.js',
'/vendor/xterm.css',
'/icon-192.png',
'/icon-512.png',
@@ -102,7 +111,17 @@ self.addEventListener('fetch', (event) => {
}
return response;
})
.catch(() => caches.match(request))
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
// `.js`/`.css` reference — content-hashed names included, so the page asks
// for `/app.556be563.js?v=1789423735875` while the precache stored
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
// every precached entry was unreachable and only `/`, the icons and the
// manifest could ever be served offline.
//
// It also makes runtime-cached entries survive an mtime change: the same
// file re-requested under a new `?v=` still matches the copy already held.
.catch(() => caches.match(request, { ignoreSearch: true }))
);
});
+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);
+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)
+7 -1
View File
@@ -2124,7 +2124,13 @@ 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 this (issue #464). A session with no pane
// reports nothing rather than the constructor defaults — see `ptyGeometry`.
return session.ptyGeometry ?? {};
});
// ========== Get Last Response (from transcript JSONL) ==========
+16
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,19 @@ 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.
// A session with no pane sends nothing at all: its `_ptyCols`/
// `_ptyRows` are constructor defaults no process was ever told.
const applied = session.ptyGeometry;
if (applied && socket.readyState === 1) {
socket.send(`{"t":"zc","c":${applied.cols},"r":${applied.rows}}`);
}
}
} catch {
// Ignore malformed messages