diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 276a983e..fb5ecbea 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1614,7 +1614,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds if (id === excludeId) continue; // A detached (popped-out) session's own window already yields its PTY // size (see sendResize's detachedElsewhere guard in terminal-ui.js) — - // Pane B's SplitTerminalPane._sendResize() has no such check, so letting + // Pane B's TerminalTile._sendResize() has no such check, so letting // one into the picker put its detached window and Pane B in a fight over // the same PTY's dimensions. if (detachedIds?.has?.(id)) continue; @@ -1622,7 +1622,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds if (!session) continue; // A session with no PTY attached (exited CLI, a crash-looped session // whose breaker tripped, a restore that failed to re-attach) has nothing - // reading its tmux pane. SplitTerminalPane never does selectSession()'s + // reading its tmux pane. TerminalTile never does selectSession()'s // re-attach POST, so its socket would open onto a pane nothing feeds: // no terminal events, and Session.write() silently drops every keystroke // with no ack either way (Pane B sends no `seq`), so the loss is diff --git a/src/web/public/index.html b/src/web/public/index.html index 958371f6..73515418 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3919,6 +3919,7 @@ + diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 7a8e96c2..8a5d839b 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -1,640 +1,16 @@ // src/web/public/terminal-split.js /** - * @fileoverview SplitTerminalPane — a second, independent live terminal pane - * ("Pane B") for split-view sessions. Deliberately plainer than the primary - * pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no - * CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only - * feature by nature — see docs/split-pane-sessions-plan.md. + * @fileoverview Split-pane orchestration: opens a second live session + * ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with + * a draggable divider, a session picker, and auto-collapse when either + * session ends. Desktop-only; see docs/split-pane-sessions-plan.md. * - * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js - * @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight) - * @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js + * @dependency terminal-tile.js (window.TerminalTile) + * @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH) + * @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js */ -(function (global) { - // How long a scroll-to-top history pull may hold Pane B's live output. - const HISTORY_PULL_TIMEOUT_MS = 10000; - - /** - * Minimal chunked write for Pane B's own xterm instance — write() in - * TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one - * giant synchronous write that blocks the main thread while parsing a long - * scrollback. 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. - */ - function writeChunked(terminal, buffer, isDestroyed) { - if (!buffer) return Promise.resolve(); - if (buffer.length <= TERMINAL_CHUNK_SIZE) { - terminal.write(buffer); - return Promise.resolve(); - } - // Resolves once the LAST chunk is written (or the pane was destroyed - // mid-replay), so _loadBuffer() below can hold its single-flight flag - // across the whole replay rather than just the fetch that precedes it. - return new Promise((resolve) => { - let offset = 0; - const writeNext = () => { - if (isDestroyed() || !terminal) { - resolve(); - return; - } - const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE); - offset += chunk.length; - terminal.write(chunk); - if (offset < buffer.length) { - if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext); - else setTimeout(writeNext, 16); - } else { - resolve(); - } - }; - writeNext(); - }); - } - - class SplitTerminalPane { - 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 split is already open must still be - // seen by _sendResize() below, or it re-creates the exact PTY-size - // fight the split picker already refuses to open at pick time. - this.detachedSessions = opts.detachedSessions; - 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; - // 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; - } - - async connect() { - const savedFontSize = 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: DEFAULT_SCROLLBACK, - allowTransparency: true, - allowProposedApi: true, - }); - - this.fitAddon = new FitAddon.FitAddon(); - this.terminal.loadAddon(this.fitAddon); - this.terminal.open(this.mountEl); - this.fitAddon.fit(); - - this._installWheelListener(); - - this.terminal.onData((data) => { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ t: 'i', d: data })); - } - }); - - // Pane B has no gates of its own by default, so every app-level chord - // that the document capture-phase handler (app.js) only preventDefault()s - // — never stopPropagation()s — reaches xterm here too and writes its raw - // byte/escape sequence into THIS session's PTY on top of whatever the app - // action already did to Pane A (COD-153; mirrors the primary pane's own - // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette, - // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, 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 is deliberately left on xterm's own - // default (plain-text paste): Pane B has no image-paste trap to route it - // to, so intercepting it here would only break paste. - this.terminal.attachCustomKeyEventHandler((ev) => { - 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; - } - // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own - // swallow — 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. Pane B has its own PTY/session and must not send a - // suspend into a non-shell one just because the primary pane's own - // gate lives elsewhere. - 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. - // Mirrors terminal-ui.js's own handling — xterm sends plain \r for - // every Enter variant, so an Ink app (Claude Code) can't tell a - // newline from a submit. Without this gate, Pane B's onData would - // send that bare \r straight over the WS 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 (Pane B 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 (mirrors terminal-ui.js's Ctrl+C gate, #211): with a - // selection, Ctrl+C copies THIS pane's own selection instead of - // sending ^C; with none, plain Ctrl+C must fall through unchanged or - // the interrupt key is lost. Ctrl+Shift+C is different: it is the - // explicit, never-falls-through copy chord, and the predicate above - // does not distinguish it from plain Ctrl+C — ev.shiftKey does, below. - // xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into - // a branch that assigns c.key only for a couple of special cases - // ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING - // for Ctrl+Shift+C either way — this is not about an accidental - // interrupt byte reaching the PTY (verified live: it does not). - // Gating this whole block on hasSelection() (an earlier draft) meant - // that with no selection Ctrl+Shift+C skipped straight to `return - // true`, silently ceding the keystroke to the BROWSER's own handling - // (e.g. Chrome's Inspect-Element binding) with no feedback and no - // attempt to copy, unlike Pane A, which always intercepts it. - // Re-implemented against this.terminal rather than reusing - // app.copyTerminalSelection(), which reads app.terminal — Pane A's — - // and would copy the wrong pane's selection. - if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) { - const raw = this.terminal?.getSelection?.() || ''; - const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3; - // Both clean options are read for THIS pane, never the primary one: - // the gutter width comes from this.sessionId's own run mode, and the - // partial-first-line flag from this terminal's own selection range. - // Passing neither left Pane B keeping a margin Pane A dropped, on the - // same split and the same keystroke. - const range = global.app?._normalisedSelectionRange?.(this.terminal); - const selection = isColumnSelection - ? raw - : (global.CodemanCopySelection?.clean?.(raw, { - margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0, - firstLinePartial: !!range && range.start.x > 0, - }) ?? raw); - if (selection.trim()) { - ev.preventDefault(); - void global.app._copyText?.(selection).then((ok) => { - this.terminal?.clearSelection?.(); - global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error'); - }); - return false; - } - // Nothing worth copying — clear for feedback (a padding-only - // selection cleans to '' and this press still falls through to the - // PTY as 0x03, matching the primary pane's own rule). - if (this.terminal?.hasSelection?.()) { - this.terminal.clearSelection?.(); - global.app.showToast?.('Nothing to copy', 'warning'); - } - // Ctrl+Shift+C never falls through, even with nothing to copy — - // matches terminal-ui.js's own ev.shiftKey branch. - 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 Pane B stays blank until the - // target session happens to produce new output. It LOOKED - // intermittent rather than always-broken because _sendResize() below - // often nudges the shared session's real tmux window to a new size, - // and tmux repaints its current screen on resize — that repaint was - // getting captured and streamed here, incidentally populating the - // pane. When Pane B's computed dimensions happened to already match - // the session's last-known size, Session.resize() (session.ts) skips - // the resize as a no-op, no repaint fires, and the pane stayed blank. - // 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:'; - const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`; - this.ws = new WebSocket(url); - - this.ws.onopen = () => { - this._wsReady = true; - this._sendResize(); - }; - - this.ws.onmessage = (event) => { - 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:2990) — Pane B has its own - // buffer loader for the same reason connect() does. - this._refreshBuffer(); - } - } catch { - /* Malformed frame — ignore, matches primary pane's tolerance. */ - } - }; - - // Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady - // must go false on a drop or fit()/_sendResize() silently no-ops on a - // closed socket per the WebSocket spec (no exception, no log). No - // reconnect logic here — Pane B is deliberately plainer than the - // primary pane (see the fileoverview above); a drop just stops - // resizing until the parent recreates the pane. But onData already - // silently drops keystrokes while _wsReady is false (below), so - // without a visible marker a dropped socket left Pane B looking - // normal while it quietly ate everything typed into it. v1 scope is - // "say so", not reconnect — collapsing the split would lose the - // user's place in Pane B's scrollback for a transient blip. - this.ws.onclose = () => this._onSocketClosed(); - - this.ws.onerror = () => { - // onclose fires after onerror — cleanup happens there. - }; - } - - // 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()). - _onSocketClosed() { - this._wsReady = false; - this._wsClosed = true; - if (this._bufferLoading) this._markerOwed = true; - else this._writeDisconnectedMarker(); - } - - // 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 clear() synchronously, while xterm parses - // a write() on a later tick: a marker written here would land in the - // freshly cleared buffer ABOVE that refresh's replay, a second, stale copy. - // The refresh re-owes the marker on a closed socket and stamps it itself. - 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[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n'); - } - - // Fetches and writes the session's current scrollback. Used both by - // connect() (initial load) and by the `{t:'r'}` server-refresh frame - // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is - // scoped to `this.activeSessionId` and clears/rewrites the primary - // terminal, neither of which applies to this independent pane, so this is - // a standalone equivalent rather than a call into it. - // - // 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 a 50000-line xterm on every load. Non-shell (TUI) sessions still - // get one full replay. `fetch` here goes through the global wrapper - // (constants.js), which already prefixes CodemanBase — unlike the raw - // WebSocket URL above, which does not. - // - // 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() { - if (this._bufferLoading) return; - this._bufferLoading = true; - try { - const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; - const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`); - const payload = (await res.json())?.data ?? {}; - if (payload.terminalBuffer && this.terminal) { - await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); - } - } catch { - /* Best-effort — live output still arrives once the socket connects. */ - } finally { - this._stampMarkerIfOwed(); - this._endBufferLoad(); - } - } - - // 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 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 - // replayed behind the snapshot by _pullHistory() (the primary pane's - // _finishBufferLoad `since` rule), never written underneath it. - _onLiveOutput(data) { - if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); - else this.terminal?.write(data); - } - - // The server's `{t:'c'}` clear frame takes the same route as output, for the - // same reason: clearing straight away, mid-replay, would wipe the half-written - // snapshot and leave _pullHistory() measuring a buffer that is no longer the - // one it is restoring. Queued, it lands in order with the frames around it. - _onLiveClear() { - if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); - else this.terminal?.clear(); - } - - // 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. Passive: this only observes, - // xterm keeps doing the scrolling. - _installWheelListener() { - this._onWheel = (ev) => { - if (ev.deltaY < 0) this._maybeLoadMoreHistory(); - }; - this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true }); - } - - // 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 nothing here ever went back to ask, so the - // history was unreachable. The primary pane has the same pull - // (app.js _maybeRefetchFullHistory); Pane B 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 Pane B 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; - void 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; - 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; - 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}`, { - 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 - // bought nothing and froze 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'); - replayed = true; - if (this._wsClosed) this._markerOwed = true; - await writeChunked(term, buffer, () => this._destroyed); - 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); - const queued = this._liveQueue ?? []; - this._liveQueue = null; - // 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. - const cutoff = replayed ? capturedAt : 0; - for (const entry of queued) { - if (entry.at < cutoff) continue; - if (entry.clear) this.terminal?.clear(); - else this.terminal?.write(entry.data); - } - // 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(); - } - } - - // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh - // frames in a row used to start two concurrent replays, each clearing - // 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; - } - this.terminal?.clear(); - // The clear 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; - void this._loadBuffer(); - } - - // Local reflow only — no PTY resize frame. Split out so a divider drag - // can reflow both 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; - this.fitAddon.fit(); - } - - fit() { - this.localFit(); - this._sendResize(); - } - - _sendResize() { - if (!this._wsReady || !this.fitAddon) 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 split was opened, so its own window now - // owns the PTY's size and Pane B must stand aside. - if (this.detachedSessions?.has(this.sessionId)) return; - const dims = this.fitAddon.proposeDimensions(); - if (!dims) return; - // Send the real proposed dimensions unclamped, matching the primary - // pane's convention (terminal-ui.js's getTerminalDimensions()) — the - // server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts). - // A 40/10 floor here misreported Pane B's real width to the PTY at the - // divider's own reachable 20% floor position, causing real - // output-wrapping bugs. - this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' })); - } - - destroy() { - this._destroyed = true; - if (this._onWheel) { - this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); - this._onWheel = null; - } - if (this.ws) { - this.ws.onopen = null; - this.ws.onmessage = null; - // onclose fires asynchronously AFTER close(); without this it ran - // its "disconnected" write against a pane already torn down. - this.ws.onclose = null; - this.ws.onerror = null; - this.ws.close(); - this.ws = null; - } - if (this.terminal) { - this.terminal.dispose(); - this.terminal = null; - } - this.fitAddon = null; - } - } - - global.SplitTerminalPane = SplitTerminalPane; -})(window); - Object.assign(CodemanApp.prototype, { /** * Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions @@ -777,7 +153,7 @@ Object.assign(CodemanApp.prototype, { // B's own session tab while split can otherwise land here with // sessionId === activeSessionId: two live WebSockets to the same // session, each independently claiming PTY dimensions via its own `{t:'z',...}` - // resize frame. Refuse before creating any DOM or SplitTerminalPane. + // resize frame. Refuse before creating any DOM or TerminalTile. if (sessionId === this.activeSessionId) return; // The picker's own exclusions (buildSplitPickerSessions in constants.js), // re-applied here: the menu can sit open while a listed session's CLI @@ -818,7 +194,7 @@ Object.assign(CodemanApp.prototype, { container.appendChild(paneB); paneB.style.flexBasis = '50%'; - this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), { + this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), { mode: session?.mode, fontSettings: this.loadAppSettingsFromStorage?.() || {}, detachedSessions: this.detachedSessions, @@ -900,7 +276,7 @@ Object.assign(CodemanApp.prototype, { // frame). Coalesced to one call per animation frame below — a raw // mousemove stream fires far faster than the browser repaints, and // without the rAF gate each event did a full xterm reflow on BOTH - // panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has + // panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has // no client-side "dims unchanged" skip), which fanned out into a // `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty // of each dragging across half a wide viewport. diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js new file mode 100644 index 00000000..ce90d2d7 --- /dev/null +++ b/src/web/public/terminal-tile.js @@ -0,0 +1,641 @@ +// 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 planned in + * docs/tile-grid-plan.md reuses the same class for every tile. + * + * Deliberately plainer than the primary pane (this.terminal/this._ws in + * terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile + * handlers, no keyboard accessory bar. Desktop-only by nature; see + * docs/split-pane-sessions-plan.md. + * + * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js + * @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight) + * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js + */ + +(function (global) { + // How long a scroll-to-top history pull may hold Pane B's live output. + const HISTORY_PULL_TIMEOUT_MS = 10000; + + /** + * Minimal chunked write for Pane B's own xterm instance — write() in + * TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one + * giant synchronous write that blocks the main thread while parsing a long + * scrollback. 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. + */ + function writeChunked(terminal, buffer, isDestroyed) { + if (!buffer) return Promise.resolve(); + if (buffer.length <= TERMINAL_CHUNK_SIZE) { + terminal.write(buffer); + return Promise.resolve(); + } + // Resolves once the LAST chunk is written (or the pane was destroyed + // mid-replay), so _loadBuffer() below can hold its single-flight flag + // across the whole replay rather than just the fetch that precedes it. + return new Promise((resolve) => { + let offset = 0; + const writeNext = () => { + if (isDestroyed() || !terminal) { + resolve(); + return; + } + const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE); + offset += chunk.length; + terminal.write(chunk); + if (offset < buffer.length) { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext); + else setTimeout(writeNext, 16); + } else { + resolve(); + } + }; + writeNext(); + }); + } + + 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 split is already open must still be + // seen by _sendResize() below, or it re-creates the exact PTY-size + // fight the split picker already refuses to open at pick time. + this.detachedSessions = opts.detachedSessions; + 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; + // 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; + } + + async connect() { + const savedFontSize = 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: DEFAULT_SCROLLBACK, + allowTransparency: true, + allowProposedApi: true, + }); + + this.fitAddon = new FitAddon.FitAddon(); + this.terminal.loadAddon(this.fitAddon); + this.terminal.open(this.mountEl); + this.fitAddon.fit(); + + this._installWheelListener(); + + this.terminal.onData((data) => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ t: 'i', d: data })); + } + }); + + // Pane B has no gates of its own by default, so every app-level chord + // that the document capture-phase handler (app.js) only preventDefault()s + // — never stopPropagation()s — reaches xterm here too and writes its raw + // byte/escape sequence into THIS session's PTY on top of whatever the app + // action already did to Pane A (COD-153; mirrors the primary pane's own + // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette, + // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, 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 is deliberately left on xterm's own + // default (plain-text paste): Pane B has no image-paste trap to route it + // to, so intercepting it here would only break paste. + this.terminal.attachCustomKeyEventHandler((ev) => { + 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; + } + // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own + // swallow — 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. Pane B has its own PTY/session and must not send a + // suspend into a non-shell one just because the primary pane's own + // gate lives elsewhere. + 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. + // Mirrors terminal-ui.js's own handling — xterm sends plain \r for + // every Enter variant, so an Ink app (Claude Code) can't tell a + // newline from a submit. Without this gate, Pane B's onData would + // send that bare \r straight over the WS 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 (Pane B 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 (mirrors terminal-ui.js's Ctrl+C gate, #211): with a + // selection, Ctrl+C copies THIS pane's own selection instead of + // sending ^C; with none, plain Ctrl+C must fall through unchanged or + // the interrupt key is lost. Ctrl+Shift+C is different: it is the + // explicit, never-falls-through copy chord, and the predicate above + // does not distinguish it from plain Ctrl+C — ev.shiftKey does, below. + // xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into + // a branch that assigns c.key only for a couple of special cases + // ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING + // for Ctrl+Shift+C either way — this is not about an accidental + // interrupt byte reaching the PTY (verified live: it does not). + // Gating this whole block on hasSelection() (an earlier draft) meant + // that with no selection Ctrl+Shift+C skipped straight to `return + // true`, silently ceding the keystroke to the BROWSER's own handling + // (e.g. Chrome's Inspect-Element binding) with no feedback and no + // attempt to copy, unlike Pane A, which always intercepts it. + // Re-implemented against this.terminal rather than reusing + // app.copyTerminalSelection(), which reads app.terminal — Pane A's — + // and would copy the wrong pane's selection. + if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) { + const raw = this.terminal?.getSelection?.() || ''; + const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3; + // Both clean options are read for THIS pane, never the primary one: + // the gutter width comes from this.sessionId's own run mode, and the + // partial-first-line flag from this terminal's own selection range. + // Passing neither left Pane B keeping a margin Pane A dropped, on the + // same split and the same keystroke. + const range = global.app?._normalisedSelectionRange?.(this.terminal); + const selection = isColumnSelection + ? raw + : (global.CodemanCopySelection?.clean?.(raw, { + margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0, + firstLinePartial: !!range && range.start.x > 0, + }) ?? raw); + if (selection.trim()) { + ev.preventDefault(); + void global.app._copyText?.(selection).then((ok) => { + this.terminal?.clearSelection?.(); + global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error'); + }); + return false; + } + // Nothing worth copying — clear for feedback (a padding-only + // selection cleans to '' and this press still falls through to the + // PTY as 0x03, matching the primary pane's own rule). + if (this.terminal?.hasSelection?.()) { + this.terminal.clearSelection?.(); + global.app.showToast?.('Nothing to copy', 'warning'); + } + // Ctrl+Shift+C never falls through, even with nothing to copy — + // matches terminal-ui.js's own ev.shiftKey branch. + 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 Pane B stays blank until the + // target session happens to produce new output. It LOOKED + // intermittent rather than always-broken because _sendResize() below + // often nudges the shared session's real tmux window to a new size, + // and tmux repaints its current screen on resize — that repaint was + // getting captured and streamed here, incidentally populating the + // pane. When Pane B's computed dimensions happened to already match + // the session's last-known size, Session.resize() (session.ts) skips + // the resize as a no-op, no repaint fires, and the pane stayed blank. + // 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:'; + const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`; + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + this._wsReady = true; + this._sendResize(); + }; + + this.ws.onmessage = (event) => { + 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:2990) — Pane B has its own + // buffer loader for the same reason connect() does. + this._refreshBuffer(); + } + } catch { + /* Malformed frame — ignore, matches primary pane's tolerance. */ + } + }; + + // Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady + // must go false on a drop or fit()/_sendResize() silently no-ops on a + // closed socket per the WebSocket spec (no exception, no log). No + // reconnect logic here — Pane B is deliberately plainer than the + // primary pane (see the fileoverview above); a drop just stops + // resizing until the parent recreates the pane. But onData already + // silently drops keystrokes while _wsReady is false (below), so + // without a visible marker a dropped socket left Pane B looking + // normal while it quietly ate everything typed into it. v1 scope is + // "say so", not reconnect — collapsing the split would lose the + // user's place in Pane B's scrollback for a transient blip. + this.ws.onclose = () => this._onSocketClosed(); + + this.ws.onerror = () => { + // onclose fires after onerror — cleanup happens there. + }; + } + + // 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()). + _onSocketClosed() { + this._wsReady = false; + this._wsClosed = true; + if (this._bufferLoading) this._markerOwed = true; + else this._writeDisconnectedMarker(); + } + + // 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 clear() synchronously, while xterm parses + // a write() on a later tick: a marker written here would land in the + // freshly cleared buffer ABOVE that refresh's replay, a second, stale copy. + // The refresh re-owes the marker on a closed socket and stamps it itself. + 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[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n'); + } + + // Fetches and writes the session's current scrollback. Used both by + // connect() (initial load) and by the `{t:'r'}` server-refresh frame + // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is + // scoped to `this.activeSessionId` and clears/rewrites the primary + // terminal, neither of which applies to this independent pane, so this is + // a standalone equivalent rather than a call into it. + // + // 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 a 50000-line xterm on every load. Non-shell (TUI) sessions still + // get one full replay. `fetch` here goes through the global wrapper + // (constants.js), which already prefixes CodemanBase — unlike the raw + // WebSocket URL above, which does not. + // + // 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() { + if (this._bufferLoading) return; + this._bufferLoading = true; + try { + const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; + const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`); + const payload = (await res.json())?.data ?? {}; + if (payload.terminalBuffer && this.terminal) { + await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); + } + } catch { + /* Best-effort — live output still arrives once the socket connects. */ + } finally { + this._stampMarkerIfOwed(); + this._endBufferLoad(); + } + } + + // 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 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 + // replayed behind the snapshot by _pullHistory() (the primary pane's + // _finishBufferLoad `since` rule), never written underneath it. + _onLiveOutput(data) { + if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data }); + else this.terminal?.write(data); + } + + // The server's `{t:'c'}` clear frame takes the same route as output, for the + // same reason: clearing straight away, mid-replay, would wipe the half-written + // snapshot and leave _pullHistory() measuring a buffer that is no longer the + // one it is restoring. Queued, it lands in order with the frames around it. + _onLiveClear() { + if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true }); + else this.terminal?.clear(); + } + + // 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. Passive: this only observes, + // xterm keeps doing the scrolling. + _installWheelListener() { + this._onWheel = (ev) => { + if (ev.deltaY < 0) this._maybeLoadMoreHistory(); + }; + this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true }); + } + + // 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 nothing here ever went back to ask, so the + // history was unreachable. The primary pane has the same pull + // (app.js _maybeRefetchFullHistory); Pane B 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 Pane B 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; + void 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; + 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; + 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}`, { + 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 + // bought nothing and froze 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'); + replayed = true; + if (this._wsClosed) this._markerOwed = true; + await writeChunked(term, buffer, () => this._destroyed); + 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); + const queued = this._liveQueue ?? []; + this._liveQueue = null; + // 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. + const cutoff = replayed ? capturedAt : 0; + for (const entry of queued) { + if (entry.at < cutoff) continue; + if (entry.clear) this.terminal?.clear(); + else this.terminal?.write(entry.data); + } + // 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(); + } + } + + // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh + // frames in a row used to start two concurrent replays, each clearing + // 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; + } + this.terminal?.clear(); + // The clear 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; + void this._loadBuffer(); + } + + // Local reflow only — no PTY resize frame. Split out so a divider drag + // can reflow both 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; + this.fitAddon.fit(); + } + + fit() { + this.localFit(); + this._sendResize(); + } + + _sendResize() { + if (!this._wsReady || !this.fitAddon) 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 split was opened, so its own window now + // owns the PTY's size and Pane B must stand aside. + if (this.detachedSessions?.has(this.sessionId)) return; + const dims = this.fitAddon.proposeDimensions(); + if (!dims) return; + // Send the real proposed dimensions unclamped, matching the primary + // pane's convention (terminal-ui.js's getTerminalDimensions()) — the + // server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts). + // A 40/10 floor here misreported Pane B's real width to the PTY at the + // divider's own reachable 20% floor position, causing real + // output-wrapping bugs. + this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' })); + } + + destroy() { + this._destroyed = true; + if (this._onWheel) { + this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); + this._onWheel = null; + } + if (this.ws) { + this.ws.onopen = null; + this.ws.onmessage = null; + // onclose fires asynchronously AFTER close(); without this it ran + // its "disconnected" write against a pane already torn down. + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.close(); + this.ws = null; + } + if (this.terminal) { + this.terminal.dispose(); + this.terminal = null; + } + this.fitAddon = null; + } + } + + global.TerminalTile = TerminalTile; +})(window); diff --git a/test/shift-enter-keypress-swallowed.test.ts b/test/shift-enter-keypress-swallowed.test.ts index 1ca5f311..5ffe3d14 100644 --- a/test/shift-enter-keypress-swallowed.test.ts +++ b/test/shift-enter-keypress-swallowed.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public'); -describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => { +describe.each(['terminal-ui.js', 'terminal-tile.js'])('%s Shift/Ctrl+Enter handler', (file) => { const src = readFileSync(join(PUBLIC, file), 'utf8'); it('swallows every event type for Shift/Ctrl+Enter', () => { diff --git a/test/shift-enter-keypress.browser.test.ts b/test/shift-enter-keypress.browser.test.ts index c13aead8..2a22b2a4 100644 --- a/test/shift-enter-keypress.browser.test.ts +++ b/test/shift-enter-keypress.browser.test.ts @@ -8,8 +8,8 @@ * worked all along because Chromium fires no keypress for it. * * The page is the real app served by a real WebServer, so the handlers under test are the ones - * terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real - * `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is + * terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real + * `TerminalTile`) attach. Nothing restates their predicate. What stands in for the server is * only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and * no session exists behind the ids, so nothing is ever typed into a real pane. * @@ -74,7 +74,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw const mount = document.createElement('div'); mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;'; document.body.appendChild(mount); - const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' }); + const pane = new w.TerminalTile(paneBId, mount, { mode: 'claude' }); void pane.connect().catch(() => {}); // What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket. w.__paneBData = [] as string[]; @@ -200,7 +200,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw expect(shift.sendKeys).toEqual([]); }); - it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => { + it("Pane B (terminal-tile.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => { const shift = await pressIn('paneB', 'Shift+Enter'); expect(shift.focused).toBe(true); expect(shift.data).toEqual([]); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index bd5b211a..e3ce08bb 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -1,4 +1,4 @@ -/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */ +/** @fileoverview Real Chromium + real WebSocket coverage for TerminalTile (Task 4 of the split-pane-sessions plan). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; @@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js'; const PORT = 3175; const BASE_URL = `http://localhost:${PORT}`; -describe('SplitTerminalPane in a real browser', () => { +describe('TerminalTile in a real browser', () => { let server: WebServer; let browser: Browser; let page: Page; @@ -37,7 +37,7 @@ describe('SplitTerminalPane in a real browser', () => { // Testing section) — the shell PTY only spawns once this is called, and // without it the WS opens but no bytes ever flow, and the echo assertion // below would hang until its own timeout for reasons unrelated to - // SplitTerminalPane. + // TerminalTile. await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); return id; }); @@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => { mount.style.height = '300px'; document.body.appendChild(mount); - const pane = new (window as any).SplitTerminalPane(id, mount); + const pane = new (window as any).TerminalTile(id, mount); pane.connect(); // Wait for the WS to open, then send a real input frame — testMode's @@ -107,7 +107,7 @@ describe('SplitTerminalPane in a real browser', () => { }); const id = (await res.json()).data.session.id; await fetch(`/api/sessions/${id}/shell`, { method: 'POST' }); - // Write directly to the session (not through SplitTerminalPane, which + // Write directly to the session (not through TerminalTile, which // does not exist yet). Poll the real ?full=1 capture (same endpoint // connect() below will use) rather than a fixed delay — the shell's // own startup can race an early write and, on this box, a startup @@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => { mount.style.height = '300px'; document.body.appendChild(mount); - const pane = new (window as any).SplitTerminalPane(id, mount); + const pane = new (window as any).TerminalTile(id, mount); await pane.connect(); // xterm's write() parses asynchronously (it queues data and processes it @@ -191,7 +191,7 @@ describe('SplitTerminalPane in a real browser', () => { mount.style.height = '300px'; document.body.appendChild(mount); - const pane = new (window as any).SplitTerminalPane(id, mount); + const pane = new (window as any).TerminalTile(id, mount); await pane.connect(); await new Promise((resolve) => { const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100)); diff --git a/test/split-pane-terminal-unit.test.ts b/test/terminal-tile-unit.test.ts similarity index 98% rename from test/split-pane-terminal-unit.test.ts rename to test/terminal-tile-unit.test.ts index b408283e..b4fd7058 100644 --- a/test/split-pane-terminal-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -1,8 +1,9 @@ -// test/split-pane-terminal-unit.test.ts -// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like +// test/terminal-tile-unit.test.ts +// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like // split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches). // -// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from +// Unit coverage for the two TerminalTile (terminal-tile.js, the split pane's +// Pane B until it moved out of terminal-split.js) fixes from // the final review of #453 that need no browser: destroy() nulling EVERY socket // handler (onclose used to survive it and fire its "disconnected" write into a // pane already torn down), and the `{t:'r'}` server-refresh path being @@ -15,7 +16,7 @@ // The last block covers the scroll-to-top history pull: a burst of output leaves // a shell pane's xterm with about one screen of scrollback while tmux holds every // line, and Pane B (a separate xterm from the primary pane) never went back to -// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js. +// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-tile.js. import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; @@ -73,9 +74,9 @@ const fetchMock = vi.fn(); const rafQueue: Array<() => void> = []; /** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */ const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = []; -const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8'); +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8'); -function loadSplitTerminalPane() { +function loadTerminalTile() { const context = vm.createContext({ console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, // The primary pane's row estimator, reduced to a line count: the pull only @@ -111,18 +112,18 @@ function loadSplitTerminalPane() { }); // The module's tail patches CodemanApp.prototype; nothing on it runs here. vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context); - return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest }) - .SplitTerminalPane; + return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest }) + .TerminalTile; } -const SplitTerminalPane = loadSplitTerminalPane(); +const TerminalTile = loadTerminalTile(); function makePane( mode = 'claude', mount: unknown = {}, opts: { detachedSessions?: Set } = {} ): PaneUnderTest & { terminal: FakeTerminal } { - const pane = new SplitTerminalPane('s1', mount, { mode, ...opts }); + const pane = new TerminalTile('s1', mount, { mode, ...opts }); pane.terminal = { // xterm invokes a write's callback once everything before it is parsed. write: vi.fn((_data: string, done?: () => void) => done?.()), @@ -187,7 +188,7 @@ beforeEach(() => { clock = 0; }); -describe('SplitTerminalPane.destroy()', () => { +describe('TerminalTile.destroy()', () => { it('nulls every WebSocket handler, onclose included, before closing the socket', () => { const pane = makePane(); const terminal = pane.terminal; @@ -210,7 +211,7 @@ describe('SplitTerminalPane.destroy()', () => { }); }); -describe('SplitTerminalPane server-refresh single-flight', () => { +describe('TerminalTile server-refresh single-flight', () => { it('a refresh with nothing in flight clears and fetches straight away', async () => { const pane = makePane(); fetchMock.mockResolvedValueOnce(jsonResponse('one')); @@ -329,7 +330,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => { }); }); -describe('SplitTerminalPane scroll-to-top history pull', () => { +describe('TerminalTile scroll-to-top history pull', () => { it('a shell pane at the top pulls a bounded window of full history and replays it', async () => { const pane = makePane('shell'); const term = pane.terminal;