// src/web/public/terminal-tile.js /** * @fileoverview TerminalTile: one independent live terminal pane bound to one * session, with its own xterm instance and its own * `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js) * uses one as its second pane ("Pane B"); the tile grid (tile-grid.js, * docs/tile-grid-plan.md) uses one per tile, and feeds every capture they * fetch through ONE TileLoadQueue (below), because each capture is a * synchronous tmux call that blocks the server's event loop. * * Deliberately plainer than the primary pane (this.terminal/this._ws in * terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile * handlers (a swipe on a touch screen pages nothing), no keyboard accessory * bar, and no SGR wheel forwarding to Claude's fullscreen renderer * (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see * docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md. * * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): * - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the * alternate screen, Claude's repaint mode) leaves the xterm no scrollback, * so the wheel pages the CLI's own transcript with PageUp/PageDown * (_maybePageCliTranscript) through the primary pane's gates, plus an * overflow-row discount for this pane's capture-before-resize load * (_localRows), and only while the viewport is on the live screen (a * wheel-down from those overflow rows is xterm's, and brings it home). * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener), sent * ephemeral, like every mouse report from this pane (_onTerminalData). * - The soft-keyboard controller (terminal-keycode229-recovery.js), one per * pane, on this pane's own textarea and composition helper and sending to * this pane's session (_createKeyCode229Recovery): it forwards an * `insertText` xterm refused, settles a pending textarea edit at the next * keydown ahead of that key (#441: the last character an Android keyboard * commits in the same task as Enter), and replaces xterm's append-only * keyCode-229 diff with an edit-based one (#541: autocorrect on space * duplicated the line). Not a desktop-only concern: the grid and the split * are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide * Android tablet, or a large foldable unfolded in landscape, reaches. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ (function (global) { // How long a load may hold this pane's live output while it reads a bounded // body: a scroll-to-top history pull, or a refresh of a bounded window. const HISTORY_PULL_TIMEOUT_MS = 10000; // How much of a replay is queued in xterm at once: a 1 MiB load goes in one // window, and xterm's write queue throws past 50 MB, which an unbounded // `full=1` capture (up to the server's 32 MB) would otherwise come near. const REPLAY_WINDOW_BYTES = 1024 * 1024; /** * Replays a capture into a pane's own xterm: TERMINAL_CHUNK_SIZE slices, all * of a window queued at once. xterm 6 parses its write queue in 12 ms slices * and yields between them, so a long scrollback never becomes a long task, * and it is not held to one slice per animation frame either (that pacing * took about a second per 1 MiB, with the grid's load queue waiting behind * it). Queued up front, the capture also stays in one piece: live output * written during the parse lands after it, not between two of its slices. * Deliberately NOT the primary pane's chunkedTerminalWrite (terminal-ui.js): * that one is wired into session-switch generation counters and the * live-output gate this simpler, independently created/destroyed pane has no * equivalent of. * * Resolves once xterm has parsed the last slice (a write's callback runs once * everything queued before it is parsed), so _loadBuffer() below holds its * single-flight flag across the whole replay. A disposed xterm never runs its * callbacks, so `setCancel` hands the owner a function that settles the * replay at once: destroy() calls it, or the pane's flag and the grid's load * queue would wait forever. */ function writeChunked(terminal, buffer, isDestroyed, setCancel) { if (!buffer || !terminal) return Promise.resolve(); return new Promise((resolve) => { let offset = 0; let settled = false; const settle = () => { if (settled) return; settled = true; setCancel?.(null); resolve(); }; const writeWindow = () => { if (settled) return; if (isDestroyed()) { settle(); return; } const end = Math.min(buffer.length, offset + REPLAY_WINDOW_BYTES); while (offset < end) { const chunk = buffer.slice(offset, Math.min(end, offset + TERMINAL_CHUNK_SIZE)); offset += chunk.length; terminal.write(chunk); } terminal.write('', offset < buffer.length ? writeWindow : settle); }; setCancel?.(settle); writeWindow(); }); } class TerminalTile { constructor(sessionId, mountEl, opts = {}) { this.sessionId = sessionId; this.mountEl = mountEl; this.sessionMode = opts.mode; this.fontSettings = opts.fontSettings || {}; // Live reference (not a snapshot) to the app's detachedSessions Set: // detaching this session AFTER the pane opened must still be seen by // _sendResize() below, or this pane and the session's own window fight // over the PTY's size (which the split picker refuses at pick time). this.detachedSessions = opts.detachedSessions; // Lines of scrollback this pane's xterm keeps (the grid passes its smaller // TILE_SCROLLBACK) and its font size (the grid's own tile font); absent, // the primary pane's values. this.scrollback = Number.isFinite(opts.scrollback) ? opts.scrollback : null; this.fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : null; // `scheduleLoad(tile, kind, run)` runs every capture this pane fetches // (`kind`: 'initial', 'refresh' or 'history') when its owner says so, and // resolves once `run` has finished or was dropped. The grid passes its one // queue so N tiles never fetch at once; absent, a load runs straight away. this._scheduleLoad = typeof opts.scheduleLoad === 'function' ? opts.scheduleLoad : null; // Loads a BOUNDED window (`full=1&tail=` for a TUI, `tail=` for a shell) // instead of a TUI's whole history. Grid tiles do; full history is one // "leave the grid" away in the primary pane. this.boundedLoad = opts.boundedLoad === true; this.terminal = null; this.fitAddon = null; this.ws = null; this._wsReady = false; this._wsClosed = false; this._destroyed = false; // Single-flight state for _loadBuffer()/_refreshBuffer() below. this._bufferLoading = false; this._bufferRefreshPending = false; // True only while a load's work runs, not while it waits in the owner's // queue (see _runLoad): a close during the wait writes its marker at once. this._loadRunning = false; // Aborts the running load's fetch; destroy() uses it so a removed tile // does not hold the owner's queue for a whole deadline. this._loadAbort = null; // Settles a replay xterm is still parsing (writeChunked): destroy() calls // it, because a disposed xterm never runs the callback the replay awaits. this._cancelReplay = null; // Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory(). // `_liveQueue` is non-null from the pull's response until its finally // block: live frames are held there with their arrival time instead of // written under the replay. `_markerOwed` is the "disconnected" marker a // load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()). this._historyPullAt = 0; this._historyPullUseless = false; this._liveQueue = null; this._markerOwed = false; this._onWheel = null; // `{ ws, lastRecvAt }`, registered with the app's input-socket map while // this pane's socket is open, so the exactly-once input queue delivers this // session's keystrokes over it (app.js _inputSocketFor). Null otherwise. this._inputHandle = null; // Reconnect state. `_socketUrl` is set once connect() opens the first // socket: a pane that never connected has nothing to reconnect to. // `_reconnectAttempts` counts consecutive failed opens and is reset ONLY by // a successful open (resetting it per attempt is the tight-loop bug the // primary pane's _disconnectWs documents). `_stoppedCode` is the close code // that ended the pane for good; `onExit(code)` tells the owner once. this._socketUrl = null; this._reconnectAttempts = 0; this._reconnectTimer = null; this._stoppedCode = null; this._markerText = TerminalTile.MARKER_RECONNECTING; this.onExit = typeof opts.onExit === 'function' ? opts.onExit : null; // The `{ cols, rows }` last sent in a `{t:'z'}` frame, so an unchanged size // is not resent (each one costs a `tmux resize-window` and a SIGWINCH). // Cleared on every open: a fresh socket must announce its size, which is // also what registers it as a desktop viewer server-side. this._lastSentDims = null; // Whether the pointer is over a link in THIS pane (the primary pane's own // flag, app._linkHovered, belongs to its terminal alone). this._linkHovered = false; this._onFocusIn = null; // Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a // whole page, carried to the next wheel event. this._pageKeyPending = 0; // Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes). this._scrollBytes = ''; this._scrollFlushTimer = null; // Rows above the screen that this pane pushed there itself rather than // received as history: a capture taken at the PTY's previous, taller size // and row-shrinking fits (_overflowAfterLoad, _noteResizeRows). Not // history, so the paging gate leaves them out (_localRows). this._overflowRows = 0; // The desktop click reporter (_installClickListener). this._onClick = null; // The soft-keyboard controller (terminal-keycode229-recovery.js): created // in connect() once the xterm is open, torn down in destroy(). this._keyCode229Recovery = null; } async connect() { const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10); this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily), ...global.CodemanTerminalFont.resolveWeights(this.fontSettings), fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14, lineHeight: 1.2, cursorBlink: false, cursorStyle: 'block', minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1, scrollback: this.scrollback ?? DEFAULT_SCROLLBACK, allowTransparency: true, allowProposedApi: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.open(this.mountEl); this.fitAddon.fit(); // File paths and URLs printed here are clickable, through the SAME // provider as the primary pane (registerFilePathLinkProvider, // terminal-ui.js), and open against THIS pane's session. global.app?.registerFilePathLinkProvider?.({ terminal: this.terminal, getSessionId: () => this.sessionId, setHovered: (hovered) => { this._linkHovered = hovered; }, }); this._installWheelListener(); this._installClickListener(); // Focusing this terminal makes it the pane the keyboard is in, so the // app-level shortcuts, voice and paste act on it (app._focusedPane). this._onFocusIn = () => global.app?._noteFocusedTile?.(this); this.terminal.textarea?.addEventListener('focus', this._onFocusIn); this._createKeyCode229Recovery(); // The twin of terminal-ui.js's onData gate (initTerminal; keep the two in // step). Canonical xterm data tells the controller this keystroke was // delivered, but not a query reply or a focus/mouse report, which xterm // emits on its own and which would otherwise stand a pending recovery // down. The notify lives HERE and not in _onTerminalData(): the // controller's own recovered bytes go through _onTerminalData() too, and // must never count as xterm's, or a second pending character from the // same keystroke window would stand down and be lost. this.terminal.onData((data) => { try { const input = global.CodemanTerminalInput; if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) { this._keyCode229Recovery?.notifyCanonicalData?.(); } } catch { /* Bookkeeping must never block real input. */ } this._onTerminalData(data); }); // xterm has no gates of its own, so every app-level chord that the // document capture-phase handler (app.js) only preventDefault()s (never // stopPropagation()s) would reach this xterm too and write its raw byte // or escape sequence into THIS session's PTY on top of whatever the app // action did (COD-153). These are the primary pane's gates // (terminal-ui.js attachCustomKeyEventHandler): command palette, // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, the tile grid's chords, // Ctrl+Z suspend, Shift/Ctrl+Enter newline, and smart-copy // Ctrl+C/Ctrl+Shift+C. Routed through the same registry-aware // predicates so a rebind or a disable restores plain terminal behavior // here too. Ctrl+V goes through the primary pane's paste trap // (image-input.js), aimed at this pane (below). this.terminal.attachCustomKeyEventHandler((ev) => { // FIRST, above the IME early return below, as in terminal-ui.js: every // keydown settles this pane's pending textarea edit and drains a // pending recovery BEFORE xterm handles the key, so a character an // Android keyboard committed in the same task as Enter is sent ahead // of the \r. Below that return a keyCode-229 keydown would skip the // settle, the drain and the snapshot, and the panes would differ. // Read at call time, never captured, so the controller can be swapped // (the tests count xterm's emissions through it). try { this._keyCode229Recovery?.handleKeyEvent?.(ev); } catch { /* The controller must never interfere with xterm's own handling. */ } if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '') ) { return false; } if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) { return false; } if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { return false; } // Tile grid chords (focus, toggle): acted on by the capture handler, so // they must never reach this tile's PTY. Every event type, and before // the Shift+Enter branch below. if (global.app?.tileShortcutFor?.(ev)) return false; // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so // a pasted image uploads to this pane's session and its path is typed // here, and pasted text goes into this xterm with its bracketed-paste // markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it // xterm's default only ever pasted text. if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') { global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId }); return false; } // Ctrl+Z (SIGTSTP/job-control suspend), as terminal-ui.js swallows it: // in a plain shell session this is the user's own job-control tool and // must reach the PTY, but in every other mode (claude/omp/pi/codex/...) // it silently stops an unattended agent loop dead. This pane has its // own session and applies the same rule to it. if ( ev.type === 'keydown' && ev.key.toLowerCase() === 'z' && ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && this.sessionMode !== 'shell' ) { return false; } // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting, as // terminal-ui.js does. xterm sends plain \r for every Enter variant, // so an Ink app (Claude Code) can't tell a newline from a submit, and // without this gate this pane's onData would send that bare \r and // submit an incomplete prompt instead of adding a line to it. Targets // THIS pane's own session (this.sessionId), never the primary pane's // activeSessionId, and has no local-echo overlay of its own to flush // first (this pane is deliberately plainer, see the fileoverview). // Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends. if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) { if (ev.type === 'keydown') { fetch(`/api/sessions/${this.sessionId}/send-key`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), }).catch(() => { /* Best-effort, matching this pane's tolerance elsewhere. */ }); } return false; } // Smart copy, the primary pane's rule (terminal-ui.js's Ctrl+C gate, // #211) through the SAME helpers, aimed at THIS pane: the gutter width // comes from this session's run mode, the partial first line from this // terminal's selection, and the clear and refocus after the copy land // here. With a selection worth copying, Ctrl+C copies instead of // sending ^C; with none, plain Ctrl+C falls through unchanged or the // interrupt key is lost. Ctrl+Shift+C is the explicit copy chord and // never falls through (ev.shiftKey, below): with nothing to copy it // would otherwise reach the browser's own binding for that chord. // As in the primary gate, the CLEANED selection decides and the copy is // handed the RAW one, because the margin strip is not idempotent. if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) { const app = global.app; const target = { terminal: this.terminal, sessionId: this.sessionId }; const raw = this.terminal?.getSelection?.() || ''; if (app.cleanedTerminalSelection?.(raw, target)?.trim()) { ev.preventDefault(); void app.copyTerminalSelection(raw, target); return false; } // Nothing worth copying: cleared for feedback, and the press still // reaches the PTY as 0x03, as in the primary pane. if (this.terminal?.hasSelection?.()) { this.terminal.clearSelection?.(); app.showToast?.('Nothing to copy', 'warning'); } if (ev.shiftKey) { ev.preventDefault(); return false; } } return true; }); // Load existing scrollback before going live. The WS below is // subscribe-only (ws-routes.ts sends nothing on connect, only future // 'terminal' events), so without this the pane stays blank until the // session happens to produce new output. The resize _sendResize() sends // on open is no substitute: tmux repaints on a resize, but // Session.resize() (session.ts) skips one that matches the session's // last size, and then nothing repaints at all. The await covers the // whole chunked replay, not just the fetch, so a live frame from the // socket below can never land in the middle of it. await this._loadBuffer(); if (this._destroyed) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; // The tab's own connection identity plus a `:tile` suffix. The server // supersedes a socket that reuses a cid on the same session (4010), so a // pane must never share the primary pane's exact cid: were both ever on // one session they would evict each other in a loop. Input frames still // carry the BARE clientId, which is what the server dedups on. const app = global.app; const cid = app?._clientId ? `${app._clientId}:${app._wsTabNonce}:tile` : ''; const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : ''; this._socketUrl = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal${cidQuery}`; this._openSocket(); } // Opens a socket and makes it THE socket. A previous one is detached first // (handlers nulled, then closed), and every handler below checks it still // belongs to the current socket: a replacement opened while the old socket // still looked alive (a half-open connection whose close has not landed) // makes the server supersede the old one with a 4010, and that late close // must not stop a pane that is already running on its successor. _openSocket() { if (this._destroyed || !this._socketUrl) return; this._detachSocket(); const ws = new WebSocket(this._socketUrl); this.ws = ws; ws.onopen = () => { if (ws !== this.ws) return; this._onSocketOpen(); }; ws.onmessage = (event) => { if (ws !== this.ws) return; if (this._inputHandle) this._inputHandle.lastRecvAt = Date.now(); try { const msg = JSON.parse(event.data); if (msg.t === 'o') { this._onLiveOutput(msg.d); } else if (msg.t === 'c') { this._onLiveClear(); } else if (msg.t === 'r') { // Server-triggered refresh (SSE backpressure cleared, terminal // data was dropped). The primary pane routes this to // _onSessionNeedsRefresh (app.js); this pane has its own buffer // loader for the same reason connect() does. this._refreshBuffer(); } else if (msg.t === 'ia') { // Input ACK. The frame names no session, so it is this pane's. global.app?._onWsInputAck?.(msg.seq, msg, this.sessionId); } else if (msg.t === 'zc') { this._onPtyGeometryReport(msg.c, msg.r); } } catch { /* Malformed frame: ignored, as in the primary pane. */ } }; // _wsReady must go false on a drop or fit()/_sendResize() silently // no-op on a closed socket per the WebSocket spec (no exception, no log). // Input is not lost meanwhile: it waits in the app's durable queue and // goes out over HTTP or the next socket. The "disconnected" marker says // so on screen, and a transient close reconnects (_onSocketClosed). ws.onclose = (event) => { if (ws !== this.ws) return; this._onSocketClosed(event); }; ws.onerror = () => { // onclose fires after onerror: cleanup happens there. }; } // Lets go of the current socket without running its close handling. _detachSocket() { const ws = this.ws; if (!ws) return; ws.onopen = null; ws.onmessage = null; // onclose fires asynchronously AFTER close(); without this it would run // its "disconnected" write against a pane already torn down or replaced. ws.onclose = null; ws.onerror = null; try { ws.close(); } catch { /* Already closed. */ } this.ws = null; this._wsReady = false; this._unregisterInputSocket(); } // A socket came up. After a drop this is a reconnect: the gap left nothing // to replay (output frames carry no sequence number), so the buffer is // refreshed. The closed state is reset FIRST, or the refresh would re-owe // the "disconnected" marker (_refreshBuffer does on a closed socket) and // stamp it under a healthy pane. _onSocketOpen() { const reconnected = this._wsClosed; this._wsReady = true; this._wsClosed = false; this._markerOwed = false; this._reconnectAttempts = 0; this._lastSentDims = null; this._registerInputSocket(); this._sendResize(); if (reconnected) this._refreshBuffer(); } // Opens a replacement socket now instead of waiting out the backoff (for an // owner that just learned the server is back). No-op while the current // socket is open, after a permanent stop, or once destroyed. reconnectNow() { if (this._destroyed || this._stoppedCode !== null || !this._socketUrl) return; if (this.ws && this.ws.readyState === WebSocket.OPEN) return; clearTimeout(this._reconnectTimer); this._reconnectTimer = null; this._openSocket(); } // The socket's close, split out of connect() so the tests can drive it. // While any load runs (a history pull or a `{t:'r'}` refresh) the marker is // only owed, and that load's finally block settles it (_stampMarkerIfOwed()): // written now, it would sit above the output a pull is still holding (flushed // after it on a skip, a downgrade or a failed fetch), above a refresh's // replay, or in the middle of a chunked replay. A pull still waiting for its // response holds the marker too, for as long as the request takes (up to its // budget, see _pullHistory()). // // Then decides what comes next. Codes that cannot get better stop the pane // for good and report once through `onExit(code)`: 4004/4009 (the session // is gone), 4003 (refused: Host/Origin/owner, a retry gets the same answer) // and 4010 (another socket with this pane's cid took over; only ever // reaches here for the CURRENT socket, see _openSocket). Everything else, // including the redelivery sweep force-closing a silent socket (1005), is // transient and reconnects on the primary pane's backoff ladder // (CodemanWsReconnect, constants.js) plus jitter. _onSocketClosed(event) { this._wsReady = false; this._wsClosed = true; this._unregisterInputSocket(); const code = event?.code; const permanent = TerminalTile.STOP_MARKERS[code]; this._markerText = permanent || TerminalTile.MARKER_RECONNECTING; if (this._loadRunning) this._markerOwed = true; else this._writeDisconnectedMarker(); if (this._destroyed) return; if (permanent) { this._stop(code); return; } this._scheduleReconnect(code); } _scheduleReconnect(code) { if (this._destroyed || !this._socketUrl || this._reconnectTimer) return; const plan = global.CodemanWsReconnect?.plan?.(code ?? 1006, this._reconnectAttempts) || { action: 'reconnect', delayMs: 1000, }; if (plan.action === 'give-up') { this._stop(code); return; } this._reconnectAttempts++; const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter: tiles must not reconnect in lockstep this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; this._openSocket(); }, delay); } _stop(code) { if (this._stoppedCode !== null) return; this._stoppedCode = code ?? null; clearTimeout(this._reconnectTimer); this._reconnectTimer = null; if (!this._destroyed) this.onExit?.(code); } // Keystrokes and pastes go through the app's exactly-once input queue (seq, // ACK, persisted until delivered, redelivered after a drop), over this // pane's own socket while it is open and the HTTP fallback while it is not. // What xterm GENERATES must not be queued: a query reply (DA/CPR/OSC) is // dropped, as the primary pane drops it, because forwarding it types // "0;276;0c" into the CLI, and replaying one after a reload would do so // again into a later screen. A focus or mouse report is real input the // program asked for, but nobody typed it: it goes out once, never // persisted. Same predicates as the primary pane (terminal-ui.js onData). _onTerminalData(data) { const input = global.CodemanTerminalInput; if (input?.shouldSuppressTerminalQueryResponse?.(data)) return; const app = global.app; if (input?.isTerminalFocusOrMouseReport?.(data)) { app?._sendInputEphemeral?.(this.sessionId, data); return; } app?._sendInputAsync?.(this.sessionId, data); } // The soft-keyboard controller, the twin of the primary pane's wiring in // terminal-ui.js initTerminal() (keep the two in step); the behaviour lives // once, in terminal-keycode229-recovery.js. Everything it is handed is THIS // pane's: its textarea, its xterm's CompositionHelper (whose // `_handleAnyTextareaChanges` it patches, per instance) and its send path. // Recovered text goes straight to _onTerminalData(), never through xterm's // onData, so it is not counted as xterm's own (see connect()'s onData). // Created after terminal.open(): xterm's capture `input` listener on the // textarea is registered there, and must run before the controller's. No // device or mode gate, as in the primary pane: with a hardware keyboard it // costs one assignment per keydown. A failure leaves xterm's own handling. _createKeyCode229Recovery() { this._destroyKeyCode229Recovery(); if (!this.terminal) return; try { this._keyCode229Recovery = global.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, emitRecovered: (data) => this._onTerminalData(data), getCompositionHelper: () => this.terminal?._core?._compositionHelper, isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, }) ?? null; } catch { this._keyCode229Recovery = null; } } // Restores xterm's own textarea diff and removes the controller's capture // listeners from the live textarea, so it runs before terminal.dispose(). _destroyKeyCode229Recovery() { try { this._keyCode229Recovery?.destroy?.(); } catch { /* Optional; teardown must continue. */ } this._keyCode229Recovery = null; } // Joins the app's input-socket map for this session and flushes anything // already queued for it (typed while the socket was down, or left over from // a reload) over the fresh socket. Called from onopen. _registerInputSocket() { const app = global.app; if (!this.ws || !app?._registerInputSocket) return; this._unregisterInputSocket(); this._inputHandle = { ws: this.ws, lastRecvAt: 0 }; app._registerInputSocket(this.sessionId, this._inputHandle); app._onWsReady?.(this.sessionId); // The header's connection dot reads tile sockets while the grid is open. app._updateConnectionIndicator?.(); } // Leaves the map; only this pane's own handle is removed (a replacement // socket's registration survives a late close of the old one). _unregisterInputSocket() { if (!this._inputHandle) return; global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle); this._inputHandle = null; global.app?._updateConnectionIndicator?.(); } // Settles a marker the pane owes: set when a close lands during a load (the // replay would otherwise sit below it) or when a load wipes the terminal on // a closed socket. Called from each load's own finally, just before // _endBufferLoad() starts any trailing refresh. _stampMarkerIfOwed() { // A trailing refresh is about to run, and it settles the marker itself: // a replay's queued `\x1bc` would wipe one written here (it re-owes the // marker on a closed socket and stamps it below the replay), and a refresh // that writes nothing stamps the one still owed. Stamped here as well, // there would be two marker writes for one close. if (this._bufferRefreshPending && !this._destroyed) return; const owed = this._markerOwed; this._markerOwed = false; if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker(); } // Extracted so both _onSocketClosed() and a load that ends owing it on a // closed socket can write it (see _stampMarkerIfOwed()). _writeDisconnectedMarker() { this.terminal?.write(`\r\n\x1b[2m${this._markerText}\x1b[0m\r\n`); } // Fetches and writes the session's current scrollback. Used both by // connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`) // and a reconnect (_refreshBuffer). The primary pane's own // _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and // rewrites the primary terminal, neither of which applies to this // independent pane, so this is a standalone equivalent rather than a call // into it, in the primary's order (below). // // Mirrors the primary pane's own mode check (app.js's selectSession / // _onSessionNeedsRefresh): a shell session can retain hundreds of // thousands of plain scrollback lines, so pulling `?full=1` there parses // an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body // into this xterm on every load; a shell loads the `tail=` window. A // non-shell (TUI) session gets one full replay, or the same bounded window // with `boundedLoad` (grid tiles). `fetch` here goes through the global // wrapper (constants.js), which already prefixes CodemanBase, unlike the // raw WebSocket URL above, which does not. // // A refresh replaces what the pane shows, in the primary pane's order // (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the // pane keeps its last frame through the round trip (and through a grid // tile's wait in the load queue); then the queued in-stream `\x1bc`, never // xterm's clear(): clear() is synchronous while write() is parsed on a later // tick, so live bytes still queued would land after it and fuse into the // snapshot, and it keeps the cursor's row, column, SGR and margins, so the // capture (raw rows, no home) started wherever the cursor sat. Live frames // from the response onward are held (`_liveQueue`, the primary's // _finishBufferLoad `since` rule, as _pullHistory() holds them) and only // those that arrived after it are written behind the replay. A failed, // aborted or empty fetch writes nothing and resets nothing: the pane keeps // its last frame and every held frame. The initial load needs none of // this: it runs before the pane has a socket, onto a fresh xterm. // // Single-flight: the flag is held across the fetch AND the chunked write // (writeChunked resolves after its last chunk), so two replays can never // interleave their chunks into one terminal. A second call while one is // in flight is dropped here; _refreshBuffer() is the caller that queues // a trailing re-run instead. async _loadBuffer({ refresh = false } = {}) { if (this._bufferLoading) return; this._bufferLoading = true; await this._runLoad(refresh ? 'refresh' : 'initial', async () => { this._loadRunning = true; let replayed = false; let capturedAt = 0; // A deadline covering the body as well as the headers (the primary // pane's budgets, CodemanFetchDeadline): a capture that never answers // would otherwise hold this pane's single-flight flag, and in the grid // the one load queue every tile waits behind, forever. Re-armed once a // refresh's headers land (below), so one signal carries both budgets. const controller = global.AbortController ? new global.AbortController() : null; let abortTimer = null; const armDeadline = (ms) => { if (!controller) return; clearTimeout(abortTimer); abortTimer = setTimeout(() => controller.abort(), ms); }; try { if (this._destroyed) return; const shell = this.sessionMode === 'shell'; let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; this._loadAbort = controller; armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000); let payload; try { const res = await fetch( `/api/sessions/${this.sessionId}/terminal?${query}`, controller ? { signal: controller.signal } : undefined ); if (refresh) { // The response's arrival stands in for the instant tmux took the // capture (see _pullHistory()). Frames from here on are news the // capture cannot hold, so they wait for the replay. From now on // live output IS held, so a bounded window's body (at most // TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded // capture (the split's Pane B, up to 32 MB) keeps the request's. capturedAt = performance.now(); this._liveQueue = []; if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS); } payload = (await res.json())?.data ?? {}; } finally { clearTimeout(abortTimer); this._loadAbort = null; } if (payload.terminalBuffer && this.terminal && !this._destroyed) { if (refresh) { this.terminal.write('\x1bc'); this._overflowRows = 0; // the reset leaves nothing above the screen replayed = true; // The reset wipes a "disconnected" marker (a `{t:'r'}` frame can // queue a trailing refresh behind a pull that the socket's close // then interrupts), so a refresh on a closed socket owes it back // once its replay is written. if (this._wsClosed) this._markerOwed = true; } await writeChunked( this.terminal, payload.terminalBuffer, () => this._destroyed, (cancel) => (this._cancelReplay = cancel) ); if (!this._destroyed && this.terminal) this._overflowRows = this._overflowAfterLoad(payload); } } catch { /* Best-effort: live output still arrives once the socket connects. */ } finally { clearTimeout(abortTimer); this._loadAbort = null; this._loadRunning = false; // Held frames before the marker, so the marker stays the last thing on // screen (see _pullHistory()). this._flushLiveQueue(replayed ? capturedAt : 0); this._stampMarkerIfOwed(); this._endBufferLoad(); } }); } // Runs a load's work now, or when the owner's queue gives this pane its turn // (`scheduleLoad`). The single-flight flag is already set by the caller, so a // load waiting in the queue still coalesces refreshes and blocks a second // pull; the work itself sets `_loadRunning`. A load the queue drops (this // pane was destroyed while it waited) never runs, so its flags are released // here. async _runLoad(kind, work) { let ran = false; const run = () => { ran = true; return work(); }; if (!this._scheduleLoad) { await run(); return; } try { await this._scheduleLoad(this, kind, run); } catch { /* The queue never rejects; a load that failed already settled itself. */ } if (!ran) { this._bufferLoading = false; this._bufferRefreshPending = false; } } // Ends a single-flight load (initial, refresh or history pull): clears the // flag, then runs the ONE trailing refresh that arrived while it was busy. _endBufferLoad() { this._bufferLoading = false; if (this._bufferRefreshPending && !this._destroyed) { this._bufferRefreshPending = false; this._refreshBuffer(); } } // Live terminal output. Written straight through, except while a refresh or // a history pull is replaying: a capture is current only up to the instant // tmux took it, so a frame arriving mid-replay is held with its arrival time // and written behind the snapshot by that load's _flushLiveQueue() (the // primary pane's _finishBufferLoad `since` rule), never underneath it. _onLiveOutput(data) { if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); else this.terminal?.write(data); } // Releases the frames a load held (_liveQueue) and closes the queue. After a // replay only those that arrived after the capture are news (`cutoff`, the // response's arrival; earlier ones are already in it); with no replay // (`cutoff` 0) every one is. _flushLiveQueue(cutoff) { const queued = this._liveQueue ?? []; this._liveQueue = null; for (const entry of queued) { if (entry.at < cutoff) continue; this.terminal?.write(entry.data); } } // The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one // emitter (Session.startInteractive, session.ts) sends it once a fresh Claude // pane first shows its prompt: the server has just trimmed its own buffer and // means "refresh after startup". The primary pane refetches the capture and // replays it (_onSessionClearTerminal, app.js), and while the grid is open // that handler stands aside for the tiles. A bare xterm clear() here kept // only the cursor's row and dropped the banner and every row above it, and an // idle Claude never repaints static rows, so a Claude session Run into the // grid (or Attached in a tile) sat there as a near-empty tile. So it takes // the `{t:'r'}` route: single-flight, coalesced into one trailing refresh // behind a load already running (a pull's held frames included), and paced // by the grid's load queue. _onLiveClear() { this._refreshBuffer(); } // Capture phase, because xterm's own wheel handler stopPropagation()s every // event it consumes, so a bubbling listener here would never see the wheel // while the pane still has scrollback to scroll. Not passive: the one route // this pane takes over, paging a hollow buffer's CLI transcript // (_maybePageCliTranscript), is consumed right here (preventDefault plus // stopPropagation in the capture phase, the primary pane's technique), so // xterm's viewport, a descendant, never sees it. Every other wheel is left // to xterm, which keeps doing the scrolling, and only observed for the // shell history pull. _installWheelListener() { this._onWheel = (ev) => { if (this._maybePageCliTranscript(ev)) { ev.preventDefault(); ev.stopPropagation(); return; } if (ev.deltaY < 0) this._maybeLoadMoreHistory(); }; this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false }); } // A plain left-click reported to the CLI, the primary pane's desktop click // (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The // server strips the mouse DECSETs of some modes (opencode's since #555, so a // drag selects text), which leaves this xterm's own mouse encoder idle for // them; without this a click in such a pane never reached the CLI. Only // while this pane's session has tracking on (cliMouseTracking), through the // same skips as the primary pane. Bubble phase, as there. The target is // built per click, so the terminal and the link hover are read live. _installClickListener() { this._onClick = (ev) => { if (this._destroyed || !this.terminal) return; global.app?._handleDesktopTerminalClick?.(ev, { terminal: this.terminal, sessionId: this.sessionId, linkHovered: this._linkHovered, // Like every mouse report from this pane (_onTerminalData): once, // never persisted, so a reload cannot replay it onto a later screen. ephemeral: true, }); }; this.mountEl.addEventListener('click', this._onClick); } // Hollow-buffer paging, the twin of the primary pane's // _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that // draws in place (opencode, on the alternate screen; Claude's repaint mode) // leaves this xterm no scrollback, so a wheel scrolled nothing; instead the // travel pages the CLI's own transcript with PageUp/PageDown. Every gate is // the primary pane's own, asked for THIS pane (its terminal, its session, // never the active one), so the CLI rules stay in terminal-ui.js and this // file names no CLI. Returns true when the wheel was consumed here. _maybePageCliTranscript(ev) { if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false; const app = global.app; const input = global.CodemanTerminalInput; if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false; // xterm's own encoder forwards the wheel while the CLI's tracking reaches // it (a shell running htop), as in the primary pane. const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; const target = { terminal: this.terminal, sessionId: this.sessionId }; // The primary pane would forward this wheel to Claude's fullscreen // renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md // follow-up 4), so the wheel stays with xterm, as before. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; // Only from the live screen. The one gate the primary pane never needs: a // primary hollow buffer has baseY 0, so its viewport is always at the // bottom, while a tile's is hollow with its own overflow rows still above // the screen, and Shift+PageUp, a scrollbar drag or a wheel during the // first replay can leave the viewport up there. Paging from there would // swallow every wheel (wheel-down included) and keep the stale rows on // screen while the CLI pages out of view; left to xterm, a wheel-down // brings the viewport home and paging resumes from there. The click // report refuses an off-bottom viewport for the same reason // (_terminalViewportAtBottom). if (!app._terminalViewportAtBottom?.(this.terminal)) return false; const lines = input.wheelDeltaLines(ev, this.terminal.rows); if (!lines) return false; const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows); this._pageKeyPending = step.pending; if (step.keys) this._queueScrollBytes(step.keys); return true; } // Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a // fling cannot build a backlog that keeps paging after it stops. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active // session only. Sent ephemeral (no seq, never persisted) to THIS pane's // session, over this pane's socket while it is open. _queueScrollBytes(data) { if (!data || this._destroyed) return; if (this._scrollBytes.length > 512) return; this._scrollBytes += data; if (this._scrollFlushTimer) return; this._scrollFlushTimer = setTimeout(() => { this._scrollFlushTimer = null; const bytes = this._scrollBytes; this._scrollBytes = ''; if (bytes && !this._destroyed) global.app?._sendInputEphemeral?.(this.sessionId, bytes); }, 40); } // History rows in this xterm, for the paging gate: baseY less the rows this // pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in // the stream drops rows behind this count's back. _localRows() { const baseY = this.terminal?.buffer?.active?.baseY || 0; this._overflowRows = Math.min(this._overflowRows, baseY); return baseY - this._overflowRows; } // After a local resize: rows a shrinking fit pushed above the screen count // as overflow, and rows a growing one pulled back come off it. `before` is // baseY just before the resize (xterm resizes synchronously). _noteResizeRows(before) { const after = this.terminal?.buffer?.active?.baseY || 0; this._overflowRows = Math.max(0, Math.min(after, this._overflowRows + (after - before))); } // The overflow a finished load leaves: everything above the screen when the // capture held a single screen (the server says how tall in `captureRows`; // the full-history path keeps every pane row and trims one newline), none // when it carried history. The counterpart of the primary pane resizing the // PTY before it captures (app.js selectSession's sendResize), which this // pane does not do: its first capture is taken at the PTY's previous size // (usually the primary pane's, taller) and written into a shorter xterm, // whose extra rows land above the screen with nothing after them to clear // them. Without a `captureRows` the raw baseY stands, as in the primary. _overflowAfterLoad(payload) { const captureRows = payload?.captureRows; if (!Number.isFinite(captureRows)) return 0; const text = payload.terminalBuffer || ''; let lines = 1; for (let i = text.indexOf('\n'); i !== -1 && lines <= captureRows; i = text.indexOf('\n', i + 1)) lines++; if (lines > captureRows) return 0; return this.terminal?.buffer?.active?.baseY || 0; } // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of // output (`cat` of a file longer than the screen) instead of scrolling it, // so this pane's xterm ends up with about one screen of scrollback while // tmux holds every line, and without this pull the history is // unreachable. The primary pane has the same pull // (app.js _maybeRefetchFullHistory); a tile is a separate xterm and needs // its own. Shell only: a non-shell CLI's history is out of scope for this // pull (its load already takes `full=1`; codex and Claude's inline renderer // do grow tmux history, this just isn't how they recover it). The // alternate-screen skip (nano, vim, less) only matters for a direct-PTY // shell: under tmux the browser xterm never enters the alternate buffer. _maybeLoadMoreHistory() { if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return; if (this._bufferLoading) return; // Mirrors app.js _maybeRefetchFullHistory and this pane's own // _sendResize(): a detached session's own window already owns its PTY // size and scrollback, so this pane has nothing of its own to reconcile. if (this.detachedSessions?.has(this.sessionId)) return; const active = this.terminal.buffer.active; if (active.type !== 'normal' || active.viewportY !== 0) return; // Momentum scrolling fires this dozens of times per flick, so cooldown // rather than latch; a pull that could only have downgraded the pane // waits far longer. const cooldown = this._historyPullUseless ? 60000 : 4000; const now = Date.now(); if (now - this._historyPullAt < cooldown) return; this._historyPullAt = now; this._bufferLoading = true; void this._runLoad('history', () => this._pullHistory()); } // Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE // a tab switch loads, so a multi-megabyte capture never lands on xterm's // main thread) and replays it under the reader's current place. Holds the // single-flight flag across the fetch AND the replay, like _loadBuffer(). async _pullHistory() { this._bufferLoading = true; if (this._destroyed) { this._endBufferLoad(); return; } this._loadRunning = true; let replayed = false; let capturedAt = 0; // Two budgets on one signal. The request itself gets the primary pane's // (CodemanFetchDeadline, constants.js): live output is not held while it // runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and // the marker owed by a close (_onSocketClosed()) both wait for it, at worst // for that whole budget. Once the headers land live output IS held, so the // body read gets the short one instead: a body that hangs would otherwise // freeze the pane for the long budget. Aborting lands in the catch below, // which releases the flag and the queue. AbortSignal.timeout() alone cannot // be re-armed, hence the controller; without AbortController the pull // simply has no deadline. const controller = global.AbortController ? new global.AbortController() : null; this._loadAbort = controller; let abortTimer = null; const armDeadline = (ms) => { if (!controller) return; clearTimeout(abortTimer); abortTimer = setTimeout(() => controller.abort(), ms); }; try { armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS); const res = await fetch( `/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`, { signal: controller?.signal } ); armDeadline(HISTORY_PULL_TIMEOUT_MS); // The cutoff below is the response's arrival, the same `since` rule the // primary pane uses (_finishBufferLoad). It is a client clock standing in // for the instant tmux took the capture, which lies somewhere in the // round trip, so a frame in that window can be lost or doubled. Bounded // by one round trip and not closable without a server-side capture time. capturedAt = performance.now(); // Opened only now: a frame from before the response is either replaced by // the capture or written unchanged, so holding it for the round trip // would buy nothing and freeze the pane for as long as the fetch took. this._liveQueue = []; const payload = (await res.json())?.data; clearTimeout(abortTimer); const buffer = payload?.terminalBuffer; const term = this.terminal; if (!buffer || !term || this._destroyed) return; const rowsBefore = term.buffer.active.length; const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length; // xterm keeps at most `scrollback + rows` rows while tmux keeps far more // lines, so a window of short lines can carry more rows than this pane // can ever hold, and `rowsIncoming <= rowsBefore` would never come true. const scrollbackCap = term.options?.scrollback || 0; const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows; // Nothing to gain (this also covers a downgrade, which would delete // history mid-scroll), and a reset+rewrite would jump the viewport. An // untruncated window IS all of tmux's history and the next burst can add // more, so keep the 4 s cooldown. A truncated window can never reach past // what the pane shows, and every ask costs the server a capture-pane of // the whole history (`tail` is cut after it): back off to 60 s, as the // primary pane does (app.js _maybeRefetchFullHistory). A full pane backs // off too, since no window can ever fit in it. if (rowsIncoming <= rowsBefore || paneFull) { if (payload.truncated || paneFull) this._historyPullUseless = true; return; } this._historyPullUseless = false; term.write('\x1bc'); this._overflowRows = 0; // the reset leaves nothing above the screen replayed = true; if (this._wsClosed) this._markerOwed = true; await writeChunked( term, buffer, () => this._destroyed, (cancel) => (this._cancelReplay = cancel) ); if (this._destroyed || !this.terminal) return; // xterm parses asynchronously: an empty write's callback fires only // after everything before it, so the row count below is the settled one. await new Promise((resolve) => this.terminal.write('', resolve)); if (this._destroyed || !this.terminal) return; // The replay grew the buffer UPWARD, so what was row 0 is now `delta` // rows down; land there and the recovered history sits above it. const delta = this.terminal.buffer.active.length - rowsBefore; if (delta > 0) this.terminal.scrollToLine(delta); else this.terminal.scrollToTop(); } catch { /* Best-effort: live output keeps arriving whatever happens here. */ } finally { clearTimeout(abortTimer); this._loadAbort = null; this._loadRunning = false; // After a replay, only frames that arrived after the capture are news; // earlier ones are already in it. With no replay, every held frame is. this._flushLiveQueue(replayed ? capturedAt : 0); // Settled after the queue flush so the marker is the last thing on // screen: a close during the pull wrote nothing (_onSocketClosed() defers // it while a load runs), and a replay's own `\x1bc` (flagged above) wipes // one written before it, which would paint a fresh, current-looking // history while onData keeps silently dropping every keystroke on the // dead socket. With a trailing refresh pending (_endBufferLoad) the marker // is left to that refresh, which writes it below its own replay. this._stampMarkerIfOwed(); this._endBufferLoad(); } } // A bounded load's `lines=` (grid tiles): tmux history beyond what this // xterm keeps (its scrollback plus the screen) would only be captured to be // thrown away, and a full capture is synchronous work on the server, about // 0.7 s for a 30k-line history. Unbounded panes (the split's Pane B) ask // for everything, as before. _historyLinesQuery() { if (!this.boundedLoad || !Number.isFinite(this.scrollback)) return ''; return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`; } // The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset // in-stream and replay (_loadBuffer). Two refresh frames in a row must not // start two concurrent replays, each resetting the terminal under the // other's chunked write. A refresh that arrives mid-replay is COALESCED // into one trailing re-run rather than ignored: the in-flight fetch may // predate the drop the new frame is reporting, and no further frame is // coming to correct stale content. _refreshBuffer() { if (this._bufferLoading) { this._bufferRefreshPending = true; return; } void this._loadBuffer({ refresh: true }); } // Local reflow only, no PTY resize frame. Split out so a divider drag // can reflow the panes at the browser's paint rate (rAF) while sending // the actual `{t:'z'}` resize once, at drag end, matching the primary // pane's own convention (throttledResize in terminal-ui.js). localFit() { if (!this.fitAddon) return; const before = this.terminal?.buffer?.active?.baseY || 0; this.fitAddon.fit(); this._noteResizeRows(before); } // Reflow to the container and tell the PTY, as one step: the xterm and the // PTY must never disagree about size (#464), and a font change is a size // change too, so the font setters call this rather than localFit(). // `force` resends an unchanged size. fit({ force = false } = {}) { this.localFit(); this._sendResize({ force }); } _sendResize({ force = false } = {}) { if (!this._wsReady || !this.fitAddon || !this.terminal) return; // One PTY cannot hold two sizes (mirrors sendResize's own // detachedElsewhere yield in terminal-ui.js): the session got detached // to its own window AFTER this pane was opened, so its own window now // owns the PTY's size and this pane must stand aside. if (this.detachedSessions?.has(this.sessionId)) return; // A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and // fit() then leaves the xterm alone: there is no size worth reporting. const dims = this.fitAddon.proposeDimensions(); if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return; // Report what the xterm actually holds, so the PTY gets exactly the size // the pane renders at. Unclamped, unlike the primary pane's 40x10 floor: // a floor would misreport the split's Pane B at its divider's reachable // 20% position (about 28 columns) and wrap output wrongly, and a floored // xterm would be wider than its container. The server enforces // its own valid range ([1,500]/[1,200] in ws-routes.ts). const cols = this.terminal.cols; const rows = this.terminal.rows; const last = this._lastSentDims; if (!force && last && last.cols === cols && last.rows === rows) return; this._lastSentDims = { cols, rows }; this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' })); } // The session's PTY is new: a tile can connect before its session has a // pane (one Run started while the grid is open joins first), and the // server drops a resize that arrives with no PTY, then spawns at its own // default size. Forget what was sent, so the size goes out now, or with // the next fit() when this tile is hidden right now (a zoomed neighbour): // _sendResize() returns before recording anything it did not send. paneStarted() { this._lastSentDims = null; this._sendResize(); } // The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to // every resize). A PTY and a terminal that disagree on WIDTH render // garbled, so a different column count is adopted; rows stay local, as in // the primary pane (_onPtyGeometryReport in terminal-ui.js, #464). The // pure verdict is the primary's too (reconcilePtyGeometry, constants.js). _onPtyGeometryReport(cols, rows) { const terminal = this.terminal; if (!terminal) return; const verdict = global.CodemanTerminalGeometry?.reconcilePtyGeometry?.( { cols: terminal.cols, rows: terminal.rows }, { cols, rows } ); if (!verdict?.adopt) return; const before = terminal.buffer?.active?.baseY || 0; terminal.resize(verdict.cols, terminal.rows); this._noteResizeRows(before); // a column change reflows rows above the screen this._lastSentDims = { cols: verdict.cols, rows: terminal.rows }; } destroy() { this._destroyed = true; // Anything still queued for this session stays in the app's queue and is // delivered over HTTP by the redelivery sweep, so closing the pane mid- // keystroke loses nothing. clearTimeout(this._reconnectTimer); this._reconnectTimer = null; // A load still fetching would otherwise hold the owner's queue (and the // server's attention) for a pane nobody can see any more. try { this._loadAbort?.abort(); } catch { /* Already settled. */ } this._loadAbort = null; // Likewise a replay still parsing: the xterm is disposed below, so the // write callback it waits for would never come. this._cancelReplay?.(); this._cancelReplay = null; if (this._onWheel) { this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); this._onWheel = null; } if (this._onClick) { this.mountEl?.removeEventListener('click', this._onClick); this._onClick = null; } // Page keys still waiting for their flush go nowhere: the pane is gone. clearTimeout(this._scrollFlushTimer); this._scrollFlushTimer = null; this._scrollBytes = ''; this._pageKeyPending = 0; this._detachSocket(); if (this._onFocusIn) { this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); this._onFocusIn = null; } // Before dispose(): puts xterm's own textarea diff back and takes the // controller's listeners off the textarea; its pending timers are inert // once it is destroyed. this._destroyKeyCode229Recovery(); // A destroyed pane cannot hold the keyboard: shortcuts fall back to the // primary terminal (_focusedPane also skips a destroyed tile on its own). if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null); if (this.terminal) { this.terminal.dispose(); this.terminal = null; } this.fitAddon = null; } } // The marker a pane writes when its socket drops: a transient drop says it is // reconnecting; a permanent stop says why, keyed by close code. All start // with `[disconnected` so a reader (and a test) can tell any of them apart // from session output. TerminalTile.MARKER_RECONNECTING = '[disconnected, reconnecting…]'; TerminalTile.STOP_MARKERS = { 4003: '[disconnected: the server refused this connection]', 4004: '[disconnected: the session ended]', 4009: '[disconnected: the session ended]', 4010: '[disconnected: another connection took over this pane]', }; /** * ONE queue for every capture a set of tiles fetches (`GET * /api/sessions/:id/terminal`): the initial load, the refresh after a * reconnect, a server `{t:'r'}` refresh and the shell history pull. Each * capture runs synchronous tmux calls on the server, so N of them at once do * not run in parallel, they stall every WebSocket and SSE stream on it back to * back. After a deploy restart all N tiles reopen within the same second; this * drains their refreshes one at a time. * * Concurrency 1. Next up is a history pull (the user is waiting on it), then * the lowest `rank(tile)` (the grid ranks the focused tile first, then reading * order), then arrival order. A destroyed tile's entries are dropped, never run. * DOM-free, so the grid owns the policy and tests drive it directly. */ class TileLoadQueue { /** * @param {{rank?: (tile: object) => number, onChange?: (tile: object, state: 'queued'|'running'|'idle') => void}} [opts] */ constructor(opts = {}) { this._rank = typeof opts.rank === 'function' ? opts.rank : () => 0; this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null; this._pending = []; this._active = null; this._seq = 0; } /** The `scheduleLoad` a TerminalTile takes. Resolves once `run` finished or was dropped; never rejects. */ schedule(tile, kind, run) { return new Promise((resolve) => { this._pending.push({ tile, kind, run, resolve, seq: this._seq++ }); this._notify(tile, 'queued'); this._pump(); }); } /** Drops every load still waiting for `tile` (the running one, if any, finishes on its own). */ drop(tile) { const keep = []; for (const entry of this._pending) { if (entry.tile === tile) entry.resolve(); else keep.push(entry); } this._pending = keep; if (this._active?.tile !== tile) this._notify(tile, 'idle'); } /** How many loads are waiting (not counting the running one). */ get size() { return this._pending.length; } /** The tile whose load is running, or null. */ get activeTile() { return this._active?.tile ?? null; } _notify(tile, state) { try { this._onChange?.(tile, state); } catch { /* A display callback never stops the queue. */ } } _takeNext() { let best = -1; let bestKey = null; for (let i = 0; i < this._pending.length; i++) { const entry = this._pending[i]; if (entry.tile?._destroyed) continue; const key = [entry.kind === 'history' ? 0 : 1, this._rank(entry.tile), entry.seq]; const order = bestKey ? key[0] - bestKey[0] || key[1] - bestKey[1] || key[2] - bestKey[2] : -1; if (order < 0) { best = i; bestKey = key; } } // Destroyed tiles' entries go now, resolved but never run. const dropped = this._pending.filter((entry) => entry.tile?._destroyed); const next = best === -1 ? null : this._pending[best]; this._pending = this._pending.filter((entry) => entry !== next && !entry.tile?._destroyed); for (const entry of dropped) entry.resolve(); return next; } async _pump() { if (this._active) return; const entry = this._takeNext(); if (!entry) return; this._active = entry; this._notify(entry.tile, 'running'); try { await entry.run(); } catch { /* A load settles its own failure; the queue only moves on. */ } finally { this._active = null; const stillQueued = this._pending.some((e) => e.tile === entry.tile); this._notify(entry.tile, stillQueued ? 'queued' : 'idle'); entry.resolve(); void this._pump(); } } } global.TerminalTile = TerminalTile; global.TileLoadQueue = TileLoadQueue; })(window);