// 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/superpowers/specs/2026-09-15-split-pane-sessions-design.md. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency terminal-ui.js (window.CodemanTerminalFont, codemanCurrentXtermTheme, codemanCurrentSkinIsLight) * @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js */ (function (global) { class SplitTerminalPane { constructor(sessionId, mountEl) { this.sessionId = sessionId; this.mountEl = mountEl; this.terminal = null; this.fitAddon = null; this.ws = null; this._wsReady = false; } connect() { this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, fontFamily: global.CodemanTerminalFont.resolve(), ...global.CodemanTerminalFont.resolveWeights({}), fontSize: 14, lineHeight: 1.2, cursorBlink: false, cursorStyle: 'block', minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1, scrollback: 5000, allowTransparency: true, allowProposedApi: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.open(this.mountEl); this.fitAddon.fit(); this.terminal.onData((data) => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ t: 'i', d: data })); } }); 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.terminal.write(msg.d); } else if (msg.t === 'c') { this.terminal.clear(); } } 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. this.ws.onclose = () => { this._wsReady = false; }; this.ws.onerror = () => { // onclose fires after onerror — cleanup happens there. }; } fit() { if (!this.fitAddon) return; this.fitAddon.fit(); this._sendResize(); } _sendResize() { if (!this._wsReady || !this.fitAddon) return; const dims = this.fitAddon.proposeDimensions(); if (!dims) return; const cols = Math.max(dims.cols, 40); const rows = Math.max(dims.rows, 10); this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' })); } destroy() { if (this.ws) { this.ws.onopen = null; this.ws.onmessage = 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, { openSplitPicker() { if (this._splitPane) { this.closeSplitPane(); return; } const candidates = window.CodemanSplitPane.buildSplitPickerSessions( this.sessions, this.sessionOrder, this.activeSessionId ); const existing = document.getElementById('splitPickerMenu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.id = 'splitPickerMenu'; menu.className = 'split-picker-menu'; if (candidates.length === 0) { menu.innerHTML = '
No other sessions to split with
'; } else { menu.innerHTML = candidates .map( (c) => `
${escapeHtml(c.label)}
` ) .join(''); } document.body.appendChild(menu); const splitBtn = document.querySelector('.btn-split'); if (splitBtn) { const rect = splitBtn.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; menu.style.right = `${window.innerWidth - rect.right}px`; } }, openSplitPane(sessionId) { if (this._splitPane) this.closeSplitPane(); const wrap = document.querySelector('.terminal-wrap'); const parent = wrap.parentElement; const container = document.createElement('div'); container.className = 'terminal-split-container'; const divider = document.createElement('div'); divider.className = 'split-divider'; const paneB = document.createElement('div'); paneB.className = 'terminal-pane-b'; const session = this.sessions.get(sessionId); paneB.innerHTML = `
${escapeHtml(session?.name || 'Session')} ×
`; parent.insertBefore(container, wrap); container.appendChild(wrap); wrap.style.flexBasis = '50%'; container.appendChild(divider); container.appendChild(paneB); paneB.style.flexBasis = '50%'; this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container')); this._splitPane.connect(); this._splitSessionId = sessionId; this._installSplitDividerDrag(divider, wrap, paneB); }, closeSplitPane() { if (!this._splitPane) return; this._splitPane.destroy(); this._splitPane = null; this._splitSessionId = null; const container = document.querySelector('.terminal-split-container'); if (!container) return; const wrap = container.querySelector('.terminal-wrap'); const parent = container.parentElement; wrap.style.flexBasis = ''; parent.insertBefore(wrap, container); container.remove(); if (this.fitAddon) this.fitAddon.fit(); this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); }, _installSplitDividerDrag(divider, wrap, paneB) { let dragging = false; const onMove = (e) => { if (!dragging) return; const container = divider.parentElement; const rect = container.getBoundingClientRect(); const rawPercent = ((e.clientX - rect.left) / rect.width) * 100; const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent); wrap.style.flexBasis = `${percent}%`; paneB.style.flexBasis = `${100 - percent}%`; if (this.fitAddon) this.fitAddon.fit(); this._splitPane?.fit(); }; const onUp = () => { dragging = false; divider.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; divider.addEventListener('mousedown', () => { dragging = true; divider.classList.add('dragging'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }, });