feat(split): both split panes name their harness and model

"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.

- Pane B's header is built from nodes now (it was innerHTML with the name
  escaped) and follows renames and model changes on every tab render, like a
  tile header; its close button is a tile button (26px target, 19px glyph).
- Pane A is the main terminal, which has no header of its own: while the
  split is open it gets the same strip, minus the close, as the first child of
  .terminal-wrap, and it names the active session. The strip takes 28px from
  the main terminal, so the opening resize fits it with the strip already in
  place, and closing removes the strip before giving the height back. Both go
  through sendResize / syncTerminalGeometry (#464): the close no longer calls
  a bare fitAddon.fit(), and a close that skips the server resize (Pane A's
  session ended) still refits through syncTerminalGeometry.
- The partial-history banner, which overlays the top of .terminal-wrap,
  starts below Pane A's strip while it is there.

test/split-pane-headers.test.ts drives the real split code on the grid's vm
harness: both headers, text-only names and models, refresh on a tab render,
no writes for an unchanged session, and the opening/closing fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 13:32:00 +02:00
parent 969f273fec
commit c6e13e4fcf
4 changed files with 360 additions and 34 deletions
+25 -19
View File
@@ -19376,31 +19376,33 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
overflow: hidden; overflow: hidden;
} }
/* A split pane's header strip, Pane B's always and Pane A's while the split is
open: harness logo, name, model, and Pane B's close (a tile button, see
.tile-btn). The tile header's size and layout, FIXED height like it (#464):
the terminal below is fitted to what is left. */
.terminal-pane-b-header { .terminal-pane-b-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 6px;
padding: 4px 8px; flex: 0 0 28px;
height: 28px;
box-sizing: border-box;
padding: 0 2px 0 8px;
overflow: hidden;
font-size: 12px; font-size: 12px;
background: var(--bg-secondary, #1a1a1a); background: var(--bg-secondary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #333); border-bottom: 1px solid var(--border-color, #333);
flex: 0 0 auto; user-select: none;
} }
.terminal-pane-b-close { /* The partial-history banner overlays the top of .terminal-wrap; while Pane A
/* A native <button> now backs this (keyboard-reachable close), so reset its has its header there, it starts below it. */
default chrome back to the plain glyph this rule always drew. */ .terminal-pane-a-header ~ .history-trunc-bar {
border: none; top: 28px;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
padding: 0 6px;
opacity: 0.7;
} }
.terminal-pane-b-close:hover { .split-harness.run-mode-dot {
opacity: 1; font-size: 13px;
} }
.terminal-pane-b-container { .terminal-pane-b-container {
@@ -19507,7 +19509,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
the logo and the buttons keep their size. The name does not shrink at all (a the logo and the buttons keep their size. The name does not shrink at all (a
shrink factor, however small, also takes a subpixel from a name that fits and shrink factor, however small, also takes a subpixel from a name that fits and
ellipsizes it) and is capped at this box's width instead. */ ellipsizes it) and is capped at this box's width instead. */
.tile-title { .tile-title,
.split-title {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 6px;
@@ -19517,7 +19520,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
white-space: nowrap; white-space: nowrap;
} }
.tile-name { .tile-name,
.split-title > .session-name {
flex: 0 0 auto; flex: 0 0 auto;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
@@ -19526,7 +19530,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.tile-model { .tile-model,
.split-model {
flex: 0 1 auto; flex: 0 1 auto;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -19536,7 +19541,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 11px; font-size: 11px;
} }
.tile-model::before { .tile-model::before,
.split-model::before {
content: '\00B7'; content: '\00B7';
margin-right: 6px; margin-right: 6px;
} }
+97 -13
View File
@@ -184,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
const paneB = document.createElement('div'); const paneB = document.createElement('div');
paneB.className = 'terminal-pane-b'; paneB.className = 'terminal-pane-b';
paneB.innerHTML = ` // Pane B's header: the harness logo, the name and the model, as on a grid
<div class="terminal-pane-b-header"> // tile, and the close button at a tile button's size.
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span> const headerB = this._buildSplitPaneHeader();
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button> const close = document.createElement('button');
</div> close.type = 'button';
<div class="terminal-pane-b-container"></div> close.className = 'tile-btn tile-remove terminal-pane-b-close';
`; close.title = 'Close split';
close.setAttribute('aria-label', 'Close split');
close.textContent = '\u00D7';
close.addEventListener('click', () => this.closeSplitPane());
headerB.el.appendChild(close);
const bodyB = document.createElement('div');
bodyB.className = 'terminal-pane-b-container';
paneB.append(headerB.el, bodyB);
// Pane A is the main terminal, which has no header of its own: while the
// split is open it gets the same strip, so each session in the split view
// names its harness and model. It takes height from the main terminal,
// which the opening resize below fits through syncTerminalGeometry (#464);
// closeSplitPane gives it back.
const headerA = this._buildSplitPaneHeader();
headerA.el.classList.add('terminal-pane-a-header');
this._splitHeaders = { a: headerA, b: headerB };
parent.insertBefore(container, wrap); parent.insertBefore(container, wrap);
wrap.insertBefore(headerA.el, wrap.firstChild);
container.appendChild(wrap); container.appendChild(wrap);
wrap.style.flexBasis = '50%'; wrap.style.flexBasis = '50%';
container.appendChild(divider); container.appendChild(divider);
container.appendChild(paneB); container.appendChild(paneB);
paneB.style.flexBasis = '50%'; paneB.style.flexBasis = '50%';
this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), { this._splitPane = new window.TerminalTile(sessionId, bodyB, {
mode: session?.mode, mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {}, fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions, detachedSessions: this.detachedSessions,
@@ -209,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
initial load — live output still arrives once/if the socket connects. */ initial load — live output still arrives once/if the socket connects. */
}); });
this._splitSessionId = sessionId; this._splitSessionId = sessionId;
this._renderSplitChrome();
// Pane A just went from full width to 50%, but nothing has told its // Pane A just went from full width to 50%, but nothing has told its
// session's PTY/tmux window about it yet — the passive ResizeObserver in // session's PTY/tmux window about it yet — the passive ResizeObserver in
@@ -236,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
this._splitPane.destroy(); this._splitPane.destroy();
this._splitPane = null; this._splitPane = null;
this._splitSessionId = null; this._splitSessionId = null;
// Before the refit below, so the main terminal gets its full height back.
this._splitHeaders?.a.el.remove();
this._splitHeaders = null;
this._updateSplitButtonState(false); this._updateSplitButtonState(false);
const container = document.querySelector('.terminal-split-container'); const container = document.querySelector('.terminal-split-container');
@@ -246,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
parent.insertBefore(wrap, container); parent.insertBefore(wrap, container);
container.remove(); container.remove();
if (this.fitAddon) this.fitAddon.fit(); // Pane A takes the whole width back and, with its header strip gone, its
// whole height: through syncTerminalGeometry (#464), never a bare
// fitAddon.fit(). sendResize fits that way as its first step.
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split // The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
// while activeSessionId is still the id the server just removed, so a // while activeSessionId is still the id the server just removed, so a
// resize from here would be aimed at a session that no longer exists; // resize from here would be aimed at a session that no longer exists;
// the promoted session gets its own resize from selectSession(). // the promoted session gets its own resize from selectSession(), and the
if (!options.skipPrimaryResize) { // terminal is only refitted here.
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
} else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
}, },
// A click on .btn-split does one of two things — open the picker, or // A click on .btn-split does one of two things — open the picker, or
@@ -261,6 +283,59 @@ Object.assign(CodemanApp.prototype, {
// nothing on the button said which. `.split-open` + aria-pressed give it // nothing on the button said which. `.split-open` + aria-pressed give it
// the same active-state language as the codebase's other toggle buttons // the same active-state language as the codebase's other toggle buttons
// (keyboard-accessory's Ctrl key, the voice-input mic). // (keyboard-accessory's Ctrl key, the voice-input mic).
/**
* A split pane's header strip: the harness logo, the session name and the
* model, the three a grid tile's header shows. Built from nodes (the name
* and the model are untrusted text) and painted by _renderSplitChrome.
*/
_buildSplitPaneHeader() {
const el = document.createElement('div');
el.className = 'terminal-pane-b-header';
const harness = document.createElement('span');
harness.className = 'split-harness run-mode-dot';
harness.setAttribute('role', 'img');
const title = document.createElement('span');
title.className = 'split-title';
const name = document.createElement('span');
// `.session-name` is one of the translator's skipped surfaces (user text).
name.className = 'session-name';
// As on a tile: the name inside is never translated, the tooltip may be,
// and screen readers hear the model once, in the logo's accessible name.
const model = document.createElement('span');
model.className = 'split-model';
model.setAttribute('aria-hidden', 'true');
model.hidden = true;
const modelName = document.createElement('span');
modelName.setAttribute('data-i18n-skip', '');
model.appendChild(modelName);
title.append(name, model);
el.append(harness, title);
return { el, harness, name, model, modelName };
},
/**
* Both split headers from their sessions: Pane A shows the active session,
* Pane B its own. Runs after every tab render, so a rename or a model
* change reaches them; unchanged values write nothing.
*/
_renderSplitChrome() {
const headers = this._splitHeaders;
if (!headers || !this._splitPane) return;
for (const [parts, id] of [
[headers.a, this.activeSessionId],
[headers.b, this._splitSessionId],
]) {
const session = id ? this.sessions.get(id) : null;
if (!session) continue;
const name = this.getSessionName?.(session) || session.name || 'Session';
if (parts.nameValue !== name) {
parts.nameValue = name;
parts.name.textContent = name;
}
this._paintSessionHarness(parts, session, 'split-harness');
}
},
/** /**
* Paints a session header's harness logo and model: a grid tile's, and the * Paints a session header's harness logo and model: a grid tile's, and the
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is * split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
@@ -452,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
// this, Pane A rebinds to a session that Pane B's independent WebSocket is // this, Pane A rebinds to a session that Pane B's independent WebSocket is
// still attached to — two live WebSockets to one session, each claiming PTY // still attached to — two live WebSockets to one session, each claiming PTY
// dimensions via its own `{t:'z',...}` resize frame. // dimensions via its own `{t:'z',...}` resize frame.
// Every tab render (any session change: a rename, a model switch) refreshes
// the split headers too, the way tile-grid.js refreshes the tile headers.
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
this._renderSplitChrome?.();
return result;
};
const _originalSelectSession = CodemanApp.prototype.selectSession; const _originalSelectSession = CodemanApp.prototype.selectSession;
CodemanApp.prototype.selectSession = function (sessionId, ...args) { CodemanApp.prototype.selectSession = function (sessionId, ...args) {
if (this._splitPane && this._splitSessionId === sessionId) { if (this._splitPane && this._splitSessionId === sessionId) {
+8 -2
View File
@@ -80,6 +80,9 @@ export class FakeEl {
else this.children.splice(i, 0, child); else this.children.splice(i, 0, child);
return child; return child;
} }
get firstChild() {
return this.children[0] ?? null;
}
get nextSibling() { get nextSibling() {
const siblings = this.parentElement?.children ?? []; const siblings = this.parentElement?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null; return siblings[siblings.indexOf(this) + 1] ?? null;
@@ -152,7 +155,8 @@ export class FakeTile {
export const main = new FakeEl(); export const main = new FakeEl();
main.className = 'main'; main.className = 'main';
const wrap = new FakeEl(); /** The main terminal's `.terminal-wrap` (Pane A while a split is open). */
export const wrap = new FakeEl();
wrap.className = 'terminal-wrap'; wrap.className = 'terminal-wrap';
main.appendChild(wrap); main.appendChild(wrap);
export const section = new FakeEl(); export const section = new FakeEl();
@@ -317,8 +321,10 @@ export function resetGridHarness() {
delete windowStub.__codemanCliCatalog; delete windowStub.__codemanCliCatalog;
section.children = []; section.children = [];
main.className = 'main'; main.className = 'main';
// A split a test left open moved .terminal-wrap into its container. // A split a test left open moved .terminal-wrap into its container, with
// Pane A's header strip in it.
main.children = []; main.children = [];
wrap.children = [];
main.appendChild(wrap); main.appendChild(wrap);
main.appendChild(section); main.appendChild(section);
bySelector.clear(); bySelector.clear();
+230
View File
@@ -0,0 +1,230 @@
/**
* @fileoverview Each session in the split view names its harness and model.
*
* - Pane B's header is the tile header's strip: the harness logo (PR #532's
* `run-mode-dot <cliId>` slot), the name and the model, and the close button
* at a tile button's size.
* - Pane A is the main terminal, which has no header of its own: while the
* split is open it gets the same strip (minus the close), as the FIRST child
* of `.terminal-wrap`, and loses it when the split closes. That strip takes
* height from the main terminal, so opening fits it through sendResize (its
* first step is syncTerminalGeometry, #464) with the strip already in place,
* and closing gives the height back the same way, never with a bare
* `fitAddon.fit()`.
* - Every tab render refreshes both headers (a rename, a model switch, the
* active session changing under Pane A); an unchanged session writes nothing.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
main,
makeGridApp,
resetGridHarness,
windowStub,
wrap,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
beforeEach(() => {
resetGridHarness();
windowStub.__codemanCliCatalog = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
];
});
/** s-a (dsh on its route) in the main pane, s-b (claude) in Pane B. */
function openSplit(): GridApp {
const app = makeGridApp(IDS);
Object.assign(app.sessions.get('s-a'), {
name: 'review-api',
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
Object.assign(app.sessions.get('s-b'), {
name: 'docs-pass',
displayModel: { model: 'Haiku 4.5', source: 'statusline' },
});
app.syncTerminalGeometry = vi.fn();
app.fitAddon = { fit: vi.fn() };
app.openSplitPane('s-b');
// closeSplitPane finds its container by selector.
const container = main.querySelector('.terminal-split-container');
if (container) bySelector.set('.terminal-split-container', container);
return app;
}
const headerB = () => main.querySelector('.terminal-pane-b')!.querySelector('.terminal-pane-b-header') as FakeEl;
const headerA = () => wrap.querySelector('.terminal-pane-a-header');
const part = (header: FakeEl, cls: string) => header.querySelector(`.${cls}`) as FakeEl;
const modelName = (header: FakeEl) => part(header, 'split-model').children[0];
/** A tab render, through the real wrapper chain (the original returns early mid-rename). */
function renderTabs(app: GridApp) {
app._inlineRenameActive = true;
app._renderSessionTabsImmediate();
}
describe('the split headers', () => {
it("Pane B's header: logo, name, model, and the close button at a tile button's size", () => {
openSplit();
const b = headerB();
expect(b.children.map((c) => c.className.split(' ')[0])).toEqual(['split-harness', 'split-title', 'tile-btn']);
expect(part(b, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(b, 'session-name').textContent).toBe('docs-pass');
expect(modelName(b).textContent).toBe('Haiku 4.5');
expect(part(b, 'split-harness').title).toBe('Claude Code · Haiku 4.5');
expect(part(b, 'split-harness').getAttribute('aria-label')).toBe('Claude Code · Haiku 4.5');
const close = part(b, 'terminal-pane-b-close');
expect(close.className).toBe('tile-btn tile-remove terminal-pane-b-close');
expect(close.getAttribute('aria-label')).toBe('Close split');
expect(close.textContent).toBe('×');
});
it("Pane A gets the same strip, first in the main terminal's wrap, without a close", () => {
openSplit();
const a = headerA()!;
expect(wrap.children[0]).toBe(a);
expect(a.className).toBe('terminal-pane-b-header terminal-pane-a-header');
expect(part(a, 'split-harness').className).toBe('split-harness run-mode-dot deepseek');
expect(part(a, 'session-name').textContent).toBe('review-api');
expect(modelName(a).textContent).toBe('qwen3.8-27b');
expect(part(a, 'split-harness').title).toBe('DeepSeek · qwen3.8-27b');
expect(part(a, 'terminal-pane-b-close')).toBeNull();
});
it('names and models are text, and the model is never translated', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<img src=x onerror=alert(1)>';
app.sessions.get('s-b').displayModel = { model: '<b>m</b>', source: 'statusline' };
app.syncTerminalGeometry = vi.fn();
app.openSplitPane('s-b');
expect(part(headerB(), 'session-name').textContent).toBe('<img src=x onerror=alert(1)>');
expect(part(headerB(), 'session-name').children).toHaveLength(0);
expect(modelName(headerB()).textContent).toBe('<b>m</b>');
expect(modelName(headerB()).getAttribute('data-i18n-skip')).toBe('');
});
it('an unknown model shows the logo alone', () => {
const app = openSplit();
delete app.sessions.get('s-b').displayModel;
renderTabs(app);
expect(part(headerB(), 'split-model').hidden).toBe(true);
expect(modelName(headerB()).textContent).toBe('');
expect(part(headerB(), 'split-harness').title).toBe('Claude Code');
});
it('a tab render carries a rename and a model switch into both headers', () => {
const app = openSplit();
app.sessions.set('s-b', {
...app.sessions.get('s-b'),
name: 'docs-pass-2',
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
});
app.sessions.get('s-a').displayModel = { model: 'deepseek-v4-flash', source: 'screen' };
renderTabs(app);
expect(part(headerB(), 'session-name').textContent).toBe('docs-pass-2');
expect(modelName(headerB()).textContent).toBe('Sonnet 4.6');
expect(modelName(headerA()!).textContent).toBe('deepseek-v4-flash');
});
it("Pane A's header follows the active session", () => {
const app = openSplit();
app.activeSessionId = 's-c';
renderTabs(app);
expect(part(headerA()!, 'session-name').textContent).toBe('s-c');
expect(part(headerA()!, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(headerA()!, 'split-model').hidden).toBe(true);
});
it('an unchanged session writes nothing on a refresh', () => {
const app = openSplit();
const writes: string[] = [];
for (const [which, header] of [
['a', headerA()!],
['b', headerB()],
] as Array<[string, FakeEl]>) {
for (const [node, props] of [
[part(header, 'split-harness'), ['className', 'title']],
[part(header, 'session-name'), ['textContent']],
[part(header, 'split-model'), ['title', 'hidden']],
[modelName(header), ['textContent']],
] as Array<[FakeEl, string[]]>) {
for (const prop of props) {
let value = (node as unknown as Record<string, unknown>)[prop];
Object.defineProperty(node, prop, {
get: () => value,
set: (v) => {
writes.push(`${which} ${prop}`);
value = v;
},
});
}
const setAttribute = node.setAttribute.bind(node);
node.setAttribute = (k: string, v: string) => {
writes.push(`${which} @${k}`);
setAttribute(k, v);
};
}
}
renderTabs(app);
renderTabs(app);
expect(writes).toEqual([]);
});
});
describe("the main terminal's height", () => {
it('opening fits Pane A with its header strip already in place, through sendResize', () => {
const app = makeGridApp(IDS);
app.fitAddon = { fit: vi.fn() };
let stripInPlace = false;
app.sendResize = vi.fn(() => {
stripInPlace = wrap.children[0]?.classList.contains('terminal-pane-a-header') ?? false;
return Promise.resolve(true);
});
app.openSplitPane('s-b');
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripInPlace).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it('closing takes the strip away and gives the height back through sendResize, never a bare fit', () => {
const app = openSplit();
app.sendResize.mockClear();
let stripGone = false;
app.sendResize = vi.fn(() => {
stripGone = headerA() === null;
return Promise.resolve(true);
});
app.closeSplitPane();
expect(headerA()).toBeNull();
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripGone).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
expect(app._splitHeaders).toBeNull();
});
it('a close that skips the resize (Pane A ended) still refits through syncTerminalGeometry', () => {
const app = openSplit();
app.sendResize.mockClear();
app.closeSplitPane({ skipPrimaryResize: true });
expect(headerA()).toBeNull();
expect(app.sendResize).not.toHaveBeenCalled();
expect(app.syncTerminalGeometry).toHaveBeenCalledTimes(1);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it("Pane B's close button closes the split", () => {
const app = openSplit();
const tile = FakeTile.all.at(-1) as FakeTile;
part(headerB(), 'terminal-pane-b-close').dispatch('click');
expect(app._splitPane).toBeNull();
expect(tile.destroy).toHaveBeenCalledTimes(1);
expect(headerA()).toBeNull();
});
});