refactor(split): move the pane class into terminal-tile.js as TerminalTile

Pure move and rename, no behavior change. The split pane's second
terminal (SplitTerminalPane) moves out of terminal-split.js into its own
terminal-tile.js (load order 7.4) as TerminalTile, so the tile grid can
reuse it. terminal-split.js keeps the split orchestration (picker,
divider, auto-collapse) and constructs a TerminalTile for Pane B.

Tests follow the class: split-pane-terminal-unit becomes
terminal-tile-unit, and the Shift+Enter guard and the two browser suites
read terminal-tile.js / window.TerminalTile. The browser suites match
master (one pre-existing environmental failure in both).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 09:28:08 +02:00
parent e2f56dc077
commit d1bbb4cc26
8 changed files with 680 additions and 661 deletions
+1 -1
View File
@@ -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', () => {
+4 -4
View File
@@ -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([]);
+7 -7
View File
@@ -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));
@@ -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<string> } = {}
): 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;