feat(split-pane): add SplitTerminalPane class for Pane B

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
timkjr
2026-09-20 13:10:24 -05:00
co-authored by Claude Sonnet 5
parent aa0521602d
commit 97a1238c85
5 changed files with 203 additions and 1 deletions
+1
View File
@@ -3721,6 +3721,7 @@
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="terminal-split.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
+106
View File
@@ -0,0 +1,106 @@
// 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. */
}
};
}
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);