diff --git a/CLAUDE.md b/CLAUDE.md
index 19833f7f..e808b276 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -304,7 +304,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
-Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
+Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
diff --git a/config/test-suites.ts b/config/test-suites.ts
index c0c444af..599bbdba 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -30,6 +30,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-load-window.browser.test.ts',
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
+ 'test/split-pane-terminal.browser.test.ts',
];
/**
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 139b035a..69cd2676 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -3721,6 +3721,7 @@
+
diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js
new file mode 100644
index 00000000..f0ee183e
--- /dev/null
+++ b/src/web/public/terminal-split.js
@@ -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);
diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts
new file mode 100644
index 00000000..2b3ee22b
--- /dev/null
+++ b/test/split-pane-terminal.browser.test.ts
@@ -0,0 +1,94 @@
+/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (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';
+
+const PORT = 3175;
+const BASE_URL = `http://localhost:${PORT}`;
+
+describe('SplitTerminalPane in a real browser', () => {
+ let server: WebServer;
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ server = new WebServer(PORT, false, true);
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage();
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
+ }, 90000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ if (server) await server.stop();
+ }, 60000);
+
+ it('connects, echoes real PTY output, and cleans up on destroy', async () => {
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
+ });
+ const id = (await res.json()).data.session.id;
+ // Session creation alone leaves pid:null and no pane (per CLAUDE.md's
+ // 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.
+ await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
+ return id;
+ });
+
+ const result = await page.evaluate(async (id) => {
+ const mount = document.createElement('div');
+ mount.style.width = '400px';
+ mount.style.height = '300px';
+ document.body.appendChild(mount);
+
+ const pane = new (window as any).SplitTerminalPane(id, mount);
+ pane.connect();
+
+ // Wait for the WS to open, then send a real input frame — testMode's
+ // echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
+ // is what proves the WS round-trip actually reaches a real PTY and back,
+ // not just that xterm can render locally-written text.
+ await new Promise((resolve) => {
+ const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
+ check();
+ });
+ pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));
+
+ const hasEcho = await new Promise((resolve) => {
+ const deadline = Date.now() + 5000;
+ const poll = () => {
+ const buf = pane.terminal.buffer.active;
+ for (let i = 0; i < buf.length; i++) {
+ if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
+ resolve(true);
+ return;
+ }
+ }
+ if (Date.now() > deadline) resolve(false);
+ else setTimeout(poll, 100);
+ };
+ poll();
+ });
+
+ pane.destroy();
+ const cleanedUp = mount.querySelector('.xterm') === null;
+ document.body.removeChild(mount);
+
+ return { hasEcho, cleanedUp };
+ }, sessionId);
+
+ expect(result.hasEcho).toBe(true);
+ expect(result.cleanedUp).toBe(true);
+
+ await page.evaluate(async (id) => {
+ await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
+ }, sessionId);
+ });
+});