Task 4's implementer found two real bugs in this plan's browser-test helpers: POST /api/sessions nests the id at data.session.id (not data.id), and mode:'shell' needs a follow-up POST .../shell to actually spawn a PTY. Fixed in Task 4's own snippet (documentation accuracy — already fixed in the real committed code) and pre-emptively in Tasks 5/6's createShellSession() helper before either was dispatched, so neither implementer has to rediscover it independently. Also corrected the <script> tag snippet to defer, matching the real file's convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
50 KiB
Split-Pane Sessions Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a Codeman window show two live sessions side-by-side in one browser tab, with a draggable divider, without touching the existing single-pane session (Pane A).
Architecture: Pane A stays exactly what it is today (this.terminal/this._ws, untouched). A new SplitTerminalPane class owns a second, independent xterm instance + WebSocket for Pane B. A new orchestration module (terminal-split.js) creates/destroys the split container, reparents the existing .terminal-wrap, wires the divider drag, and handles the session picker + auto-collapse edge cases.
Tech Stack: Vanilla JS (xterm.js, xterm-addon-fit), Fastify WebSocket routes (unchanged), Vitest (pure-logic unit tests via vm), Playwright (test/browser) for live behavior.
Spec: docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md
Execution Environment
This plan is implemented entirely inside the existing worktree at
.worktrees/split-pane-sessions (branch feat/split-pane-sessions), created
before brainstorming started — not in the main checkout. Every task's
commit step assumes cwd is that worktree. Before each commit, run
git branch --show-current and confirm it prints feat/split-pane-sessions
(CLAUDE.md's worktree/branch-safety rule) — this repo runs multiple Codeman
sessions concurrently, so verifying is cheap insurance, not ceremony.
All commit steps in this plan already stage explicit paths (never git add -A),
in line with the same rule.
Global Constraints
- Pane A's existing code path (
this.terminal,this._ws,_connectWs,sendResize, etc.) is never modified — zero regression risk on the primary pane. - Local-echo overlay, CJK IME, and the keyboard accessory bar are mobile/touch-only subsystems in this codebase (
localEchoEnableddefaults toMobileDetection.isTouchDevice(); the accessory bar is phone-toolbar-specific). Pane B gets none of them — not because they're being cut down for desktop, but because split-pane itself is a desktop-only feature (it needs a wide viewport), so a mobile-only subsystem has nothing to do there regardless. See the spec's "Key design decision: Pane B is deliberately plainer than Pane A" section for the full reasoning. - No persistence: a page reload always returns to single-pane view. No localStorage key stores split state.
- Side-by-side only, exactly 2 panes, draggable divider, default 50/50, clamped 20%–80%.
- The "Split" header button follows the existing opt-in header-button pattern: ships with a
btn-split--hiddenmarker class, gated by ashowSplitButtonsetting (defaultfalse), so it needs no addition totest/mobile-header-buttons-policy.test.ts's default-visible enumeration (mirrorsshowMultiMonitorButton). - Splitting a session against itself is disallowed — the picker excludes the currently active session.
MAX_WS_PER_SESSION = 5(src/web/routes/ws-routes.ts) is per-session, and Pane A/B are always different sessions, so no server-side change is needed for the connection cap.
Task 1: Pure helpers — divider clamp math and picker list builder
Files:
- Modify:
src/web/public/constants.js(append a newwindow.CodemanSplitPanenamespace, following the existingwindow.CodemanLineage/window.CodemanSessionOrderpattern already in this file) - Test:
test/split-pane-helpers.test.ts(new)
Interfaces:
-
Produces:
window.CodemanSplitPane.clampDividerPercent(rawPercent, min = 20, max = 80)→number -
Produces:
window.CodemanSplitPane.buildSplitPickerSessions(sessions, sessionOrder, excludeId)→Array<{id: string, label: string}> -
Step 1: Write the failing test
// test/split-pane-helpers.test.ts
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadSplitPaneHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanSplitPane: any }).CodemanSplitPane;
}
describe('CodemanSplitPane.clampDividerPercent', () => {
it('passes through a value inside the clamp range', () => {
const { clampDividerPercent } = loadSplitPaneHelper();
expect(clampDividerPercent(50)).toBe(50);
expect(clampDividerPercent(35.5)).toBe(35.5);
});
it('clamps below the floor to the floor', () => {
const { clampDividerPercent } = loadSplitPaneHelper();
expect(clampDividerPercent(5)).toBe(20);
});
it('clamps above the ceiling to the ceiling', () => {
const { clampDividerPercent } = loadSplitPaneHelper();
expect(clampDividerPercent(95)).toBe(80);
});
it('honors custom min/max', () => {
const { clampDividerPercent } = loadSplitPaneHelper();
expect(clampDividerPercent(10, 15, 85)).toBe(15);
expect(clampDividerPercent(90, 15, 85)).toBe(85);
});
});
describe('CodemanSplitPane.buildSplitPickerSessions', () => {
it('excludes the active session and preserves tab order', () => {
const { buildSplitPickerSessions } = loadSplitPaneHelper();
const sessions = new Map([
['a', { name: 'w1-codeman' }],
['b', { name: 'w1-mcp-memory' }],
['c', { name: null }],
]);
const sessionOrder = ['a', 'b', 'c'];
const result = buildSplitPickerSessions(sessions, sessionOrder, 'a');
expect(result).toEqual([
{ id: 'b', label: 'w1-mcp-memory' },
{ id: 'c', label: 'Session' },
]);
});
it('drops order entries with no matching session (stale ids)', () => {
const { buildSplitPickerSessions } = loadSplitPaneHelper();
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
const sessionOrder = ['a', 'ghost'];
const result = buildSplitPickerSessions(sessions, sessionOrder, null);
expect(result).toEqual([{ id: 'a', label: 'w1-codeman' }]);
});
it('returns an empty list when only the excluded session exists', () => {
const { buildSplitPickerSessions } = loadSplitPaneHelper();
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
expect(result).toEqual([]);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- test/split-pane-helpers.test.ts
Expected: FAIL — CodemanSplitPane is undefined (not yet added to constants.js).
- Step 3: Write minimal implementation
Append near the other window.Codeman* namespace assignments at the bottom of src/web/public/constants.js (same file that already defines window.CodemanLineage, window.CodemanSessionOrder, etc. — search for global.CodemanLineage = to find the right neighborhood):
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
function clampDividerPercent(rawPercent, min = 20, max = 80) {
if (rawPercent < min) return min;
if (rawPercent > max) return max;
return rawPercent;
}
function buildSplitPickerSessions(sessions, sessionOrder, excludeId) {
const result = [];
for (const id of sessionOrder) {
if (id === excludeId) continue;
const session = sessions.get(id);
if (!session) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
global.CodemanSplitPane = {
clampDividerPercent,
buildSplitPickerSessions,
};
- Step 4: Run test to verify it passes
Run: npm test -- test/split-pane-helpers.test.ts
Expected: PASS (7 tests)
- Step 5: Commit
git add src/web/public/constants.js test/split-pane-helpers.test.ts
git commit -m "feat(split-pane): add pure divider-clamp and picker-list helpers"
Task 2: showSplitButton setting + header button (wiring only, no split behavior yet)
Files:
- Modify:
src/web/schemas.ts(add one field next toshowMultiMonitorButton) - Modify:
src/web/public/index.html(header button markup + App Settings checkbox chip) - Modify:
src/web/public/settings-ui.js(load/save/defaults/apply, mirroringshowMultiMonitorButtonexactly) - Test:
test/routes/system-routes-split-button-setting.test.ts(new)
Interfaces:
-
Consumes: none (self-contained wiring task)
-
Produces: a
.btn-splitelement in the header (hidden by default viabtn-split--hidden), toggled byapplyHeaderVisibilitySettings(), whoseonclickwill be wired toapp.openSplitPicker()in Task 5 (not yet — for this task, leave theonclickattribute pointing atapp.openSplitPicker(), a no-op stub is fine since it doesn't exist until Task 5; clicking it before Task 5 lands will throw in the console, which is acceptable mid-plan and is fixed by Task 5). -
Step 1: Write the failing test
This mirrors the existing test/routes/system-routes-settings-partial-put.test.ts pattern exactly — there is no buildTestApp() helper in this codebase; route tests go through createRouteTestHarness(registerFn) from test/routes/_route-test-utils.ts, and GET/PUT /api/settings are NOT wrapped in the {success,data} envelope (see src/web/routes/system-routes.ts:938, app.get('/api/settings', ...) returns the raw settings object directly — read its own comment there for why). registerSystemRoutes also drives three watcher singletons on every PUT, so they must be mocked or the route throws.
// test/routes/system-routes-split-button-setting.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = () => ({
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
});
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
});
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify({})),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
describe('showSplitButton setting', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
});
afterEach(async () => {
await harness.app.close();
});
it('round-trips through PUT and GET /api/settings', async () => {
const putRes = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { showSplitButton: true },
});
expect(putRes.statusCode).toBe(200);
const getRes = await harness.app.inject({ method: 'GET', url: '/api/settings' });
const body = JSON.parse(getRes.body);
expect(body.showSplitButton).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- test/routes/system-routes-split-button-setting.test.ts
Expected: FAIL — SettingsUpdateSchema is .strict() and rejects the unknown showSplitButton key with a 400.
- Step 3: Write minimal implementation
In src/web/schemas.ts, find the line showMultiMonitorButton: z.boolean().optional(), and add directly after it:
showSplitButton: z.boolean().optional(),
In src/web/public/index.html, find the multi-monitor header button (class="btn-icon-header btn-multimonitor btn-multimonitor--hidden") and add a sibling button immediately after it:
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker()" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
In index.html's App Settings → Header & Panels checkbox list (find the appSettingsShowMultiMonitorButton chip, id="appSettingsShowMultiMonitorButton"), add a sibling chip immediately after its closing </label>:
<label class="set-chip" data-preview="header" data-preview-order="12"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
In src/web/public/settings-ui.js, mirror every showMultiMonitorButton line for showSplitButton. Four call sites (find each showMultiMonitorButton occurrence and add the twin directly after it):
// In the load function (near appSettingsShowMultiMonitorButton.checked = ...):
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
// In the save function (near showMultiMonitorButton: document.getElementById(...).checked,):
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
// In the defaults object (near showMultiMonitorButton: false,):
showSplitButton: false,
// In applyHeaderVisibilitySettings() (near the multiMonitorBtn toggle block):
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
const splitBtn = document.querySelector('.btn-split');
if (splitBtn) {
splitBtn.classList.toggle('btn-split--hidden', !showSplitButton);
}
- Step 4: Run test to verify it passes
Run: npm test -- test/routes/system-routes-split-button-setting.test.ts
Expected: PASS
- Step 5: Run the full gate to check for regressions
Run: npm test
Expected: PASS (including test/mobile-header-buttons-policy.test.ts, which should not flag .btn-split since it ships with btn-split--hidden and is therefore not in the default-visible enumeration)
- Step 6: Commit
git add src/web/schemas.ts src/web/public/index.html src/web/public/settings-ui.js test/routes/system-routes-split-button-setting.test.ts
git commit -m "feat(split-pane): add showSplitButton setting and header button"
Task 3: Split layout CSS
Files:
- Modify:
src/web/public/styles.css(new rules, no existing rules touched)
Interfaces:
-
Consumes: none
-
Produces: CSS classes
.terminal-split-container,.split-divider,.terminal-pane-b,.terminal-pane-b-header,.terminal-pane-b-closethat Task 5's orchestration code creates elements with. -
Step 1: Add the CSS
Append to src/web/public/styles.css (place near the end, or near other layout-container rules — exact location doesn't affect behavior since these are new, non-conflicting class names):
/* Split-Pane Sessions: container inserted only while a split is active.
.terminal-wrap (Pane A) is reparented into this as the first child; it
keeps every existing rule unchanged since nothing here restyles it. */
.terminal-split-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
min-height: 0;
}
.terminal-split-container > .terminal-wrap {
flex: 0 0 auto;
min-width: 240px;
overflow: hidden;
}
.split-divider {
flex: 0 0 6px;
cursor: col-resize;
background: var(--border-color, #333);
position: relative;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--accent-color, #4a9eff);
}
.terminal-pane-b {
flex: 0 0 auto;
min-width: 240px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.terminal-pane-b-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
font-size: 12px;
background: var(--bg-secondary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #333);
flex: 0 0 auto;
}
.terminal-pane-b-close {
cursor: pointer;
padding: 0 6px;
opacity: 0.7;
}
.terminal-pane-b-close:hover {
opacity: 1;
}
.terminal-pane-b-container {
flex: 1 1 auto;
min-height: 0;
}
- Step 2: Visually verify (no automated test for pure CSS)
Run: npm run check:public-assets (Prettier-checks src/web/public/**; catches formatting issues, not behavior)
Expected: PASS
- Step 3: Commit
git add src/web/public/styles.css
git commit -m "feat(split-pane): add split container/divider/pane-b CSS"
Task 4: SplitTerminalPane class (Pane B's xterm + WebSocket lifecycle)
Files:
- Create:
src/web/public/terminal-split.js - Modify:
src/web/public/index.html(add the<script>tag) - Modify:
CLAUDE.md(appendterminal-split.jsto the Frontend load-order list) - Test:
test/split-pane-terminal.browser.test.ts(new) - Modify:
config/test-suites.ts(register the new test file path inBROWSER_TEST_GLOBS, ornpm run test:browsersilently never runs it — vitest treats "no files matched" as success, and a glob-less array here means literally nothing runs this file unless it's listed)
Interfaces:
- Consumes: global
window.CodemanTerminalFont.resolve()/.resolveWeights(),window.codemanCurrentXtermTheme(),window.codemanCurrentSkinIsLight()(all already attached towindowbyterminal-ui.js), globalTerminal/FitAddon(vendor libs, already loaded before this script per load order) - Produces:
class SplitTerminalPane { constructor(sessionId, mountEl); connect(): void; fit(): void; destroy(): void; }, exposed aswindow.SplitTerminalPane
⚠️ This codebase's browser tests do NOT use @playwright/test's own runner, and there is no test/browser/ directory. They are ordinary vitest describe/it files that import chromium from the raw playwright package and spin up a REAL in-process server via new WebServer(PORT, false, true) (port, https=false, testMode=true — testMode makes TmuxManager/Session spawn a real echo PTY instead of real tmux, per test/terminal-copy-shortcut.test.ts, test/tab-rail-resize.browser.test.ts). Test files live at the top of test/, individually named, and npm run test:browser/CI only run files explicitly listed in config/test-suites.ts's BROWSER_TEST_GLOBS array — there is no directory glob. Port 3175 (checked against every existing const PORT = in test/*.ts and the mobile suite's port constants file at plan-writing time — free).
- Step 1: Write the failing test
// test/split-pane-terminal.browser.test.ts
/** @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' }),
});
// POST /api/sessions nests the session under data.session, and mode:'shell'
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
// POST .../shell is what actually starts it (both were bugs in this plan's
// original text, found and fixed by Task 4's implementer against the real
// server; corrected here to match what was actually committed).
const id = (await res.json()).data.session.id;
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);
});
});
- Step 2: Run test to verify it fails
Run: npm run test:browser -- test/split-pane-terminal.browser.test.ts
Expected: This file is not yet listed in config/test-suites.ts's BROWSER_TEST_GLOBS, so vitest reports 0 files matched and exits 0 (a vacuous "pass" — read the file count, not the color, per CLAUDE.md's Testing section). Register the file in BROWSER_TEST_GLOBS FIRST (add 'test/split-pane-terminal.browser.test.ts', to the array in config/test-suites.ts), then re-run: now it actually executes and FAILs — window.SplitTerminalPane is undefined.
- Step 3: Write minimal implementation
// 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);
In src/web/public/index.html, find the <script src="terminal-ui.js"> tag — check whether it (and its neighbors) actually carry a defer attribute in the real file before copying this verbatim; match whatever the surrounding block's real convention is — and add immediately after it:
<script defer src="terminal-split.js"></script>
In CLAUDE.md, find the Frontend load-order line (... → terminal-ui.js(7) → respawn-ui.js(8) → ...) and insert terminal-split.js(7.5) between them, matching the existing X.Y of 16 @loadorder numbering convention already used for other .5-numbered modules (e.g. tab-rail-resize.js(6.5)).
In config/test-suites.ts, add 'test/split-pane-terminal.browser.test.ts', to the BROWSER_TEST_GLOBS array (any position — it's a flat list, not order-sensitive).
- Step 4: Run test to verify it passes
Run: npm run test:browser -- test/split-pane-terminal.browser.test.ts
Expected: PASS (1 test)
- Step 5: Run the full gate to check for regressions
Run: npm test
Expected: PASS (this task adds no vitest-visible surface to the CI gate itself, so this just confirms nothing broke — test/split-pane-terminal.browser.test.ts is excluded from npm test by the same BROWSER_TEST_GLOBS exclusion list, which is why Step 4 uses test:browser instead)
- Step 6: Commit
git add src/web/public/terminal-split.js src/web/public/index.html CLAUDE.md config/test-suites.ts test/split-pane-terminal.browser.test.ts
git commit -m "feat(split-pane): add SplitTerminalPane class for Pane B"
Task 5: Split orchestration — open/close, session picker, reparenting
Files:
- Modify:
src/web/public/terminal-split.js(add orchestration methods, mixed intoCodemanApp.prototype) - Test:
test/split-pane-orchestration.browser.test.ts(new) - Modify:
config/test-suites.ts(register the new test file inBROWSER_TEST_GLOBS, same reason as Task 4)
Interfaces:
- Consumes:
window.SplitTerminalPane(Task 4),window.CodemanSplitPane.buildSplitPickerSessions(Task 1),app.sessions(Map<string, {name, ...}>),app.sessionOrder(string[]),app.activeSessionId(string | null) - Produces:
app.openSplitPicker(),app.openSplitPane(sessionId),app.closeSplitPane(),app._splitPane(the liveSplitTerminalPaneinstance ornull),app._splitSessionId(the Pane B session id ornull)
⚠️ Same test-infrastructure note as Task 4: vitest + raw playwright + a real WebServer(PORT, false, true), file at the top of test/, registered by exact path in config/test-suites.ts. Port 3176 (checked free at plan-writing time, alongside 3175 used by Task 4 — the two suites never run concurrently since fileParallelism: false in config/vitest.browser.config.ts, but distinct ports avoid any doubt).
- Step 1: Write the failing test
// test/split-pane-orchestration.browser.test.ts
/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
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 = 3176;
const BASE_URL = `http://localhost:${PORT}`;
describe('split-pane orchestration 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);
async function createShellSession(): Promise<string> {
return page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
// POST /api/sessions nests the session under data.session, and mode:'shell'
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
// POST .../shell is what actually starts it (both found and fixed by Task 4's
// implementer against this exact pattern; carried forward here so this task
// does not rediscover the same two bugs).
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id;
});
}
it('opening and closing a split reparents and restores .terminal-wrap', async () => {
const idA = await createShellSession();
const idB = await createShellSession();
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
const duringSplit = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') !== null,
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
}));
expect(duringSplit.hasContainer).toBe(true);
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
expect(duringSplit.hasPaneB).toBe(true);
await page.evaluate(() => (window as any).app.closeSplitPane());
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
timeout: 10000,
});
const afterClose = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') === null,
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
}));
expect(afterClose.hasContainer).toBe(true);
expect(afterClose.wrapRestored).toBe(true);
await page.evaluate(async (ids) => {
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
}, { a: idA, b: idB });
});
it('the split picker excludes the active session', async () => {
const id = await createShellSession();
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
const pickerExcludesActive = await page.evaluate((sid) => {
(window as any).app.openSplitPicker();
const items = Array.from(document.querySelectorAll('.split-picker-item'));
return !items.some((el) => el.getAttribute('data-session-id') === sid);
}, id);
expect(pickerExcludesActive).toBe(true);
await page.evaluate(async (sid) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, id);
});
});
- Step 2: Run test to verify it fails
Run: register 'test/split-pane-orchestration.browser.test.ts', in config/test-suites.ts's BROWSER_TEST_GLOBS first, then npm run test:browser -- test/split-pane-orchestration.browser.test.ts
Expected: FAIL — app.openSplitPane/closeSplitPane/openSplitPicker are undefined.
- Step 3: Write minimal implementation
Append to src/web/public/terminal-split.js, after the SplitTerminalPane class and its global.SplitTerminalPane = SplitTerminalPane; line, still inside the same IIFE closure is not required — this part attaches to CodemanApp.prototype at module scope like every other UI mixin file:
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 = '<div class="split-picker-empty">No other sessions to split with</div>';
} else {
menu.innerHTML = candidates
.map(
(c) =>
`<div class="split-picker-item" data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); document.getElementById('splitPickerMenu')?.remove();">${escapeHtml(c.label)}</div>`
)
.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 = `
<div class="terminal-pane-b-header">
<span>${escapeHtml(session?.name || 'Session')}</span>
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">×</span>
</div>
<div class="terminal-pane-b-container"></div>
`;
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);
});
},
});
- Step 4: Run test to verify it passes
Run: npm run test:browser -- test/split-pane-orchestration.browser.test.ts
Expected: PASS (2 tests)
- Step 5: Run the full gate to check for regressions
Run: npm test
Expected: PASS
- Step 6: Commit
git add src/web/public/terminal-split.js config/test-suites.ts test/split-pane-orchestration.browser.test.ts
git commit -m "feat(split-pane): add open/close orchestration, picker, and divider drag"
Task 6: Auto-collapse on either session ending
Files:
- Modify:
src/web/public/terminal-split.js(hook into existing SSE session-lifecycle handlers) - Test:
test/split-pane-auto-collapse.browser.test.ts(new) - Modify:
config/test-suites.ts(register the new test file inBROWSER_TEST_GLOBS, same reason as Tasks 4-5)
Interfaces:
- Consumes: the existing
_onSessionDeleted(data)handler inapp.js(find it via[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted']in theapp.jshandler map) — this task wraps it rather than replacing it. - Produces: no new public interface; behavior only.
⚠️ Same test-infrastructure note as Tasks 4-5: vitest + raw playwright + a real WebServer(PORT, false, true). Port 3177 (checked free at plan-writing time, alongside 3175/3176 from Tasks 4-5).
- Step 1: Write the failing test
// test/split-pane-auto-collapse.browser.test.ts
/** @fileoverview Real Chromium coverage for split auto-collapse when either session ends (Task 6). */
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 = 3177;
const BASE_URL = `http://localhost:${PORT}`;
describe('split-pane auto-collapse 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);
async function createShellSession(): Promise<string> {
return page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
// POST /api/sessions nests the session under data.session, and mode:'shell'
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
// POST .../shell is what actually starts it (both found and fixed by Task 4's
// implementer against this exact pattern; carried forward here so this task
// does not rediscover the same two bugs).
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id;
});
}
it('deleting the Pane B session auto-collapses the split', async () => {
const idA = await createShellSession();
const idB = await createShellSession();
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
// Delete Pane B's session from "outside" (simulating the SSE event another
// client's delete would produce, by hitting the DELETE route directly).
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, idB);
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
timeout: 10000,
});
const collapsed = await page.evaluate(() => ({
hasContainer: document.querySelector('.terminal-split-container') === null,
splitPaneNulled: (window as any).app._splitPane === null,
}));
expect(collapsed.hasContainer).toBe(true);
expect(collapsed.splitPaneNulled).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, idA);
});
});
- Step 2: Run test to verify it fails
Run: register 'test/split-pane-auto-collapse.browser.test.ts', in config/test-suites.ts's BROWSER_TEST_GLOBS first, then npm run test:browser -- test/split-pane-auto-collapse.browser.test.ts
Expected: FAIL — deleting Pane B's session leaves the split container in place with a dead SplitTerminalPane.
- Step 3: Write minimal implementation
⚠️ This must wrap the PROTOTYPE method at module-evaluation time, not the instance inside a DOMContentLoaded listener. connectSSE() in app.js builds this._sseHandlerWrappers once, on first connect, and each wrapper closure captures the handler function by value — const fn = this[method] (see the _SSE_HANDLER_MAP loop in app.js) — then always invokes that CAPTURED fn, never re-reading this._onSessionDeleted later. So patching the live instance's _onSessionDeleted after connectSSE() has already run (which a DOMContentLoaded listener cannot guarantee happens before) would silently never fire — the wrapper keeps calling the pre-patch original forever. Patching CodemanApp.prototype._onSessionDeleted directly at the top level of terminal-split.js sidesteps this entirely: script tags evaluate synchronously in document order, so this patch runs and completes before app.js's own DOMContentLoaded-triggered bootstrap ever constructs an instance or calls connectSSE() — by the time this[method] is looked up, the prototype it falls through to is already the wrapped version.
Append to src/web/public/terminal-split.js, after the Object.assign(CodemanApp.prototype, {...}) block from Task 5, as top-level module code (not inside any function, not inside a DOMContentLoaded listener):
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
CodemanApp.prototype._onSessionDeleted = function (data) {
if (this._splitSessionId === data.id) {
this.closeSplitPane();
} else if (this._splitPane && this.activeSessionId === data.id) {
// Pane A's session ended: promote Pane B by closing the split and
// selecting its session as the new (single) active pane.
const promoted = this._splitSessionId;
this.closeSplitPane();
if (promoted) this.selectSession(promoted);
}
return _originalOnSessionDeleted.call(this, data);
};
- Step 4: Run test to verify it passes
Run: npm run test:browser -- test/split-pane-auto-collapse.browser.test.ts
Expected: PASS
- Step 5: Run the full gate to check for regressions
Run: npm test
Expected: PASS
- Step 6: Commit
git add src/web/public/terminal-split.js config/test-suites.ts test/split-pane-auto-collapse.browser.test.ts
git commit -m "feat(split-pane): auto-collapse split when either session ends"
Task 7: Documentation — architecture-invariants.md entry
Files:
- Modify:
docs/architecture-invariants.md(one new section, following the existing pattern for feature write-ups in this file)
Interfaces: none — documentation only.
- Step 1: Write the section
Add a new section (placed near other terminal/session-UI entries, e.g. after "Header button visibility"):
**Split-pane sessions** (`showSplitButton`, header button, default OFF): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession()`, never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. Design: `docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md`.
- Step 2: Commit
git add docs/architecture-invariants.md
git commit -m "docs: add split-pane sessions architecture-invariants entry"
Self-Review Notes (completed during plan authoring)
- Spec coverage: side-by-side split (Task 5), draggable divider (Task 5), 50/50 default clamped 20–80% (Task 1 + Task 5), button+picker trigger excluding active session (Task 2 + Task 5), Pane A untouched (Global Constraints + every task explicitly avoids editing
terminal-ui.js), Pane B feature-reduced (Task 4's fileoverview comment + Global Constraints), no persistence (no task writes a localStorage key), auto-collapse both directions (Task 6), subagent windows unchanged (no task touchessubagent-windows.jsorultracode-windows.js— confirmed nothing in this plan needs to, since they already float independent of.terminal-wrap's layout). - Placeholder scan: none found — every step has real code or a real doc paragraph.
- Type/name consistency checked:
SplitTerminalPane(class name) used identically in Tasks 4, 5, 6;_splitPane/_splitSessionId(instance state) used identically in Tasks 5, 6;openSplitPicker/openSplitPane/closeSplitPane(method names) used identically in Tasks 2 (button onclick), 5 (definition), 6 (auto-collapse caller). - Post-approval fixes (SDD pre-flight, before Task 1 dispatch): Task 2's test originally invented a
buildTestApp()helper and a{data:...}envelope that don't exist for/api/settings— corrected against the realcreateRouteTestHarness/registerSystemRoutespattern intest/routes/system-routes-settings-partial-put.test.ts. Tasks 4-6 originally used@playwright/test's own runner against atest/browser/directory that does not exist in this codebase — corrected to the real pattern (vitestdescribe/it+ rawplaywrightpackage +new WebServer(PORT, false, true), files at the top oftest/, individually registered inconfig/test-suites.ts'sBROWSER_TEST_GLOBS) found intest/terminal-copy-shortcut.test.tsandtest/tab-rail-resize.browser.test.ts. Ports assigned: 3175 (Task 4), 3176 (Task 5), 3177 (Task 6), checked against every existingconst PORT =intest/*.tsplus the mobile suite's port constants at fix-time.