feat(tiles): tile chords in the shortcut registry, and the grid never beside a split

Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable):

- Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step
  back after a selection outside it), else an open split as two tiles, else
  the active session as one tile; pressed again, back to the single view of
  the focused session. xterm emits nothing for a shifted Ctrl letter; the
  browser's find-previous is overridden only where the grid can open.
- Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the
  tile in that direction.
- Remove Focused Tile, unbound: the session keeps running.

tileShortcutFor() decides whether a chord applies (the toggle wherever a grid
could open, the rest only while one is open, so outside the grid
Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The
capture handler dispatches it, and the main terminal's and every tile's xterm
key handler return false for it, for every event type and before the
Shift+Enter gate, so a chord that applies never reaches a PTY.

Coexistence with the split pane: opening the grid over an open split closes
it (no wasted resize for the pane about to park) and seeds the grid with both
of its sessions, Pane A focused. While the grid is open openSplitPicker and
openSplitPane refuse and the Split button reads as unavailable
(aria-disabled); closing the grid never reopens a split.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 16:31:29 +02:00
parent 1a04a75c3d
commit 6adf750c50
9 changed files with 672 additions and 8 deletions
+59
View File
@@ -525,6 +525,56 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }], bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar', action: 'toggleSessionSidebar',
}, },
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
// states (the focus chords only while the grid is open), and outside them the
// chord must reach the terminal untouched. Every xterm key handler swallows a
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs.
{
id: 'toggle-tile-grid',
group: 'Tiles',
label: 'Toggle Tile Grid',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
action: 'toggleTileGrid',
},
{
id: 'focus-tile-left',
group: 'Tiles',
label: 'Focus Tile Left',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
action: 'focusTileLeft',
},
{
id: 'focus-tile-right',
group: 'Tiles',
label: 'Focus Tile Right',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
action: 'focusTileRight',
},
{
id: 'focus-tile-up',
group: 'Tiles',
label: 'Focus Tile Up',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
action: 'focusTileUp',
},
{
id: 'focus-tile-down',
group: 'Tiles',
label: 'Focus Tile Down',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
action: 'focusTileDown',
},
{
id: 'remove-tile',
group: 'Tiles',
label: 'Remove Focused Tile',
// Unbound by default; the session keeps running either way.
bindings: [],
action: 'removeTile',
},
{ {
id: 'previous-next-session', id: 'previous-next-session',
group: 'Session', group: 'Session',
@@ -1362,6 +1412,15 @@ class CodemanApp {
} }
} }
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
// so outside the grid Alt+Shift+Arrows still reach the terminal.
const tileShortcut = this.tileShortcutFor?.(e);
if (tileShortcut) {
e.preventDefault();
this.runTileShortcut(tileShortcut);
return;
}
// Match against the shortcut registry so user rebinds and per-shortcut // Match against the shortcut registry so user rebinds and per-shortcut
// disables (App Settings → Shortcuts) take effect. Every dispatchable // disables (App Settings → Shortcuts) take effect. Every dispatchable
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain // binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
+6
View File
@@ -2510,6 +2510,12 @@ html[data-tab-orientation='vertical'].tab-rail-compact
display: none !important; display: none !important;
} }
/* The tile grid is open (tile-grid.js): the split cannot open beside it. */
.btn-split.btn-split--blocked {
opacity: 0.4;
cursor: not-allowed;
}
/* While a split is open, the button's own click closes it instead of opening /* While a split is open, the button's own click closes it instead of opening
the picker — the accent fill is what tells the user which of its two the picker — the accent fill is what tells the user which of its two
behaviours the next click will get (aria-pressed carries the same state behaviours the next click will get (aria-pressed carries the same state
+5
View File
@@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
// the old exact-node check below) bubbled straight through to // the old exact-node check below) bubbled straight through to
// `document` and self-closed the menu it just opened. // `document` and self-closed the menu it just opened.
event?.stopPropagation(); event?.stopPropagation();
// The tile grid and the split are never open together (tile-grid.js); the
// Split button shows as unavailable meanwhile.
if (this._tilesOwnTerminal?.()) return;
if (this._splitPane) { if (this._splitPane) {
this.closeSplitPane(); this.closeSplitPane();
return; return;
@@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
// see _applySplitButtonVisibility's comment for why both a JS check and // see _applySplitButtonVisibility's comment for why both a JS check and
// a CSS backstop exist. // a CSS backstop exist.
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return; if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
// Never beside the tile grid: the main terminal is parked while it is open.
if (this._tilesOwnTerminal?.()) return;
// No active session means there is no `.terminal-wrap` to split against // No active session means there is no `.terminal-wrap` to split against
// (the welcome overlay is showing) — without this, a split opened from // (the welcome overlay is showing) — without this, a split opened from
// the home screen still created the container and connected Pane B, just // the home screen still created the container and connected Pane B, just
+4
View File
@@ -208,6 +208,10 @@
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false; return false;
} }
// Tile grid chords (focus, toggle): acted on by the capture handler, so
// they must never reach this tile's PTY. Every event type, and before
// the Shift+Enter branch below.
if (global.app?.tileShortcutFor?.(ev)) return false;
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
// a pasted image uploads to this pane's session and its path is typed // a pasted image uploads to this pane's session and its path is typed
// here, and pasted text goes into this xterm with its bracketed-paste // here, and pasted text goes into this xterm with its bracketed-paste
+5
View File
@@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
return false; return false;
} }
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
// has already acted on one that applies, and its preventDefault() does not
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
if (this.tileShortcutFor?.(ev)) return false;
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending // Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
// ^C. With NO selection the branch must fall through (return true, and no // ^C. With NO selection the branch must fall through (return true, and no
// preventDefault) or the interrupt key is lost, which is the whole reason // preventDefault) or the interrupt key is lost, which is the whole reason
+108 -1
View File
@@ -26,6 +26,16 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
// Trailing debounce for refitting tiles after the grid area changes size, so a // Trailing debounce for refitting tiles after the grid area changes size, so a
// window drag sends each tile's PTY one resize, not one per frame. // window drag sends each tile's PTY one resize, not one per frame.
const TILE_GRID_REFIT_MS = 150; const TILE_GRID_REFIT_MS = 150;
// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
// needs the grid open. The toggle applies wherever a grid could open.
const TILE_SHORTCUTS = {
'toggle-tile-grid': { needsOpen: false },
'focus-tile-left': { needsOpen: true, direction: 'left' },
'focus-tile-right': { needsOpen: true, direction: 'right' },
'focus-tile-up': { needsOpen: true, direction: 'up' },
'focus-tile-down': { needsOpen: true, direction: 'down' },
'remove-tile': { needsOpen: true },
};
/** The grid's state. `has(id)` answers only while it is open. */ /** The grid's state. `has(id)` answers only while it is open. */
class TileGridModel { class TileGridModel {
@@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, {
if (!this.canOpenTileGrid()) return false; if (!this.canOpenTileGrid()) return false;
const grid = (this._tileGrid ||= new TileGridModel()); const grid = (this._tileGrid ||= new TileGridModel());
const max = window.CodemanTileGrid.TILE_GRID_MAX; const max = window.CodemanTileGrid.TILE_GRID_MAX;
// The grid and the split are never open together. An open split becomes the
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
// want more" is one step. No closing resize for Pane A: it is about to park.
let requested = ids || [];
if (this._splitPane) {
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
this.closeSplitPane({ skipPrimaryResize: true });
requested = [...seed, ...requested];
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
}
const wanted = []; const wanted = [];
for (const id of ids || []) { for (const id of requested) {
if (typeof id !== 'string' || wanted.includes(id)) continue; if (typeof id !== 'string' || wanted.includes(id)) continue;
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue;
wanted.push(id); wanted.push(id);
@@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, {
this._installTileGridWidthGate(); this._installTileGridWidthGate();
this._selectTiledSession(focus, { auto }); this._selectTiledSession(focus, { auto });
this._updateConnectionIndicator?.(); this._updateConnectionIndicator?.();
this._updateSplitButtonForTiles();
return true; return true;
}, },
@@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, {
this.terminalBufferCache?.delete(id); this.terminalBufferCache?.delete(id);
} }
this._updateConnectionIndicator?.(); this._updateConnectionIndicator?.();
this._updateSplitButtonForTiles();
if (reselect) this._selectAfterTileGrid(focusedId); if (reselect) this._selectAfterTileGrid(focusedId);
}, },
// The Split button cannot act while the grid is open (openSplitPicker and
// openSplitPane refuse), so it says so: aria-disabled plus a title, the same
// refusal the split already gives for web tabs and the welcome screen.
_updateSplitButtonForTiles() {
const btn = document.querySelector('.btn-split');
if (!btn) return;
const blocked = this._tilesOwnTerminal();
btn.classList.toggle('btn-split--blocked', blocked);
btn.setAttribute('aria-disabled', blocked ? 'true' : 'false');
if (blocked) {
btn.title = 'Split: unavailable while tiles are open';
btn.setAttribute('aria-label', btn.title);
} else {
this._updateSplitButtonState?.(!!this._splitPane);
}
},
/**
* The tile chord `e` asks for, if it applies right now, else null: the toggle
* wherever a grid could open (or is open), the focus and remove chords only
* while it is open, so outside the grid they reach the terminal untouched.
* Registry-aware (rebinds and disables in App Settings, Shortcuts). The
* capture handler (app.js) dispatches it; every xterm key handler returns
* false for it, so a chord that applies never reaches a PTY.
*
* @returns {string|null} the registry id
*/
tileShortcutFor(e) {
if (!e || (!e.ctrlKey && !e.metaKey && !e.altKey)) return null;
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return null;
const open = this._tilesOwnTerminal();
for (const shortcut of this.getShortcutRegistry()) {
const spec = TILE_SHORTCUTS[shortcut.id];
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
if (spec.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id;
return null;
}
return null;
},
/** Runs a chord tileShortcutFor() matched. */
runTileShortcut(id) {
const spec = TILE_SHORTCUTS[id];
if (!spec) return;
if (id === 'toggle-tile-grid') this.toggleTileGrid();
else if (id === 'remove-tile') this.removeFocusedTile();
else if (spec.direction) this.focusTileInDirection(spec.direction);
},
/**
* Opens the grid, or closes it to the single view of the focused session.
* Opening brings back the grid this tab last left (decision 1: one step back
* after a selection outside it), else an open split as two tiles, else the
* active session as one tile.
*/
toggleTileGrid() {
if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: true, reselect: true });
return;
}
if (!this.canOpenTileGrid()) return;
const remembered = this._tileGridRemembered;
const ids = (remembered?.ids || []).filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id));
if (ids.length > 0) {
this.openTileGrid(ids, { focusedId: remembered.focusedId, auto: true });
return;
}
if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId });
},
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
focusTileInDirection(direction) {
const grid = this._tileGrid;
if (!grid?.open) return;
const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols);
if (id) this.selectSession(id);
},
/** Removes the focused tile (the session keeps running); a neighbour takes focus. */
removeFocusedTile() {
const grid = this._tileGrid;
if (!grid?.open || !grid.focusedId) return;
this.removeTile(grid.focusedId, { refocus: true, auto: true });
},
// The single view after the grid closes: the session the grid was focused on, // The single view after the grid closes: the session the grid was focused on,
// replayed fresh (forceReload drops the stale snapshot and nulls // replayed fresh (forceReload drops the stale snapshot and nulls
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
+30 -7
View File
@@ -1,7 +1,7 @@
/** /**
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real * @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
* app around it: constants.js + app.js + terminal-ui.js + tile-grid.js in one * app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
* context, a small fake DOM (just what the grid touches) and a fake * tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
* TerminalTile that records what the grid asks of it. * TerminalTile that records what the grid asks of it.
* *
* `makeGridApp()` returns an app instance with everything around the grid that * `makeGridApp()` returns an app instance with everything around the grid that
@@ -57,7 +57,8 @@ export class FakeEl {
remove() { remove() {
if (!this.parentElement) return; if (!this.parentElement) return;
const siblings = this.parentElement.children; const siblings = this.parentElement.children;
siblings.splice(siblings.indexOf(this), 1); const i = siblings.indexOf(this);
if (i !== -1) siblings.splice(i, 1);
this.parentElement = null; this.parentElement = null;
} }
setAttribute(k: string, v: string) { setAttribute(k: string, v: string) {
@@ -76,7 +77,15 @@ export class FakeEl {
getBoundingClientRect() { getBoundingClientRect() {
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 }; return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
} }
querySelector() { /** `.class` selectors only: the first descendant carrying that class. */
querySelector(sel: string): FakeEl | null {
if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null;
const cls = sel.slice(1);
for (const child of this.children) {
if (child.classList.contains(cls)) return child;
const deeper = child.querySelector(sel);
if (deeper) return deeper;
}
return null; return null;
} }
} }
@@ -117,6 +126,11 @@ section.id = 'tileGrid';
section.className = 'tile-grid'; section.className = 'tile-grid';
main.appendChild(section); main.appendChild(section);
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
export const bySelector = new Map<string, FakeEl>();
export const body = new FakeEl();
/** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn();
export const localStore = new Map<string, string>(); export const localStore = new Map<string, string>();
/** What the code under test deferred with requestIdleCallback; a test runs them. */ /** What the code under test deferred with requestIdleCallback; a test runs them. */
export const idleCallbacks: Array<() => void> = []; export const idleCallbacks: Array<() => void> = [];
@@ -145,11 +159,13 @@ const context = vm.createContext({
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
history: { replaceState: vi.fn(), state: null }, history: { replaceState: vi.fn(), state: null },
document: { document: {
addEventListener: vi.fn(), addEventListener: documentAddEventListener,
documentElement: { dataset: {} }, documentElement: { dataset: {} },
createElement: () => new FakeEl(), createElement: () => new FakeEl(),
getElementById: (id: string) => (id === 'tileGrid' ? section : null), getElementById: (id: string) => (id === 'tileGrid' ? section : null),
querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), body,
querySelector: (sel: string) =>
sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : (bySelector.get(sel) ?? null),
querySelectorAll: () => [], querySelectorAll: () => [],
}, },
localStorage: { localStorage: {
@@ -162,7 +178,8 @@ const context = vm.createContext({
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
}); });
vm.runInContext( vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
`${read('tile-grid.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;', 'globalThis.__CodemanApp = CodemanApp;',
context context
); );
@@ -237,4 +254,10 @@ export function resetGridHarness() {
windowStub.innerWidth = 2400; windowStub.innerWidth = 2400;
section.children = []; section.children = [];
main.className = 'main'; main.className = 'main';
// A split a test left open moved .terminal-wrap into its container.
main.children = [];
main.appendChild(wrap);
main.appendChild(section);
bySelector.clear();
body.children = [];
} }
+331
View File
@@ -0,0 +1,331 @@
/**
* @fileoverview The tile grid's chords: registry entries, when they apply, and
* that a chord which applies never reaches a PTY.
*
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
* the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is
* unbound. The toggle applies wherever a grid could open; the focus and remove
* chords only while the grid is open, so outside it Alt+Shift+Arrows reach the
* terminal untouched. The capture-phase handler (app.js) dispatches a chord
* that applies; its preventDefault() does not stop xterm, so every xterm key
* handler (the main terminal's and TerminalTile's) returns false for it too,
* for every event type and before the Shift+Enter gate (which would otherwise
* send S-Enter for Alt+Shift+Enter style chords).
*
* Real code: the grid harness (test/mocks/tile-grid-vm.ts) for the registry,
* the capture handler and the actions; a second `vm` context with the real
* TerminalTile for its key handler; the main terminal's handler, which needs a
* real xterm, is pinned at the source. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
documentAddEventListener,
makeGridApp,
resetGridHarness,
windowStub as gridWindow,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const chord = (overrides: Record<string, unknown>) => ({
type: 'keydown',
key: '',
code: '',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
target: { closest: () => null },
...overrides,
});
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
beforeEach(() => {
resetGridHarness();
});
describe('registry', () => {
it('ships the tile chords, rebindable, with Remove Focused Tile unbound', () => {
const app = makeGridApp(IDS);
const byId = Object.fromEntries(app.getShortcutRegistry().map((s: { id: string }) => [s.id, s])) as Record<
string,
{ bindings: Array<Record<string, unknown>>; group: string }
>;
expect(byId['toggle-tile-grid'].bindings).toEqual([{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }]);
for (const dir of ['Left', 'Right', 'Up', 'Down']) {
expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` },
]);
}
expect(byId['remove-tile'].bindings).toEqual([]);
expect(byId['toggle-tile-grid'].group).toBe('Tiles');
});
});
describe('when a chord applies', () => {
it('the toggle applies wherever a grid could open, and while one is open', () => {
const app = makeGridApp(IDS);
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
});
it('the toggle does not apply in a narrow window or a solo window', () => {
const app = makeGridApp(IDS);
gridWindow.innerWidth = 1000;
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
gridWindow.innerWidth = 2400;
app.isSoloWindow = true;
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('the focus chords apply only while the grid is open', () => {
const app = makeGridApp(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
});
it('plain typing and unrelated chords never match', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord({ key: 'g', code: 'KeyG' }))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'ArrowRight', altKey: true }))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'G', code: 'KeyG', ctrlKey: true }))).toBeNull();
});
it('honours a disable and a rebind from App Settings', () => {
const app = makeGridApp(IDS);
app.loadAppSettingsFromStorage = () => ({
shortcutOverrides: {
'toggle-tile-grid': { disabled: true },
'focus-tile-right': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'l', code: 'KeyL' }] },
},
});
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'l', code: 'KeyL', ctrlKey: true, altKey: true }))).toBe(
'focus-tile-right'
);
});
});
describe('the capture-phase handler', () => {
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
if (!keydown) throw new Error('no capture-phase keydown listener');
return keydown[1];
}
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
const onKeydown = handlerFor(app);
const open = chord(TOGGLE);
onKeydown(open);
expect(open.preventDefault).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-a']);
onKeydown(chord(TOGGLE));
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
});
it('Alt+Shift+Right moves focus to the tile on the right, as a human selection', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.markIdleAlertSeen.mockClear();
// The real selectSession routes a tiled id to the tile branch.
delete app.selectSession;
const onKeydown = handlerFor(app);
const e = chord(RIGHT);
onKeydown(e);
expect(e.preventDefault).toHaveBeenCalled();
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
});
it('outside the grid Alt+Shift+Right is left alone for the terminal', () => {
const app = makeGridApp(IDS);
const onKeydown = handlerFor(app);
const e = chord(RIGHT);
onKeydown(e);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(app._tilesOwnTerminal()).toBe(false);
});
});
describe('the actions', () => {
it('the toggle brings back the grid this tab last left, focus included', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(IDS, { focusedId: 's-c' });
app.closeTileGrid({ reselect: false });
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-c');
});
it('a remembered tile whose session is gone is not brought back', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b');
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
});
it('with every remembered session gone, it opens on the active session instead', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-b', 's-c']);
app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b');
app.sessions.delete('s-c');
app.activeSessionId = 's-a';
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a']);
});
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
app._apiDelete = vi.fn();
app.removeFocusedTile();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.sessions.has('s-b')).toBe(true);
expect(app._apiDelete).not.toHaveBeenCalled();
});
});
describe('xterm key handlers swallow a chord that applies', () => {
it("the main terminal's handler checks the chord before the Shift+Enter gate, for every event type", () => {
// That handler lives in initTerminal's closure over a real xterm, so the
// rule is pinned at the source, as terminal-copy-clean.test.ts does.
const src = read('terminal-ui.js');
const handler = src.slice(src.indexOf('this.terminal.attachCustomKeyEventHandler((ev) => {'));
const gate = handler.indexOf('if (this.tileShortcutFor?.(ev)) return false;');
const enter = handler.indexOf("if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {");
expect(gate).toBeGreaterThan(0);
expect(gate).toBeLessThan(enter);
});
describe("TerminalTile's handler (real tile, real tileShortcutFor)", () => {
class FakeTerminal {
static last: FakeTerminal;
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
textarea = { addEventListener() {}, removeEventListener() {} };
constructor(options: Record<string, unknown>) {
this.options = options;
FakeTerminal.last = this;
}
loadAddon() {}
open() {}
onData() {}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
write() {}
clear() {}
dispose() {}
}
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) }));
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
innerWidth: 2400,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: class {
static OPEN = 1;
send() {}
close() {}
},
Terminal: FakeTerminal,
FitAddon: {
FitAddon: class {
fit() {}
proposeDimensions() {
return { cols: 80, rows: 24 };
}
},
},
fetch: fetchMock,
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
`${read('terminal-split.js')}\n${read('tile-grid.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const App = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const TerminalTile = windowStub.TerminalTile as new (
id: string,
mount: unknown,
opts: object
) => { connect(): Promise<void>; destroy(): void };
async function tileHandler(gridOpen: boolean) {
const app = Object.create(App.prototype) as GridApp;
app.loadAppSettingsFromStorage = () => ({});
app.isSoloWindow = false;
app._tileGrid = { open: gridOpen };
windowStub.app = app;
const tile = new TerminalTile('s1', { addEventListener() {}, removeEventListener() {} }, { mode: 'claude' });
await tile.connect();
const handler = FakeTerminal.last.keyHandler!;
tile.destroy();
return handler;
}
it.each(['keydown', 'keypress', 'keyup'])(
'returns false for Alt+Shift+Right (%s) while the grid is open',
async (type) => {
const handler = await tileHandler(true);
expect(handler({ ...chord(RIGHT), type })).toBe(false);
}
);
it('lets Alt+Shift+Right through to the PTY when no grid is open', async () => {
const handler = await tileHandler(false);
expect(handler(chord(RIGHT))).toBe(true);
});
it('returns false for the toggle chord', async () => {
const handler = await tileHandler(true);
expect(handler(chord(TOGGLE))).toBe(false);
});
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @fileoverview The tile grid and the split pane are never open together.
*
* Both run on TerminalTile, and the split's wrappers (`selectSession`,
* `_onSessionDeleted` in terminal-split.js) key on `this._splitPane`, so they
* would fight the grid over the same terminal area if both were ever up:
*
* - opening the grid while a split is open closes the split first and seeds
* the grid with both of its sessions, Pane A focused and Pane B beside it;
* - while the grid is open, `openSplitPicker` and `openSplitPane` refuse, and
* the Split button says so (`aria-disabled`, a title);
* - closing the grid never reopens a split;
* - the split's wrappers stay inert while the grid is open.
*
* Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
* tile-grid.js (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
bySelector,
main,
makeGridApp,
resetGridHarness,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
function makeSplitButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-split';
bySelector.set('.btn-split', btn);
return btn;
}
/** s-a in the main pane, s-b in Pane B (the split's real openSplitPane). */
function openSplit(app: GridApp) {
app.openSplitPane('s-b');
expect(app._splitSessionId).toBe('s-b');
// closeSplitPane finds its container by selector.
const container = main.querySelector('.terminal-split-container');
if (container) bySelector.set('.terminal-split-container', container);
return FakeTile.all.at(-1) as FakeTile;
}
beforeEach(() => {
resetGridHarness();
});
describe('opening the grid over an open split', () => {
it('closes the split and seeds the grid with both of its sessions, Pane A focused', () => {
const app = makeGridApp(IDS);
const paneB = openSplit(app);
app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
// Pane A is about to park: no closing resize for it.
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
});
it('an explicit open over a split keeps both split sessions first', () => {
const app = makeGridApp(IDS);
openSplit(app);
app.openTileGrid(['s-c']);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-a');
});
});
describe('while the grid is open', () => {
it('the split refuses to open, from the picker or directly', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
const before = FakeTile.all.length;
app.openSplitPicker({ stopPropagation: vi.fn() });
app.openSplitPane('s-b');
expect(app._splitPane ?? null).toBeNull();
expect(FakeTile.all.length).toBe(before);
// The picker itself never opened either.
expect(body.children).toHaveLength(0);
expect(app._splitPickerDismissHandlers ?? null).toBeNull();
});
it('the Split button is marked unavailable, and back to normal once the grid closes', () => {
const app = makeGridApp(IDS);
const btn = makeSplitButton();
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-disabled')).toBe('true');
expect(btn.classList.contains('btn-split--blocked')).toBe(true);
app.closeTileGrid({ reselect: false });
expect(btn.getAttribute('aria-disabled')).toBe('false');
expect(btn.classList.contains('btn-split--blocked')).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
});
it("the split's wrappers do nothing (no split to close or promote)", () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.closeSplitPane = vi.fn();
app._onSessionDeleted({ id: 's-c' });
expect(app.closeSplitPane).not.toHaveBeenCalled();
});
});
describe('closing the grid', () => {
it('never reopens the split it replaced', () => {
const app = makeGridApp(IDS);
openSplit(app);
app.toggleTileGrid();
app.closeTileGrid({ reselect: false });
expect(app._splitPane).toBeNull();
expect(app._tilesOwnTerminal()).toBe(false);
});
});