From 526d396492b51b1ebe13159f4913073b6281a3ec Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 15:58:20 +0200 Subject: [PATCH 01/90] refactor(select): extract the deferred panel refresh into _refreshSessionPanels The block selectSession runs in an idle callback once the terminal content is on screen (respawn banner and countdown, action log, task panel, Ralph state, CLI info, project insights, subagent window visibility, file browser) moves verbatim into its own method. The tile grid's focus change needs the same refresh without the rest of selectSession, and one copy keeps the two from drifting. No behavior change: the stale-generation guard moves with it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 157 +++++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 72 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 96978897..d41eeed8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -8513,78 +8513,7 @@ class CodemanApp { // Defer secondary panel updates so they don't block the main thread // after terminal content is already visible. const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); - idleCb(() => { - // Guard against stale generation — user may have switched tabs again - if (selectGen !== this._selectGeneration) return; - - // Update respawn banner - if (this.respawnStatus[sessionId]) { - this.showRespawnBanner(); - this.updateRespawnBanner(this.respawnStatus[sessionId].state); - document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; - this.updateCountdownTimerDisplay(); - this.updateActionLogDisplay(); - if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { - this.startCountdownInterval(); - } - } else { - this.hideRespawnBanner(); - this.stopCountdownInterval(); - } - - // Update task panel if open - const taskPanel = document.getElementById('taskPanel'); - if (taskPanel && taskPanel.classList.contains('open')) { - this.renderTaskPanel(); - } - - // Update ralph state panel for this session - const curSession = this.sessions.get(sessionId); - if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) { - this.updateRalphState(sessionId, { - loop: curSession.ralphLoop, - todos: curSession.ralphTodos - }); - } - this.renderRalphStatePanel(); - - // Update CLI info bar (mobile - shows Claude version/model) - this.updateCliInfoDisplay(); - - // Update project insights panel for this session - this.renderProjectInsightsPanel(); - - // Update subagent window visibility for active session - this.updateSubagentWindowVisibility(); - - // Load file browser if enabled - const settings = this.loadAppSettingsFromStorage(); - if (settings.showFileBrowser) { - const fileBrowserPanel = this.$('fileBrowserPanel'); - if (fileBrowserPanel) { - fileBrowserPanel.classList.add('visible'); - this.loadFileBrowser(sessionId); - // Attach drag listeners if not already attached - if (!this.fileBrowserDragListeners) { - const header = fileBrowserPanel.querySelector('.file-browser-header'); - if (header) { - const onFirstDrag = () => { - if (!fileBrowserPanel.style.left) { - const rect = fileBrowserPanel.getBoundingClientRect(); - fileBrowserPanel.style.left = `${rect.left}px`; - fileBrowserPanel.style.top = `${rect.top}px`; - fileBrowserPanel.style.right = 'auto'; - } - }; - header.addEventListener('mousedown', onFirstDrag); - header.addEventListener('touchstart', onFirstDrag, { passive: true }); - this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header); - this.fileBrowserDragListeners._onFirstDrag = onFirstDrag; - } - } - } - } - }); + idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); // Open WebSocket for low-latency terminal I/O (after buffer load completes) this._connectWs(sessionId); @@ -8707,6 +8636,90 @@ class CodemanApp { } } + /** + * The panels that follow the active session (respawn banner and countdown, + * action log, task panel, Ralph state, CLI info, project insights, subagent + * window visibility, file browser). Run deferred, after the terminal content + * is on screen, by selectSession and by the tile grid's focus change + * (tile-grid.js _selectTiledSession), so both share one copy. + * + * @param {string} sessionId - the session that just became active + * @param {number} selectGen - the `_selectGeneration` of that selection; a + * newer one (the user switched again) makes this a no-op + */ + _refreshSessionPanels(sessionId, selectGen) { + // A newer selection won: the user switched tabs again. + if (selectGen !== this._selectGeneration) return; + + // Update respawn banner + if (this.respawnStatus[sessionId]) { + this.showRespawnBanner(); + this.updateRespawnBanner(this.respawnStatus[sessionId].state); + document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; + this.updateCountdownTimerDisplay(); + this.updateActionLogDisplay(); + if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) { + this.startCountdownInterval(); + } + } else { + this.hideRespawnBanner(); + this.stopCountdownInterval(); + } + + // Update task panel if open + const taskPanel = document.getElementById('taskPanel'); + if (taskPanel && taskPanel.classList.contains('open')) { + this.renderTaskPanel(); + } + + // Update ralph state panel for this session + const curSession = this.sessions.get(sessionId); + if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) { + this.updateRalphState(sessionId, { + loop: curSession.ralphLoop, + todos: curSession.ralphTodos + }); + } + this.renderRalphStatePanel(); + + // Update CLI info bar (mobile - shows Claude version/model) + this.updateCliInfoDisplay(); + + // Update project insights panel for this session + this.renderProjectInsightsPanel(); + + // Update subagent window visibility for active session + this.updateSubagentWindowVisibility(); + + // Load file browser if enabled + const settings = this.loadAppSettingsFromStorage(); + if (settings.showFileBrowser) { + const fileBrowserPanel = this.$('fileBrowserPanel'); + if (fileBrowserPanel) { + fileBrowserPanel.classList.add('visible'); + this.loadFileBrowser(sessionId); + // Attach drag listeners if not already attached + if (!this.fileBrowserDragListeners) { + const header = fileBrowserPanel.querySelector('.file-browser-header'); + if (header) { + const onFirstDrag = () => { + if (!fileBrowserPanel.style.left) { + const rect = fileBrowserPanel.getBoundingClientRect(); + fileBrowserPanel.style.left = `${rect.left}px`; + fileBrowserPanel.style.top = `${rect.top}px`; + fileBrowserPanel.style.right = 'auto'; + } + }; + header.addEventListener('mousedown', onFirstDrag); + header.addEventListener('touchstart', onFirstDrag, { passive: true }); + this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header); + this.fileBrowserDragListeners._onFirstDrag = onFirstDrag; + } + } + } + } + } + // Shared cleanup for all session data — called from both closeSession() and session:deleted handler _cleanupSessionData(sessionId) { this.closeTabRailActionMenu?.(); From 59eb509d4776f6a3a87e830b1975f09befa8389e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:00:35 +0200 Subject: [PATCH 02/90] feat(tiles): pure layout and state helpers for the tile grid window.CodemanTileGrid (constants.js), the grid's pure half: - computeTileLayout: columns x rows by tile count (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, 3x3), capped at 9, and whether every cell clears the minimum tile size (480x240). - tileGridCapacity: how many tiles a grid area can hold. - sanitizeTileGridState: a stored grid (ids only) made safe to apply; unknown, deleted, detached and duplicate ids are dropped, focus and zoom must name a kept tile, track fractions must be sane. - tileNeighbor / tileInDirection / cycleTile: which tile takes focus when one leaves, on a directional chord, and on Ctrl+Tab or Alt+[ ]. - TILE_SCROLLBACK (10,000 lines, not the primary pane's 50,000) and the tile font default. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 180 +++++++++++++++++++++++++++++++ test/tile-grid-layout.test.ts | 195 ++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 test/tile-grid-layout.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index fb5ecbea..efcb996f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1633,6 +1633,173 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds return result; } +// ── Tile grid (tile-grid.js) ─────────────────────────────────────────────── +// +// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md): +// 1 to 9 live sessions side by side, each in its own TerminalTile. Desktop +// only, behind the same 1180px gate as the split pane. + +/** Hard cap on tiles in one grid. */ +const TILE_GRID_MAX = 9; +// The smallest tile worth showing: about 60 columns and a dozen rows at the +// default tile font. Bounds how many tiles a window can hold. +const TILE_MIN_W = 480; +const TILE_MIN_H = 240; +// Three tiles go side by side (3x1) only when each still gets ~600px; +// otherwise they take three cells of a 2x2. +const TILE_GRID_WIDE_3X1 = 1800; +// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: nine DOM +// renderers at 50k lines each is a real memory cost, and a tile's load is a +// bounded 1 MiB window anyway, so more scrollback only fills with live output. +const TILE_SCROLLBACK = 10000; +// Tiles have their own per-device font size (a tile is a fraction of the screen). +const TILE_FONT_SIZE_DEFAULT = 13; + +/** + * Columns and rows for `count` tiles, by count (the spec's table), and whether + * that layout gives every cell at least the minimum tile size in a grid area + * of `width` x `height` px. + * + * @param {{count: number, width?: number, height?: number, minTileW?: number, minTileH?: number}} p + * @returns {{cols: number, rows: number, fits: boolean}} + */ +function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { + const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_GRID_MAX); + let cols; + let rows; + if (n === 0) return { cols: 0, rows: 0, fits: true }; + if (n === 1) { cols = 1; rows = 1; } + else if (n === 2) { cols = 2; rows = 1; } + else if (n === 3) { + if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; } + else { cols = 2; rows = 2; } + } + else if (n === 4) { cols = 2; rows = 2; } + else if (n <= 6) { cols = 3; rows = 2; } + else { cols = 3; rows = 3; } + const fits = width / cols >= minTileW && height / rows >= minTileH; + return { cols, rows, fits }; +} + +/** + * How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX + * whose layout, and every smaller count's layout, fits. 0 when not even one + * tile fits. + * + * @param {{width: number, height: number, minTileW?: number, minTileH?: number}} p + * @returns {number} + */ +function tileGridCapacity({ width, height, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { + let capacity = 0; + for (let n = 1; n <= TILE_GRID_MAX; n++) { + if (!computeTileLayout({ count: n, width, height, minTileW, minTileH }).fits) break; + capacity = n; + } + return capacity; +} + +/** + * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown, + * deleted, detached and duplicate ids are dropped, the list is capped at + * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions + * must be 1 to 3 finite positive numbers. Anything that is not a v1 object + * (or its JSON) gives null. + * + * @param {unknown} raw - the parsed value, or the stored JSON string + * @param {{has(id: string): boolean}|Iterable} liveSessions - ids that exist now + * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window + * @returns {{v: 1, open: boolean, ids: string[], focused: string|null, zoomed: string|null, + * colFr: number[]|null, rowFr: number[]|null}|null} + */ +function sanitizeTileGridState(raw, liveSessions, detachedIds) { + let value = raw; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { return null; } + } + if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null; + const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []); + const ids = []; + for (const id of Array.isArray(value.ids) ? value.ids : []) { + if (typeof id !== 'string' || !id || ids.includes(id)) continue; + if (!live.has(id)) continue; + if (detachedIds?.has?.(id)) continue; + ids.push(id); + if (ids.length === TILE_GRID_MAX) break; + } + const fractions = (fr) => { + if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null; + return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null; + }; + return { + v: 1, + open: value.open === true && ids.length > 0, + ids, + focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null), + zoomed: ids.includes(value.zoomed) ? value.zoomed : null, + colFr: fractions(value.colFr), + rowFr: fractions(value.rowFr), + }; +} + +/** + * Which tile takes focus when `id` leaves the grid: the next one in grid + * order, else the previous one, else null. + * + * @param {string[]} ids - the grid's tiles, in reading order + * @param {string} id - the tile that is leaving + * @returns {string|null} + */ +function tileNeighbor(ids, id) { + const i = ids.indexOf(id); + if (i === -1) return ids[0] ?? null; + return ids[i + 1] ?? ids[i - 1] ?? null; +} + +/** + * The tile a directional focus chord moves to, in a row-major grid of `cols` + * columns. Left and right stay within the row; up and down move a whole row, + * and moving down onto a short last row lands on its last tile. Null when + * there is nothing in that direction. + * + * @param {string[]} ids - the grid's tiles, in reading order + * @param {string} focusedId - the tile the keyboard is in + * @param {'left'|'right'|'up'|'down'} direction + * @param {number} cols - the layout's column count + * @returns {string|null} + */ +function tileInDirection(ids, focusedId, direction, cols) { + const n = ids.length; + const i = ids.indexOf(focusedId); + if (i === -1 || n === 0 || !(cols >= 1)) return null; + const col = i % cols; + let j = -1; + if (direction === 'left') j = col > 0 ? i - 1 : -1; + else if (direction === 'right') j = col < cols - 1 && i + 1 < n ? i + 1 : -1; + else if (direction === 'up') j = i - cols; + else if (direction === 'down') { + j = i + cols; + const lastRow = Math.floor((n - 1) / cols); + if (j >= n && Math.floor(i / cols) < lastRow) j = n - 1; + } + return j >= 0 && j < n && j !== i ? ids[j] : null; +} + +/** + * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the + * grid is open: tiles cycle in reading order and wrap. + * + * @param {string[]} ids + * @param {string} focusedId + * @param {number} delta - +1 or -1 + * @returns {string|null} + */ +function cycleTile(ids, focusedId, delta) { + if (ids.length === 0) return null; + const i = ids.indexOf(focusedId); + if (i === -1) return ids[0]; + return ids[(i + delta + ids.length) % ids.length]; +} + // ── Renderer liveness ────────────────────────────────────────────────────── // // iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to @@ -1879,6 +2046,19 @@ if (typeof window !== 'undefined') { buildSplitPickerSessions, SPLIT_PANE_MIN_WIDTH, }; + window.CodemanTileGrid = { + computeTileLayout, + tileGridCapacity, + sanitizeTileGridState, + tileNeighbor, + tileInDirection, + cycleTile, + TILE_GRID_MAX, + TILE_MIN_W, + TILE_MIN_H, + TILE_SCROLLBACK, + TILE_FONT_SIZE_DEFAULT, + }; window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS }; window.CodemanFetchDeadline = { terminalFetchDeadlineMs, diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts new file mode 100644 index 00000000..d8fbad1a --- /dev/null +++ b/test/tile-grid-layout.test.ts @@ -0,0 +1,195 @@ +/** + * @fileoverview The tile grid's pure helpers (constants.js, `window.CodemanTileGrid`). + * + * - `computeTileLayout`: columns x rows by tile count (the spec's table), with + * the 3-tile special case (3x1 only on a wide grid area) and whether every + * cell clears the minimum tile size. + * - `tileGridCapacity`: how many tiles a grid area can hold. + * - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to + * apply (unknown, deleted, detached and duplicate ids dropped). + * - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when + * one leaves, on a directional chord, and on Ctrl+Tab / Alt+[ ]. + * + * Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +type Layout = { cols: number; rows: number; fits: boolean }; +type TileGrid = { + computeTileLayout(p: Record): Layout; + tileGridCapacity(p: Record): number; + sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set): Record | null; + tileNeighbor(ids: string[], id: string): string | null; + tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null; + cycleTile(ids: string[], focused: string, delta: number): string | null; + TILE_GRID_MAX: number; + TILE_MIN_W: number; + TILE_MIN_H: number; + TILE_SCROLLBACK: number; +}; + +function loadTileGrid(): TileGrid { + 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 { CodemanTileGrid: TileGrid }).CodemanTileGrid; +} + +const T = loadTileGrid(); +// Plenty of room: every layout fits. +const BIG = { width: 3000, height: 2000 }; + +describe('computeTileLayout', () => { + it.each([ + [1, 1, 1], + [2, 2, 1], + [4, 2, 2], + [5, 3, 2], + [6, 3, 2], + [7, 3, 3], + [8, 3, 3], + [9, 3, 3], + ])('%i tiles lay out as %ix%i', (count, cols, rows) => { + expect(T.computeTileLayout({ count, ...BIG })).toMatchObject({ cols, rows, fits: true }); + }); + + it('puts 3 tiles side by side only on a grid area at least 1800px wide', () => { + expect(T.computeTileLayout({ count: 3, width: 1800, height: 900 })).toMatchObject({ cols: 3, rows: 1 }); + expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 }); + }); + + it('caps the count at 9 and treats nothing as an empty grid', () => { + expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 }); + expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 }); + }); + + it('reports whether every cell clears the minimum tile size', () => { + // 3x2 needs 3 * 480 = 1440 wide and 2 * 240 = 480 high. + expect(T.computeTileLayout({ count: 6, width: 1440, height: 480 }).fits).toBe(true); + expect(T.computeTileLayout({ count: 6, width: 1439, height: 480 }).fits).toBe(false); + expect(T.computeTileLayout({ count: 6, width: 1440, height: 479 }).fits).toBe(false); + }); +}); + +describe('tileGridCapacity', () => { + it('holds all nine on a large monitor', () => { + expect(T.tileGridCapacity(BIG)).toBe(T.TILE_GRID_MAX); + }); + + it('stops at the first count whose layout does not fit', () => { + // 1440 x 600: 3x2 fits (480 x 300) but 3x3 (480 x 200) does not. + expect(T.tileGridCapacity({ width: 1440, height: 600 })).toBe(6); + // 1200 x 900: 2x2 fits (600 x 450), 3x2 does not (400 wide). + expect(T.tileGridCapacity({ width: 1200, height: 900 })).toBe(4); + // 1000 x 400: 2x1 fits (500 x 400); three tiles take a 2x2 (200 high), which does not. + expect(T.tileGridCapacity({ width: 1000, height: 400 })).toBe(2); + }); + + it('is 0 when not even one tile fits', () => { + expect(T.tileGridCapacity({ width: 400, height: 900 })).toBe(0); + }); +}); + +describe('sanitizeTileGridState', () => { + const live = new Map([ + ['a', {}], + ['b', {}], + ['c', {}], + ['d', {}], + ]); + + it('keeps a valid stored grid as it is', () => { + const raw = { v: 1, open: true, ids: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], rowFr: [1] }; + expect(T.sanitizeTileGridState(raw, live, new Set())).toEqual({ + v: 1, + open: true, + ids: ['a', 'b'], + focused: 'b', + zoomed: 'a', + colFr: [1, 2], + rowFr: [1], + }); + }); + + it('accepts the stored JSON string', () => { + const raw = JSON.stringify({ v: 1, open: true, ids: ['c'], focused: 'c' }); + expect(T.sanitizeTileGridState(raw, live)?.ids).toEqual(['c']); + }); + + it('drops unknown (deleted), detached and duplicate ids', () => { + const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 'c', 7, ''], focused: 'a' }; + const out = T.sanitizeTileGridState(raw, live, new Set(['b'])); + expect(out?.ids).toEqual(['a', 'c']); + }); + + it('moves focus to the first kept tile when the focused one was dropped, and drops a dropped zoom', () => { + const raw = { v: 1, open: true, ids: ['gone', 'b', 'c'], focused: 'gone', zoomed: 'gone' }; + const out = T.sanitizeTileGridState(raw, live); + expect(out?.focused).toBe('b'); + expect(out?.zoomed).toBeNull(); + }); + + it('is closed when no tile survives', () => { + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['gone'] }, live); + expect(out).toMatchObject({ open: false, ids: [], focused: null }); + }); + + it('caps the list at nine tiles', () => { + const many = Array.from({ length: 12 }, (_, i) => `s${i}`); + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many); + expect(out?.ids).toEqual(many.slice(0, 9)); + }); + + it('drops malformed track fractions', () => { + const out = T.sanitizeTileGridState( + { v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, + live + ); + expect(out?.colFr).toBeNull(); + expect(out?.rowFr).toBeNull(); + }); + + it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => { + expect(T.sanitizeTileGridState(raw, live)).toBeNull(); + }); +}); + +describe('focus helpers', () => { + it('tileNeighbor prefers the next tile, then the previous one', () => { + expect(T.tileNeighbor(['a', 'b', 'c'], 'b')).toBe('c'); + expect(T.tileNeighbor(['a', 'b', 'c'], 'c')).toBe('b'); + expect(T.tileNeighbor(['a'], 'a')).toBeNull(); + }); + + it('tileInDirection moves within a row-major grid', () => { + // 3x2: a b c + // d e + const ids = ['a', 'b', 'c', 'd', 'e']; + expect(T.tileInDirection(ids, 'b', 'left', 3)).toBe('a'); + expect(T.tileInDirection(ids, 'a', 'left', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'b', 'right', 3)).toBe('c'); + expect(T.tileInDirection(ids, 'c', 'right', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'e', 'right', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'd', 'up', 3)).toBe('a'); + expect(T.tileInDirection(ids, 'a', 'up', 3)).toBeNull(); + expect(T.tileInDirection(ids, 'b', 'down', 3)).toBe('e'); + // Nothing below c in that column: the short last row's last tile. + expect(T.tileInDirection(ids, 'c', 'down', 3)).toBe('e'); + expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull(); + }); + + it('cycleTile wraps in reading order', () => { + expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a'); + expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c'); + expect(T.cycleTile([], 'a', 1)).toBeNull(); + }); +}); + +describe('tile constants', () => { + it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => { + expect(T.TILE_SCROLLBACK).toBe(10000); + }); +}); From fca7acd05f06182e32c2d16f64eb26f033c5c4e2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:05:56 +0200 Subject: [PATCH 03/90] feat(tiles): one load queue for every capture a grid tile fetches GET /api/sessions/:id/terminal runs synchronous tmux calls on the server, so N tiles loading at once would stall every WebSocket and SSE stream back to back (and after a deploy restart all N reopen within the same second). TerminalTile takes the options PR 1 deferred to the grid: - scheduleLoad(tile, kind, run): every capture the tile fetches (initial load, reconnect refresh, server {t:'r'} refresh, shell history pull) runs when its owner says so. Absent (the split's Pane B), a load runs at once. - scrollback (the grid passes TILE_SCROLLBACK) and fontSize. - boundedLoad: a TUI tile loads the bounded full=1&tail= window, never its whole history. TileLoadQueue (terminal-tile.js, DOM-free) is that one queue: concurrency 1, a history pull ahead of background refreshes, then the owner's rank (the grid ranks the focused tile first, then reading order). A destroyed tile's waiting loads are dropped unrun, and destroy() aborts the running fetch so the queue moves on. Also, for Pane B as well: the load now has a deadline covering the body (CodemanFetchDeadline), so a capture that never answers cannot hold the single-flight flag (or the queue) forever; a refresh clears the screen at its turn rather than when it is asked for; and a close while a load only waits in the queue writes the disconnected marker at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/terminal-tile.js | 249 ++++++++++++++-- test/terminal-tile-unit.test.ts | 5 +- test/tile-grid-load-queue.test.ts | 478 ++++++++++++++++++++++++++++++ 3 files changed, 706 insertions(+), 26 deletions(-) create mode 100644 test/tile-grid-load-queue.test.ts diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index f42adea9..6061a3f7 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -4,8 +4,10 @@ * @fileoverview TerminalTile: one independent live terminal pane bound to one * session, with its own xterm instance and its own * `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js) - * uses one as its second pane ("Pane B"); the tile grid planned in - * docs/tile-grid-plan.md reuses the same class for every tile. + * uses one as its second pane ("Pane B"); the tile grid (tile-grid.js, + * docs/tile-grid-plan.md) uses one per tile, and feeds every capture they + * fetch through ONE TileLoadQueue (below), because each capture is a + * synchronous tmux call that blocks the server's event loop. * * Deliberately plainer than the primary pane (this.terminal/this._ws in * terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile @@ -13,7 +15,7 @@ * docs/split-pane-sessions-plan.md. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js - * @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) + * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -72,6 +74,20 @@ // seen by _sendResize() below, or it re-creates the exact PTY-size // fight the split picker already refuses to open at pick time. this.detachedSessions = opts.detachedSessions; + // Lines of scrollback this pane's xterm keeps (the grid passes its smaller + // TILE_SCROLLBACK) and its font size (the grid's own tile font); absent, + // the primary pane's values. + this.scrollback = Number.isFinite(opts.scrollback) ? opts.scrollback : null; + this.fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : null; + // `scheduleLoad(tile, kind, run)` runs every capture this pane fetches + // (`kind`: 'initial', 'refresh' or 'history') when its owner says so, and + // resolves once `run` has finished or was dropped. The grid passes its one + // queue so N tiles never fetch at once; absent, a load runs straight away. + this._scheduleLoad = typeof opts.scheduleLoad === 'function' ? opts.scheduleLoad : null; + // Loads a BOUNDED window (`full=1&tail=` for a TUI, `tail=` for a shell) + // instead of a TUI's whole history. Grid tiles do; full history is one + // "leave the grid" away in the primary pane. + this.boundedLoad = opts.boundedLoad === true; this.terminal = null; this.fitAddon = null; this.ws = null; @@ -81,6 +97,12 @@ // Single-flight state for _loadBuffer()/_refreshBuffer() below. this._bufferLoading = false; this._bufferRefreshPending = false; + // True only while a load's work runs, not while it waits in the owner's + // queue (see _runLoad): a close during the wait writes its marker at once. + this._loadRunning = false; + // Aborts the running load's fetch; destroy() uses it so a removed tile + // does not hold the owner's queue for a whole deadline. + this._loadAbort = null; // Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory(). // `_liveQueue` is non-null from the pull's response until its finally // block: live frames are held there with their arrival time instead of @@ -119,7 +141,7 @@ } async connect() { - const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10); + const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10); this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily), @@ -129,7 +151,7 @@ cursorBlink: false, cursorStyle: 'block', minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1, - scrollback: DEFAULT_SCROLLBACK, + scrollback: this.scrollback ?? DEFAULT_SCROLLBACK, allowTransparency: true, allowProposedApi: true, }); @@ -453,7 +475,7 @@ const code = event?.code; const permanent = TerminalTile.STOP_MARKERS[code]; this._markerText = permanent || TerminalTile.MARKER_RECONNECTING; - if (this._bufferLoading) this._markerOwed = true; + if (this._loadRunning) this._markerOwed = true; else this._writeDisconnectedMarker(); if (this._destroyed) return; if (permanent) { @@ -571,21 +593,82 @@ // interleave their chunks into one terminal. A second call while one is // in flight is dropped here; _refreshBuffer() is the caller that queues // a trailing re-run instead. - async _loadBuffer() { + async _loadBuffer({ refresh = false } = {}) { if (this._bufferLoading) return; this._bufferLoading = true; - try { - const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; - const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`); - const payload = (await res.json())?.data ?? {}; - if (payload.terminalBuffer && this.terminal) { - await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); + await this._runLoad(refresh ? 'refresh' : 'initial', async () => { + this._loadRunning = true; + try { + if (this._destroyed) return; + if (refresh) { + // Cleared at the load's turn, not when it was asked for: a grid tile + // waiting in the queue keeps its last frame instead of sitting blank. + this.terminal?.clear(); + // The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue + // a trailing refresh behind a pull that the socket's close then + // interrupts), so a refresh on a closed socket owes it back once its + // replay is written. + if (this._wsClosed) this._markerOwed = true; + } + const shell = this.sessionMode === 'shell'; + let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; + if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}`; + // A deadline covering the body as well as the headers (the primary + // pane's budgets, CodemanFetchDeadline): a capture that never answers + // would otherwise hold this pane's single-flight flag, and in the grid + // the one load queue every tile waits behind, forever. + const controller = global.AbortController ? new global.AbortController() : null; + this._loadAbort = controller; + const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000; + const timer = controller ? setTimeout(() => controller.abort(), budget) : null; + let payload; + try { + const res = await fetch( + `/api/sessions/${this.sessionId}/terminal?${query}`, + controller ? { signal: controller.signal } : undefined + ); + payload = (await res.json())?.data ?? {}; + } finally { + clearTimeout(timer); + this._loadAbort = null; + } + if (payload.terminalBuffer && this.terminal) { + await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed); + } + } catch { + /* Best-effort: live output still arrives once the socket connects. */ + } finally { + this._loadRunning = false; + this._stampMarkerIfOwed(); + this._endBufferLoad(); } + }); + } + + // Runs a load's work now, or when the owner's queue gives this pane its turn + // (`scheduleLoad`). The single-flight flag is already set by the caller, so a + // load waiting in the queue still coalesces refreshes and blocks a second + // pull; the work itself sets `_loadRunning`. A load the queue drops (this + // pane was destroyed while it waited) never runs, so its flags are released + // here. + async _runLoad(kind, work) { + let ran = false; + const run = () => { + ran = true; + return work(); + }; + if (!this._scheduleLoad) { + await run(); + return; + } + try { + await this._scheduleLoad(this, kind, run); } catch { - /* Best-effort — live output still arrives once the socket connects. */ - } finally { - this._stampMarkerIfOwed(); - this._endBufferLoad(); + /* The queue never rejects; a load that failed already settled itself. */ + } + if (!ran) { + this._bufferLoading = false; + this._bufferRefreshPending = false; } } @@ -656,7 +739,8 @@ const now = Date.now(); if (now - this._historyPullAt < cooldown) return; this._historyPullAt = now; - void this._pullHistory(); + this._bufferLoading = true; + void this._runLoad('history', () => this._pullHistory()); } // Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE @@ -665,6 +749,11 @@ // single-flight flag across the fetch AND the replay, like _loadBuffer(). async _pullHistory() { this._bufferLoading = true; + if (this._destroyed) { + this._endBufferLoad(); + return; + } + this._loadRunning = true; let replayed = false; let capturedAt = 0; // Two budgets on one signal. The request itself gets the primary pane's @@ -678,6 +767,7 @@ // be re-armed, hence the controller; without AbortController the pull // simply has no deadline. const controller = global.AbortController ? new global.AbortController() : null; + this._loadAbort = controller; let abortTimer = null; const armDeadline = (ms) => { if (!controller) return; @@ -743,6 +833,8 @@ /* Best-effort — live output keeps arriving whatever happens here. */ } finally { clearTimeout(abortTimer); + this._loadAbort = null; + this._loadRunning = false; const queued = this._liveQueue ?? []; this._liveQueue = null; // After a replay, only frames that arrived after the capture are news; @@ -776,12 +868,7 @@ this._bufferRefreshPending = true; return; } - this.terminal?.clear(); - // The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a - // trailing refresh behind a pull that the socket's close then interrupts), - // so a refresh on a closed socket owes it back once its replay is written. - if (this._wsClosed) this._markerOwed = true; - void this._loadBuffer(); + void this._loadBuffer({ refresh: true }); } // Local reflow only — no PTY resize frame. Split out so a divider drag @@ -851,6 +938,14 @@ // keystroke loses nothing. clearTimeout(this._reconnectTimer); this._reconnectTimer = null; + // A load still fetching would otherwise hold the owner's queue (and the + // server's attention) for a pane nobody can see any more. + try { + this._loadAbort?.abort(); + } catch { + /* Already settled. */ + } + this._loadAbort = null; if (this._onWheel) { this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true }); this._onWheel = null; @@ -883,5 +978,111 @@ 4010: '[disconnected: another connection took over this pane]', }; + /** + * ONE queue for every capture a set of tiles fetches (`GET + * /api/sessions/:id/terminal`): the initial load, the refresh after a + * reconnect, a server `{t:'r'}` refresh and the shell history pull. Each + * capture runs synchronous tmux calls on the server, so N of them at once do + * not run in parallel, they stall every WebSocket and SSE stream on it back to + * back. After a deploy restart all N tiles reopen within the same second; this + * drains their refreshes one at a time. + * + * Concurrency 1. Next up is a history pull (the user is waiting on it), then + * the lowest `rank(tile)` (the grid ranks the focused tile first, then reading + * order), then arrival order. A destroyed tile's entries are dropped, never run. + * DOM-free, so the grid owns the policy and tests drive it directly. + */ + class TileLoadQueue { + /** + * @param {{rank?: (tile: object) => number, onChange?: (tile: object, state: 'queued'|'running'|'idle') => void}} [opts] + */ + constructor(opts = {}) { + this._rank = typeof opts.rank === 'function' ? opts.rank : () => 0; + this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null; + this._pending = []; + this._active = null; + this._seq = 0; + } + + /** The `scheduleLoad` a TerminalTile takes. Resolves once `run` finished or was dropped; never rejects. */ + schedule(tile, kind, run) { + return new Promise((resolve) => { + this._pending.push({ tile, kind, run, resolve, seq: this._seq++ }); + this._notify(tile, 'queued'); + this._pump(); + }); + } + + /** Drops every load still waiting for `tile` (the running one, if any, finishes on its own). */ + drop(tile) { + const keep = []; + for (const entry of this._pending) { + if (entry.tile === tile) entry.resolve(); + else keep.push(entry); + } + this._pending = keep; + if (this._active?.tile !== tile) this._notify(tile, 'idle'); + } + + /** How many loads are waiting (not counting the running one). */ + get size() { + return this._pending.length; + } + + /** The tile whose load is running, or null. */ + get activeTile() { + return this._active?.tile ?? null; + } + + _notify(tile, state) { + try { + this._onChange?.(tile, state); + } catch { + /* A display callback never stops the queue. */ + } + } + + _takeNext() { + let best = -1; + let bestKey = null; + for (let i = 0; i < this._pending.length; i++) { + const entry = this._pending[i]; + if (entry.tile?._destroyed) continue; + const key = [entry.kind === 'history' ? 0 : 1, this._rank(entry.tile), entry.seq]; + const order = bestKey ? key[0] - bestKey[0] || key[1] - bestKey[1] || key[2] - bestKey[2] : -1; + if (order < 0) { + best = i; + bestKey = key; + } + } + // Destroyed tiles' entries go now, resolved but never run. + const dropped = this._pending.filter((entry) => entry.tile?._destroyed); + const next = best === -1 ? null : this._pending[best]; + this._pending = this._pending.filter((entry) => entry !== next && !entry.tile?._destroyed); + for (const entry of dropped) entry.resolve(); + return next; + } + + async _pump() { + if (this._active) return; + const entry = this._takeNext(); + if (!entry) return; + this._active = entry; + this._notify(entry.tile, 'running'); + try { + await entry.run(); + } catch { + /* A load settles its own failure; the queue only moves on. */ + } finally { + this._active = null; + const stillQueued = this._pending.some((e) => e.tile === entry.tile); + this._notify(entry.tile, stillQueued ? 'queued' : 'idle'); + entry.resolve(); + void this._pump(); + } + } + } + global.TerminalTile = TerminalTile; + global.TileLoadQueue = TileLoadQueue; })(window); diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index 8a77470e..17bb55de 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -221,7 +221,8 @@ describe('TerminalTile server-refresh single-flight', () => { await settle(); expect(pane.terminal.clear).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1'); + // The second argument carries the load's deadline (an AbortSignal). + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything()); expect(pane.terminal.write).toHaveBeenCalledWith('one'); expect(pane._bufferLoading).toBe(false); }); @@ -233,7 +234,7 @@ describe('TerminalTile server-refresh single-flight', () => { pane._refreshBuffer(); await settle(); - expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`); + expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything()); }); it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => { diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts new file mode 100644 index 00000000..ddb84fb1 --- /dev/null +++ b/test/tile-grid-load-queue.test.ts @@ -0,0 +1,478 @@ +/** + * @fileoverview Every capture a grid tile fetches goes through ONE queue. + * + * `GET /api/sessions/:id/terminal` runs synchronous tmux calls on the server, so + * N tiles loading at once do not load in parallel: they stall every WebSocket + * and SSE stream on the server back to back. The grid therefore hands each + * TerminalTile a `scheduleLoad` (a TileLoadQueue, terminal-tile.js) and the + * tile routes EVERY capture through it: the initial load, the refresh after a + * reconnect, a server `{t:'r'}` refresh and the shell history pull. + * + * Pinned here, with `connect()` and the socket handlers running for real: + * - at most one `/terminal` fetch is in flight at a time, for initial loads and + * for N tiles reconnecting together; + * - the focused tile goes first, then reading order, and a history pull (the + * user is waiting on it) jumps ahead of background refreshes; + * - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its + * last frame (the clear happens at its turn); + * - a destroyed tile's queued load is dropped, and destroying the tile whose + * load is running aborts its fetch so the queue moves on; + * - a load that never answers is cut off by its deadline; + * - a close while a load only WAITS writes the disconnected marker at once; + * - grid tiles load a bounded window and keep TILE_SCROLLBACK lines. + * + * Real code under test: constants.js + app.js + terminal-ui.js + + * terminal-tile.js in one `vm` context; xterm, the fit addon and WebSocket are + * fakes. 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +class FakeSocket { + static instances: FakeSocket[] = []; + readyState = 0; + sent: Array> = []; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onclose: ((ev?: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) { + FakeSocket.instances.push(this); + } + send(data: string) { + this.sent.push(JSON.parse(data)); + } + close = vi.fn(() => { + this.readyState = 3; + }); + open() { + this.readyState = 1; + this.onopen?.(); + } + receive(msg: object) { + this.onmessage?.({ data: JSON.stringify(msg) }); + } + drop(code = 1006) { + this.readyState = 3; + this.onclose?.({ code }); + } +} + +class FakeFit { + term: FakeTerminal | null = null; + fit() {} + proposeDimensions() { + return { cols: 80, rows: 24 }; + } +} + +class FakeTerminal { + options: Record; + cols = 80; + rows = 24; + buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; + writes: string[] = []; + constructor(options: Record) { + this.options = { ...options }; + } + loadAddon(addon: FakeFit) { + addon.term = this; + } + open() {} + onData() {} + attachCustomKeyEventHandler() {} + registerLinkProvider() {} + textarea = { addEventListener() {}, removeEventListener() {} }; + write(data: string, cb?: () => void) { + this.writes.push(data); + cb?.(); + } + clear() { + this.writes.push(''); + } + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + } + scrollToLine() {} + scrollToTop() {} + dispose() {} +} + +/** One `/terminal` fetch the test answers (or lets hang) by hand. */ +type Capture = { url: string; settled: boolean; aborted: boolean; answer(body: string): void }; +let captures: Capture[] = []; +const fetchMock = vi.fn((url: string, init?: { signal?: AbortSignal }) => { + return new Promise((resolveFetch, rejectFetch) => { + const capture: Capture = { + url, + settled: false, + aborted: false, + answer(body: string) { + capture.settled = true; + resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: body } }) }); + }, + }; + init?.signal?.addEventListener('abort', () => { + capture.settled = true; + capture.aborted = true; + rejectFetch(new Error('aborted')); + }); + captures.push(capture); + }); +}); +const inFlight = () => captures.filter((c) => !c.settled).length; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); +const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + AbortController, +}; +const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn() }, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + // Late-bound so vi.useFakeTimers() reaches code running in this context. + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: FakeSocket, + Terminal: FakeTerminal, + FitAddon: { FitAddon: FakeFit }, + fetch: (...args: Parameters) => fetchMock(...args), + location: { protocol: 'http:', host: 'codeman.test' }, + document: { addEventListener: vi.fn(), documentElement: { dataset: {} } }, + localStorage: { length: 0, key: vi.fn(), 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` + + 'globalThis.__CodemanApp = CodemanApp;', + context +); +const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; +const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number }; +const TAIL = 1024 * 1024; + +type Tile = { + sessionId: string; + connect(): Promise; + destroy(): void; + reconnectNow(): void; + _maybeLoadMoreHistory(): void; + _destroyed: boolean; + terminal: FakeTerminal | null; + ws: FakeSocket | null; +}; +type Queue = { + schedule(tile: object, kind: string, run: () => Promise): Promise; + size: number; + activeTile: object | null; +}; +const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile; +const TileLoadQueue = windowStub.TileLoadQueue as new (opts?: object) => Queue; + +function makeApp() { + const app = Object.create(CodemanApp.prototype) as Record; + app._clientId = 'c-test'; + app._wsTabNonce = 'nonce-1'; + app._seqCounters = new Map(); + app._pendingDeliveries = new Map(); + app._postDraining = new Set(); + app._extraInputSockets = new Map(); + app._persistReliableState = vi.fn(); + app._persistReliableNow = vi.fn(); + app._updateConnectionIndicator = vi.fn(); + app.loadAppSettingsFromStorage = () => ({}); + app._estimateReplayRows = (text: string) => text.split('\n').length; + return app; +} + +const liveTiles: Tile[] = []; +/** Grid-style tiles sharing one queue: the focused id ranks first, then the order given. */ +function makeGrid(ids: string[], { focused = ids[0], modes = {} as Record } = {}) { + windowStub.app = makeApp(); + const order = [...ids]; + const states: Array<[string, string]> = []; + const queue = new TileLoadQueue({ + rank: (tile: Tile) => (tile.sessionId === focused ? -1 : order.indexOf(tile.sessionId)), + onChange: (tile: Tile, state: string) => states.push([tile.sessionId, state]), + }); + const tiles = ids.map((id) => { + const tile = new TerminalTile( + id, + { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + { + mode: modes[id] ?? 'claude', + scheduleLoad: (t: object, kind: string, run: () => Promise) => queue.schedule(t, kind, run), + scrollback: TileGrid.TILE_SCROLLBACK, + fontSize: 13, + boundedLoad: true, + } + ); + liveTiles.push(tile); + return tile; + }); + return { tiles, queue, states }; +} + +/** Let promise chains (fetch, json, chunked write, queue pump) run. */ +async function settle() { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} + +/** Answer every capture as it arrives, one at a time, asserting the queue never overlaps two. */ +async function drain(body = 'frame') { + let max = 0; + for (let guard = 0; guard < 50; guard++) { + await settle(); + max = Math.max(max, inFlight()); + const open = captures.find((c) => !c.settled); + if (!open) break; + open.answer(body); + } + return max; +} + +beforeEach(() => { + captures = []; + fetchMock.mockClear(); + FakeSocket.instances = []; +}); + +afterEach(() => { + for (const tile of liveTiles.splice(0)) tile.destroy(); + vi.useRealTimers(); +}); + +describe('initial loads', () => { + it('N tiles connecting together fetch one capture at a time', async () => { + const { tiles } = makeGrid(['a', 'b', 'c', 'd']); + const connecting = tiles.map((t) => t.connect()); + + await settle(); + expect(inFlight()).toBe(1); + expect(await drain()).toBe(1); + await Promise.all(connecting); + expect(captures).toHaveLength(4); + }); + + it('runs the focused tile first, then reading order', async () => { + const { tiles } = makeGrid(['a', 'b', 'c', 'd'], { focused: 'c' }); + const connecting = tiles.map((t) => t.connect()); + await drain(); + await Promise.all(connecting); + // `a` was already running when the others arrived; then focus, then order. + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c', 'b', 'd']); + }); + + it('reports each tile queued, then running, then idle (the quiet loading state)', async () => { + const { tiles, states } = makeGrid(['a', 'b']); + const connecting = tiles.map((t) => t.connect()); + await drain(); + await Promise.all(connecting); + expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']); + }); + + it('loads a bounded window: full=1&tail= for a TUI, tail= for a shell', async () => { + const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } }); + const connecting = tiles.map((t) => t.connect()); + await drain(); + await Promise.all(connecting); + expect(captures.map((c) => c.url)).toEqual([ + `/api/sessions/tui/terminal?full=1&tail=${TAIL}`, + `/api/sessions/sh/terminal?tail=${TAIL}`, + ]); + }); + + it('keeps TILE_SCROLLBACK lines and the tile font size', async () => { + const { tiles } = makeGrid(['a']); + const connecting = tiles[0].connect(); + await drain(); + await connecting; + expect(tiles[0].terminal?.options.scrollback).toBe(10000); + expect(tiles[0].terminal?.options.fontSize).toBe(13); + }); +}); + +/** Connects every tile and opens its socket, with the queue drained. */ +async function connectAll(tiles: Tile[]) { + const connecting = tiles.map((t) => t.connect()); + await drain('first'); + await Promise.all(connecting); + for (const tile of tiles) tile.ws?.open(); + captures = []; +} + +describe('refreshes', () => { + it('N tiles reconnecting together (a deploy restart) refresh one at a time', async () => { + const { tiles } = makeGrid(['a', 'b', 'c', 'd', 'e', 'f']); + await connectAll(tiles); + for (const tile of tiles) tile.ws?.drop(1006); + for (const tile of tiles) { + tile.reconnectNow(); + tile.ws?.open(); + } + + await settle(); + expect(inFlight()).toBe(1); + expect(await drain('after')).toBe(1); + expect(captures).toHaveLength(6); + }); + + it('a server {t:"r"} refresh waits its turn behind another tile, keeping its last frame meanwhile', async () => { + const { tiles } = makeGrid(['a', 'b']); + await connectAll(tiles); + const [a, b] = tiles; + a.ws?.receive({ t: 'r' }); + b.ws?.receive({ t: 'r' }); + await settle(); + + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']); + // b has not been cleared: it shows its last frame until its load runs. + expect(b.terminal?.writes).not.toContain(''); + + await drain('fresh'); + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']); + expect(b.terminal?.writes.slice(-2)).toEqual(['', 'fresh']); + }); + + it('a history pull jumps ahead of background refreshes', async () => { + const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } }); + await connectAll(tiles); + const [a, b, sh] = tiles; + a.ws?.receive({ t: 'r' }); + b.ws?.receive({ t: 'r' }); + sh._maybeLoadMoreHistory(); + await settle(); + + expect(inFlight()).toBe(1); + await drain(); + expect(captures.map((c) => c.url)).toEqual([ + `/api/sessions/a/terminal?full=1&tail=${TAIL}`, + `/api/sessions/sh/terminal?full=1&tail=${TAIL}`, + `/api/sessions/b/terminal?full=1&tail=${TAIL}`, + ]); + }); + + it('a close while the refresh only WAITS writes the marker at once, and once', async () => { + const { tiles } = makeGrid(['a', 'b']); + await connectAll(tiles); + const [a, b] = tiles; + a.ws?.receive({ t: 'r' }); + b.ws?.receive({ t: 'r' }); + await settle(); + b.ws?.drop(1006); + + const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length; + expect(markers()).toBe(1); + await drain('fresh'); + // Its turn cleared the screen, so the marker is written again below the replay: one on screen. + const writes = b.terminal?.writes ?? []; + expect(writes.slice(writes.lastIndexOf(''))).toEqual([ + '', + 'fresh', + expect.stringContaining('[disconnected'), + ]); + }); +}); + +describe('teardown', () => { + it("drops a destroyed tile's queued load: it never fetches", async () => { + const { tiles, queue } = makeGrid(['a', 'b', 'c']); + const connecting = tiles.map((t) => t.connect()); + await settle(); + tiles[1].destroy(); + await drain(); + await Promise.all(connecting); + + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c']); + expect(queue.size).toBe(0); + expect(tiles[1].ws).toBeNull(); + }); + + it('destroying the tile whose load is running aborts its fetch and the queue moves on', async () => { + const { tiles } = makeGrid(['a', 'b']); + const connecting = tiles.map((t) => t.connect()); + await settle(); + tiles[0].destroy(); + await settle(); + + expect(captures[0].aborted).toBe(true); + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']); + await drain(); + await Promise.all(connecting); + }); + + it('a capture that never answers is cut off by its deadline, and the next tile loads', async () => { + vi.useFakeTimers(); + const { tiles } = makeGrid(['a', 'b']); + const connecting = tiles.map((t) => t.connect()); + await settle(); + expect(captures).toHaveLength(1); + + // The full-capture budget (CodemanFetchDeadline, 45 s for a TUI). + await vi.advanceTimersByTimeAsync(45_000); + await settle(); + expect(captures[0].aborted).toBe(true); + expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']); + captures[1].answer('b'); + await settle(); + await Promise.all(connecting); + }); +}); + +describe('TileLoadQueue on its own', () => { + it('never rejects, and moves on when a load throws', async () => { + const queue = new TileLoadQueue(); + const order: string[] = []; + const first = queue.schedule({}, 'initial', async () => { + order.push('first'); + throw new Error('boom'); + }); + const second = queue.schedule({}, 'initial', async () => { + order.push('second'); + }); + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBeUndefined(); + expect(order).toEqual(['first', 'second']); + expect(queue.activeTile).toBeNull(); + }); + + it('resolves, but never runs, a load whose tile was destroyed while it waited', async () => { + const queue = new TileLoadQueue(); + let release: () => void = () => {}; + const busy = queue.schedule({}, 'initial', () => new Promise((r) => (release = r))); + const gone = { _destroyed: false }; + const run = vi.fn(async () => {}); + const waiting = queue.schedule(gone, 'refresh', run); + gone._destroyed = true; + release(); + await busy; + await expect(waiting).resolves.toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + expect(queue.size).toBe(0); + }); + + it('drop() resolves every load still waiting for a tile', async () => { + const queue = new TileLoadQueue(); + let release: () => void = () => {}; + const busy = queue.schedule({}, 'initial', () => new Promise((r) => (release = r))); + const tile = {}; + const run = vi.fn(async () => {}); + const waiting = queue.schedule(tile, 'refresh', run); + (queue as unknown as { drop(t: object): void }).drop(tile); + await expect(waiting).resolves.toBeUndefined(); + release(); + await busy; + expect(run).not.toHaveBeenCalled(); + }); +}); From 4341d3dca883f115a85eba49bdea7b78eb59b37b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:20:05 +0200 Subject: [PATCH 04/90] feat(tiles): the grid controller, and the main terminal parked while it is open tile-grid.js (load order 7.6) adds the grid to CodemanApp: openTileGrid, closeTileGrid, addTile, removeTile and _selectTiledSession, over a
that is a SIBLING of .terminal-wrap and takes its place under .main.tiles-active. Every tile is a TerminalTile with the grid's one load queue, TILE_SCROLLBACK, a bounded load and its own per-device font size (codeman-tile-font-size; Ctrl +/- sizes the tiles while the grid is open). Layout comes from computeTileLayout; one ResizeObserver on the section refits each tile (xterm and PTY together) on the trailing edge. Opening parks the main terminal: _cleanupPreviousSession runs once (its snapshot is right at that moment, and it closes the main socket), and activeSessionId always names the focused tile's session, so the panels follow focus. With the main socket closed, every main-terminal path that would write the focused tile's output into the hidden xterm, fetch a capture for it, resize it or reopen its socket now stands aside through _tilesOwnTerminal(): the SSE terminal, clear and refresh handlers, the dropped-output recovery, the completion/error writelns, retryConnection and handleInit (both re-arm the tiles instead; handleInit keeps live tiles and drops dead ones), sendResize, throttledResize, the history re-pull, and the WebGL long-task observer, which watches the whole page and must not count tile renders toward the main terminal's sticky WebGL disable. The header connection state comes from the tile sockets. Closing destroys every tile, invalidates the main terminal's cached content (snapshot, codeman-xs key, buffer cache) for every tiled id, since it predates the grid, and replays the focused session fresh in the single view. _focusedPane() answers with the focused tile and _forEachTile reaches every grid tile. A tile whose socket stops for good is removed (4003, 4004, 4010) or keeps its "session ended" marker (4009). No entry point yet: the grid is opened from the shortcut registry in a later commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 45 ++- src/web/public/index.html | 7 + src/web/public/styles.css | 72 ++++ src/web/public/terminal-tile.js | 3 + src/web/public/terminal-ui.js | 56 ++- src/web/public/tile-grid.js | 481 +++++++++++++++++++++++++ test/tile-grid-park-guards.test.ts | 559 +++++++++++++++++++++++++++++ 7 files changed, 1207 insertions(+), 16 deletions(-) create mode 100644 src/web/public/tile-grid.js create mode 100644 test/tile-grid-park-guards.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index d41eeed8..cda1a41f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1197,6 +1197,11 @@ class CodemanApp { try { this._webglLongTaskObserver = new PerformanceObserver((list) => { if (!this._webglAddon) return; + // ⚠️ The observer sees EVERY long task on the page. While the tile grid + // owns the terminal the main terminal is parked and draws nothing; the + // long tasks are tile renders and replays (DOM renderers), and counting + // them would write the sticky 7-day WebGL disable for no WebGL reason. + if (this._tilesOwnTerminal?.()) return; const now = performance.now(); if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return; if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) { @@ -2202,6 +2207,10 @@ class CodemanApp { } _onSessionTerminal(data) { + // Tile grid open: the main terminal is parked and its socket closed, so the + // SSE fallback would write the focused tile's output into a hidden xterm. + // The tiles carry their own output over their own sockets. + if (this._tilesOwnTerminal?.()) return; if (data.id === this.activeSessionId) { if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); @@ -2251,6 +2260,8 @@ class CodemanApp { */ _scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) { if (!sessionId || this._clientDropRecoveryTimer) return; + // Nothing of the main terminal's to recover while the tile grid owns it. + if (this._tilesOwnTerminal?.()) return; // Behind the debounce guard: one line per window, not per dropped frame. if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`); this._clientDropRecoveryTimer = setTimeout(async () => { @@ -2928,6 +2939,10 @@ class CodemanApp { async _onSessionNeedsRefresh(event = {}) { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. + // Tile grid open: the main terminal is parked, so this would fetch a capture + // for a hidden xterm. Each tile refreshes itself through the grid's queue. + // `false`, never undefined: the drop recovery reads the result. + if (this._tilesOwnTerminal?.()) return false; const sessionId = this.activeSessionId; if (event?.id && event.id !== sessionId) return false; if (!sessionId || !this.terminal) return false; @@ -3030,6 +3045,8 @@ class CodemanApp { } async _onSessionClearTerminal(data) { + // The tiles get the clear over their own sockets; the parked main terminal must not refetch. + if (this._tilesOwnTerminal?.()) return; if (data.id === this.activeSessionId) { // Skip if selectSession is already loading the buffer — clearTerminal arriving // during buffer load would clear the terminal mid-write, causing visible flicker @@ -3075,14 +3092,15 @@ class CodemanApp { _onSessionCompletion(data) { this.totalCost += data.cost || 0; this.updateCost(); - if (data.id === this.activeSessionId) { + // Not into the parked main terminal while the tile grid owns the screen. + if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) { this.terminal.writeln(''); this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`); } } _onSessionError(data) { - if (data.id === this.activeSessionId) { + if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) { this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); } this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error'); @@ -4034,9 +4052,12 @@ class CodemanApp { } // With an active terminal, show its transport (WebSocket vs HTTP fallback). + // While the tile grid owns the terminal the main socket is parked on + // purpose, so the state comes from the tiles' sockets: all open is + // connected, any still coming back is reconnecting. if (this.activeSessionId) { let cls, label, detail; - switch (this._wsState) { + switch (this._tilesOwnTerminal?.() ? this._tileGridSocketState() : this._wsState) { case 'connected': cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket'; break; @@ -4217,8 +4238,11 @@ class CodemanApp { this._updateConnectionLossUi(); this.connectSSE(); // The terminal socket does not always come back on its own (planWsReconnect - // 'give-up'), so the same button re-arms it. - if (this.activeSessionId && this._wsState !== 'connected') { + // 'give-up'), so the same button re-arms it. With the tile grid open the + // main socket is parked on purpose: re-arm the tiles' sockets instead. + if (this._tilesOwnTerminal?.()) { + for (const { tile } of this._tileGrid.tiles.values()) tile.reconnectNow(); + } else if (this.activeSessionId && this._wsState !== 'connected') { this._wsReconnectAttempts = 0; this._connectWs(this.activeSessionId); } @@ -4561,6 +4585,11 @@ class CodemanApp { return; } + // Tile grid open (tile-grid.js): tiles whose sessions are gone are removed, + // the live ones are kept as they are and reconnect now, and the main + // terminal stays parked. Before the link below, which may leave the grid. + const tilesOpen = this._reconcileTileGrid?.() === true; + // A `#session=` link wins over restoring the last active tab. if (this._urlSessionId && this.sessions.has(this._urlSessionId)) { this.activeSessionId = null; @@ -4570,6 +4599,10 @@ class CodemanApp { // Not listed yet: its wait starts now that the list has loaded, and the // last active tab is restored meanwhile. if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId); + // The grid holds the focused session: nothing below may reconnect or reload + // the parked main terminal (its keepTerminal branch would reopen the main + // socket onto a session a tile already shows). + if (tilesOpen) return; const previousActiveId = this.activeSessionId; if (this.sessionOrder.length === 0) { @@ -7665,6 +7698,8 @@ class CodemanApp { const sessionId = this.activeSessionId; if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; if (this.detachedSessions?.has(sessionId)) return; + // The parked main terminal has no history to pull while tiles own the screen. + if (this._tilesOwnTerminal?.()) return; const session = this.sessions.get(sessionId); // A shell's full capture can be many megabytes, and replaying all of it from // an ordinary scroll gesture blocks xterm's main thread. So a shell scroll diff --git a/src/web/public/index.html b/src/web/public/index.html index 9cc86457..2e83db29 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -435,6 +435,12 @@ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"> + +
+ @@ -3920,6 +3926,7 @@ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b2718c82..ab9f2f73 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19347,6 +19347,78 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle min-height: 0; } +/* ── Tile grid (tile-grid.js, docs/tile-grid-plan.md) ────────────────────── + 1 to 9 sessions side by side, each a TerminalTile. The section is a SIBLING of + .terminal-wrap; .main.tiles-active swaps one for the other (the main terminal + is parked while the grid is open). Columns and rows are set inline by + _applyTileLayout() from the tile count. */ +.tile-grid { + display: none; +} + +.main.tiles-active .terminal-wrap { + display: none; +} + +.main.tiles-active .tile-grid { + display: grid; + flex: 1; + min-width: 0; + min-height: 0; + gap: 4px; + padding: 4px; + background: var(--bg-dark); +} + +/* A web tab takes the stage from the grid exactly as from the single view: the + tiles stay connected, and a hidden tile measures nothing and sends no + resize. AFTER the rule above, which has the same specificity. */ +.main.webview-active .tile-grid { + display: none; +} + +.tile { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border-color, #333); + border-radius: 4px; + background: var(--term-bg, #161b23); +} + +/* The focused tile has the keyboard and is the active session. */ +.tile.focused { + border-color: var(--accent-color, #4a9eff); +} + +.tile-body { + flex: 1 1 auto; + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tile-body .xterm { + width: 100%; + height: 100%; +} + +/* Quiet "loading" state while the tile's capture waits in the grid's one load + queue (or runs). */ +.tile.tile--loading .tile-body::after { + content: 'Loading\2026'; + position: absolute; + top: 6px; + right: 10px; + font-size: 11px; + opacity: 0.6; + pointer-events: none; +} + /* Split-picker menu: a small popover listing sessions to split with, appended to document.body and positioned `fixed` by openSplitPicker() (JS sets top/right against the Split header button's own rect). z-index above the diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 6061a3f7..76b3b445 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -541,6 +541,8 @@ this._inputHandle = { ws: this.ws, lastRecvAt: 0 }; app._registerInputSocket(this.sessionId, this._inputHandle); app._onWsReady?.(this.sessionId); + // The header's connection dot reads tile sockets while the grid is open. + app._updateConnectionIndicator?.(); } // Leaves the map; only this pane's own handle is removed (a replacement @@ -549,6 +551,7 @@ if (!this._inputHandle) return; global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle); this._inputHandle = null; + global.app?._updateConnectionIndicator?.(); } // Settles a marker the pane owes: set when a close lands during a load (the diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 88cc6a54..d11e7f92 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1328,6 +1328,9 @@ Object.assign(CodemanApp.prototype, { // Same yield as sendResize: never resize a PTY whose session is showing // in its own window. Dragging the dashboard's border must not reshape it. const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId); + // The tile grid parks the main terminal: its session is sized by its + // tile, which this same timer refits below (_forEachTile). + const tilesOwnTerminal = this._tilesOwnTerminal?.(); // ⚠️ Whether to fit is the SAME question as whether to send (issue #464). // This block used to fit unconditionally and skip only the SIGWINCH, // which is the one combination that cannot be right: it moves xterm to @@ -1335,7 +1338,9 @@ Object.assign(CodemanApp.prototype, { // repaints from the shape it was told. Withhold both, or neither — // a reflow nothing is rendering for buys nothing and costs correctness. const dims = - this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null; + this.activeSessionId && !keyboardUp && !detachedElsewhere && !tilesOwnTerminal + ? this._geometryForResizeRequest() + : null; // ⚠️ A null measurement is NOT a reason to report the floor. It used to // fall back to a bare 40x10, which tells the PTY a shape nothing measured // and xterm does not hold — the write-only guess this whole change exists @@ -4512,7 +4517,12 @@ Object.assign(CodemanApp.prototype, { * it from its own terminal's focus; the primary terminal's focus gives it back. */ _focusedPane() { - const tile = this._focusedTile; + let tile = this._focusedTile; + // With the tile grid open the main terminal is parked, so the pane is the + // focused tile even when DOM focus sits on a button or a panel. + if ((!tile || tile._destroyed || !tile.terminal) && this._tilesOwnTerminal?.()) { + tile = this._tileFor(this.activeSessionId); + } if (tile && !tile._destroyed && tile.terminal) { return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile }; } @@ -4525,13 +4535,19 @@ Object.assign(CodemanApp.prototype, { }, /** - * Run `fn(tile)` for every secondary terminal pane on screen: today the split - * pane's second terminal, when one is open. Font, weight, family and skin - * changes go through here so they reach every pane without a special case - * per pane kind. Agent Teams terminals size themselves and are not tiles. + * Run `fn(tile)` for every secondary terminal pane on screen: the split + * pane's second terminal and every tile of the tile grid (never both: the two + * modes are not open together). Font, weight, family and skin changes go + * through here so they reach every pane without a special case per pane kind. + * `{ grid: false }` skips grid tiles (they keep their own font size). + * Agent Teams terminals size themselves and are not tiles. */ - _forEachTile(fn) { + _forEachTile(fn, { grid = true } = {}) { if (this._splitPane?.terminal) fn(this._splitPane); + if (!grid || !this._tileGrid?.open) return; + for (const { tile } of this._tileGrid.tiles.values()) { + if (tile.terminal) fn(tile); + } }, // Clears the pane the keyboard is in. The chord itself also reaches that @@ -4614,6 +4630,9 @@ Object.assign(CodemanApp.prototype, { this.showToast('No active session', 'warning'); return; } + // Backstop: _focusedPane() answers with the focused tile while the grid is + // open, so this is reached only if that tile is gone mid-call. + if (this._tilesOwnTerminal?.()) return; // The pane belongs to the popup showing it, so this window has nothing to // restore. Say so rather than reporting a size that was never sent — the @@ -5742,11 +5761,20 @@ Object.assign(CodemanApp.prototype, { }, increaseFontSize() { + // With the tile grid open, Ctrl +/- sizes the tiles (their own font size). + if (this._tilesOwnTerminal?.()) { + this.setTileFontSize(Math.min(this._tileGridFontSize() + 2, 24)); + return; + } const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.min(current + 2, 24)); }, decreaseFontSize() { + if (this._tilesOwnTerminal?.()) { + this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10)); + return; + } const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.max(current - 2, 10)); }, @@ -5759,10 +5787,13 @@ Object.assign(CodemanApp.prototype, { // Update overlay font cache and re-render at new cell dimensions this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); - this._forEachTile?.((tile) => { - tile.terminal.options.fontSize = size; - tile.fit(); // a font change is a size change: tell its PTY too (#464) - }); + this._forEachTile?.( + (tile) => { + tile.terminal.options.fontSize = size; + tile.fit(); // a font change is a size change: tell its PTY too (#464) + }, + { grid: false } + ); }, /** @@ -6066,6 +6097,9 @@ Object.assign(CodemanApp.prototype, { // settle-time refit through this call and has no fallback, which is correct: // a pane it does not own is not its to refit either. if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false; + // Backstop: while the tile grid owns the terminal, a tile sizes this PTY and + // the parked main terminal measures nothing worth sending. + if (this._tilesOwnTerminal?.()) return false; // Fit, floor, and apply in one step so the numbers below are the numbers // xterm is actually holding (or, while another device holds the width, // the numbers this container would hold if the PTY followed). diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js new file mode 100644 index 00000000..adf15291 --- /dev/null +++ b/src/web/public/tile-grid.js @@ -0,0 +1,481 @@ +// src/web/public/tile-grid.js + +/** + * @fileoverview The tile grid: 1 to 9 live sessions side by side in one + * window, each in its own TerminalTile (terminal-tile.js), laid out by count + * (window.CodemanTileGrid, constants.js). Desktop only; see + * docs/tile-grid-plan.md. + * + * While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden, + * its socket closed, and every path that would write to it, fetch for it or + * reconnect it stands aside (`_tilesOwnTerminal()`). `activeSessionId` always + * names the FOCUSED tile's session, so everything keyed on it (files panel, + * respawn and Ralph panels, subagent windows, voice, image paste, the tab + * highlight) follows focus without knowing tiles exist. + * + * Every capture a tile fetches goes through the grid's one TileLoadQueue, + * because each is a synchronous tmux call on the server. + * + * @dependency terminal-tile.js (window.TerminalTile, window.TileLoadQueue) + * @dependency constants.js (window.CodemanTileGrid, SPLIT_PANE_MIN_WIDTH) + * @loadorder 7.6 of 16, loaded after terminal-split.js and before respawn-ui.js + */ + +// Per-device tile font size (a tile is a fraction of the screen). +const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; +// 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. +const TILE_GRID_REFIT_MS = 150; + +/** The grid's state. `has(id)` answers only while it is open. */ +class TileGridModel { + constructor() { + this.open = false; + // Session ids in reading order (row-major). + this.ids = []; + // id -> { tile: TerminalTile, el: HTMLElement } + this.tiles = new Map(); + this.focusedId = null; + this.cols = 0; + this.rows = 0; + this.queue = null; + this.resizeObserver = null; + this.refitTimer = null; + } + + has(id) { + return this.open && this.tiles.has(id); + } +} + +Object.assign(CodemanApp.prototype, { + /** + * True while the grid owns the terminal area and the main terminal is parked. + * Every main-terminal path that would write, fetch, resize or reconnect checks + * this and stands aside. + */ + _tilesOwnTerminal() { + return !!this._tileGrid?.open; + }, + + /** The open grid's TerminalTile for a session, or null. */ + _tileFor(sessionId) { + return this._tileGrid?.open ? (this._tileGrid.tiles.get(sessionId)?.tile ?? null) : null; + }, + + /** Desktop only, never in a solo (popped-out) window; same gate as the split. */ + canOpenTileGrid() { + return !this.isSoloWindow && window.innerWidth >= SPLIT_PANE_MIN_WIDTH; + }, + + _tileGridFontSize() { + let saved = NaN; + try { + saved = parseInt(localStorage.getItem(TILE_GRID_FONT_KEY), 10); + } catch { + /* Storage unavailable: the default below. */ + } + return saved >= 10 && saved <= 24 ? saved : window.CodemanTileGrid.TILE_FONT_SIZE_DEFAULT; + }, + + /** Ctrl +/- while the grid is open: every tile, then each tile's PTY (a font change is a size change, #464). */ + setTileFontSize(size) { + try { + localStorage.setItem(TILE_GRID_FONT_KEY, String(size)); + } catch { + /* Per-device convenience only. */ + } + for (const { tile } of this._tileGrid?.tiles.values() || []) { + tile.fontSize = size; + if (!tile.terminal) continue; + tile.terminal.options.fontSize = size; + tile.fit(); + } + }, + + _tileLoadQueue() { + const grid = this._tileGrid; + if (!grid.queue) { + grid.queue = new window.TileLoadQueue({ + // The focused tile first, then reading order. + rank: (tile) => (tile.sessionId === grid.focusedId ? -1 : grid.ids.indexOf(tile.sessionId)), + // A quiet "loading" state on a tile until its capture has landed. + onChange: (tile, state) => { + const entry = grid.tiles.get(tile.sessionId); + if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle'); + }, + }); + } + return grid.queue; + }, + + _tileGridSection() { + let section = document.getElementById('tileGrid'); + if (!section) { + section = document.createElement('section'); + section.id = 'tileGrid'; + section.className = 'tile-grid'; + section.setAttribute('aria-label', 'Tiled sessions'); + const wrap = document.querySelector('.terminal-wrap'); + wrap?.parentElement?.insertBefore(section, wrap.nextSibling); + } + return section; + }, + + /** + * Opens the grid on `ids` (unknown, detached and duplicate ids are skipped; + * at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it + * adds what is missing and moves focus. `auto: false` makes the focus a human + * selection (it acknowledges that session's idle alert). + * + * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while + * its snapshot of the session it shows is still right, and closes its socket. + * + * @returns {boolean} whether the grid is open afterwards + */ + openTileGrid(ids, { focusedId = null, auto = true } = {}) { + if (!this.canOpenTileGrid()) return false; + const grid = (this._tileGrid ||= new TileGridModel()); + const max = window.CodemanTileGrid.TILE_GRID_MAX; + const wanted = []; + for (const id of ids || []) { + if (typeof id !== 'string' || wanted.includes(id)) continue; + if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; + wanted.push(id); + if (wanted.length === max) break; + } + if (wanted.length === 0) return grid.open; + const focus = wanted.includes(focusedId) ? focusedId : wanted[0]; + + if (grid.open) { + for (const id of wanted) this.addTile(id); + if (grid.has(focus)) this._selectTiledSession(focus, { auto }); + return true; + } + + this._cleanupPreviousSession(focus); + grid.open = true; + grid.ids = []; + grid.focusedId = focus; + document.querySelector('.main')?.classList.add('tiles-active'); + const section = this._tileGridSection(); + this.hideWelcome(); + // Mount every tile and lay the grid out BEFORE any tile connects, so each + // first fit measures its real cell; the focused tile connects first, so + // its capture is the one the queue starts with. + for (const id of wanted) this._mountTile(id); + this._applyTileLayout(); + for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); + if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { + // The main terminal's observer watches a node that is now hidden; this one + // catches window resizes, sidebar toggles and rail drags for the grid. + grid.resizeObserver = new ResizeObserver(() => this._scheduleTileGridRefit()); + grid.resizeObserver.observe(section); + } + this._installTileGridWidthGate(); + this._selectTiledSession(focus, { auto }); + this._updateConnectionIndicator?.(); + return true; + }, + + /** + * Leaves the grid: every tile destroyed (sockets closed, xterms disposed, + * queued loads dropped), the main terminal unparked. + * + * `keepStored` remembers the grid for one-click return (toggleTileGrid). + * `reselect` shows the focused session in the single view through a forced + * reload; pass false when the caller selects something itself. + * + * The main terminal's cached content for EVERY tiled id is invalidated: it was + * written before the grid opened, possibly hours ago, and selectSession paints + * a snapshot as its first frame. + */ + closeTileGrid({ keepStored = true, reselect = true } = {}) { + const grid = this._tileGrid; + if (!grid?.open) return; + const focusedId = grid.focusedId; + const ids = grid.ids.slice(); + this._tileGridRemembered = keepStored ? { ids, focusedId } : null; + // Closed BEFORE the tiles go: each destroy() updates the header's connection + // state, which must read the main terminal again, not half-destroyed tiles. + grid.open = false; + clearTimeout(grid.refitTimer); + grid.refitTimer = null; + grid.resizeObserver?.disconnect(); + grid.resizeObserver = null; + for (const { tile, el } of grid.tiles.values()) { + grid.queue?.drop(tile); + tile.destroy(); + el.remove(); + } + grid.tiles.clear(); + grid.ids = []; + grid.focusedId = null; + document.querySelector('.main')?.classList.remove('tiles-active'); + const section = document.getElementById('tileGrid'); + if (section) { + section.style.gridTemplateColumns = ''; + section.style.gridTemplateRows = ''; + } + // As _redock does: the tiles sized these PTYs, so the main terminal's + // record of the last size it sent no longer describes them. + this._lastResizeDims = null; + for (const id of ids) { + this._xtermSnapshots?.delete(id); + try { + localStorage.removeItem(`codeman-xs-${id}`); + } catch { + /* Nothing stored. */ + } + this.terminalBufferCache?.delete(id); + } + this._updateConnectionIndicator?.(); + if (reselect) this._selectAfterTileGrid(focusedId); + }, + + // The single view after the grid closes: the session the grid was focused on, + // replayed fresh (forceReload drops the stale snapshot and nulls + // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), + // or, if that session is gone, the same fallback as closing the active tab. + _selectAfterTileGrid(sessionId) { + if (sessionId && this.sessions.has(sessionId)) { + this.selectSession(sessionId, { forceReload: true, auto: true }); + return; + } + this.activeSessionId = null; + try { + localStorage.removeItem('codeman-active-session'); + } catch { + /* Nothing stored. */ + } + const next = this.sessionOrder.find((id) => this.sessions.has(id)); + if (next) { + this.selectSession(next, { auto: true }); + } else { + this.terminal?.clear(); + this.showWelcome(); + } + }, + + /** Adds one session as a tile (open grid only). Returns whether it was added. */ + addTile(sessionId) { + const grid = this._tileGrid; + if (!grid?.open || grid.tiles.has(sessionId)) return false; + if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; + if (!this._mountTile(sessionId)) return false; + this._applyTileLayout(); + this._connectTile(sessionId); + this._scheduleTileGridRefit(); + return true; + }, + + /** + * Removes one tile; the session keeps running. When it held focus, `refocus` + * moves focus to the neighbouring tile (next in grid order, else previous). + * The last tile leaving closes the grid: with `refocus` the single view then + * shows that session, without it the caller decides what comes next. + */ + removeTile(sessionId, { refocus = true, auto = true } = {}) { + const grid = this._tileGrid; + const entry = grid?.open ? grid.tiles.get(sessionId) : null; + if (!entry) return false; + if (grid.ids.length === 1) { + this.closeTileGrid({ keepStored: false, reselect: refocus }); + return true; + } + const wasFocused = grid.focusedId === sessionId; + const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); + grid.queue?.drop(entry.tile); + entry.tile.destroy(); + entry.el.remove(); + grid.tiles.delete(sessionId); + grid.ids.splice(grid.ids.indexOf(sessionId), 1); + this._applyTileLayout(); + this._scheduleTileGridRefit(); + if (wasFocused) { + grid.focusedId = null; + if (refocus && neighbor) this._selectTiledSession(neighbor, { auto }); + } + return true; + }, + + _mountTile(sessionId) { + const grid = this._tileGrid; + const session = this.sessions.get(sessionId); + if (!session || this.detachedSessions?.has(sessionId)) return false; + const el = document.createElement('div'); + el.className = 'tile'; + el.dataset.sessionId = sessionId; + const body = document.createElement('div'); + body.className = 'tile-body'; + el.appendChild(body); + this._tileGridSection().appendChild(el); + const tile = new window.TerminalTile(sessionId, body, { + mode: session.mode, + fontSettings: this.loadAppSettingsFromStorage?.() || {}, + detachedSessions: this.detachedSessions, + scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run), + scrollback: window.CodemanTileGrid.TILE_SCROLLBACK, + fontSize: this._tileGridFontSize(), + boundedLoad: true, + onExit: (code) => this._onTileExit(sessionId, tile, code), + }); + grid.tiles.set(sessionId, { tile, el }); + grid.ids.push(sessionId); + return true; + }, + + _connectTile(sessionId) { + this._tileGrid?.tiles + .get(sessionId) + ?.tile.connect() + .catch(() => { + /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ + }); + }, + + /** + * A tile's socket stopped for good. 4009 (the session exited) keeps the tile + * with its "session ended" marker; 4003 (refused), 4004 (session gone) and + * 4010 (another socket took over) remove it. + */ + _onTileExit(sessionId, tile, code) { + if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; + if (code === 4009) return; + this.removeTile(sessionId, { refocus: true, auto: true }); + }, + + /** Columns x rows for the current tile count, applied to the grid section. */ + _applyTileLayout() { + const grid = this._tileGrid; + const section = this._tileGridSection(); + const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 }; + const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ + count: grid.ids.length, + width: rect.width || window.innerWidth, + height: rect.height || window.innerHeight, + }); + grid.cols = cols; + grid.rows = rows; + section.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; + section.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`; + }, + + // Refits every tile once the grid area has settled: one xterm resize and one + // PTY resize together per tile (#464), on the trailing edge. + _scheduleTileGridRefit() { + const grid = this._tileGrid; + if (!grid?.open) return; + clearTimeout(grid.refitTimer); + grid.refitTimer = setTimeout(() => { + grid.refitTimer = null; + if (!grid.open) return; + // The 3-tile layout depends on the width (3x1 or 2x2). + this._applyTileLayout(); + for (const { tile } of grid.tiles.values()) tile.fit(); + }, TILE_GRID_REFIT_MS); + }, + + // Narrowing the window past the desktop gate returns to the single view of the + // focused session; the grid is remembered. + _installTileGridWidthGate() { + if (this._tileGridWidthGateInstalled || !window.matchMedia) return; + this._tileGridWidthGateInstalled = true; + const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`); + mq.addEventListener('change', (e) => { + if (!e.matches && this._tileGrid?.open) this.closeTileGrid({ keepStored: true, reselect: true }); + }); + }, + + _paintTileFocus() { + const grid = this._tileGrid; + for (const [id, { el }] of grid?.tiles || []) el.classList.toggle('focused', id === grid.focusedId); + }, + + /** + * Focuses a tiled session: the tile branch of selectSession. Moving focus is + * an `activeSessionId` change plus `xterm.focus()`: no fetch, no replay. It + * runs the panel refresh a normal switch runs and skips everything bound to + * the main terminal (cleanup, replay, resize, its socket, local echo). Only a + * USER-initiated selection (`auto` not true) acknowledges the idle alert. + * + * @param {string} sessionId + * @param {{auto?: boolean, focus?: boolean}} [options] - `focus: false` leaves + * DOM focus where it is (an app-driven reconcile must not steal it) + */ + _selectTiledSession(sessionId, options = {}) { + const grid = this._tileGrid; + const entry = grid?.open ? grid.tiles.get(sessionId) : null; + if (!entry) return; + const userInitiated = options.auto !== true; + // Aborts any in-flight normal select at its next _isStaleSelect check. + const selectGen = ++this._selectGeneration; + this._hideWebviewLayer?.(); + this.activeSessionId = sessionId; + grid.focusedId = sessionId; + this._activateFileBrowserSession?.(sessionId); + try { + localStorage.setItem('codeman-active-session', sessionId); + } catch { + /* Per-device convenience only. */ + } + // The SSE subscription follows the focused session as in the single view; + // its terminal frames are dropped by the parking guards. + this._updateSseSubscription?.(sessionId); + this.hideWelcome(); + if (userInitiated) this.markIdleAlertSeen(sessionId); + this._paintTileFocus(); + this._updateActiveTabImmediate?.(sessionId); + this.closeSessionSidebarOnHandheld?.(); + this.renderSessionTabs?.(); + const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`); + if (activeTab) { + activeTab.classList.add('tab-glow'); + activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true }); + } + this.updateAttachmentHistoryBadge?.(); + if (this.attachmentHistoryDrawerOpen) this.loadAttachmentHistory?.(sessionId); + if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession(); + this.refreshHostWakeBanner?.(sessionId); + this.currentSessionWorkingDir = this.sessions.get(sessionId)?.workingDir || null; + const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); + idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); + // The keyboard follows focus: shortcuts, voice and paste act on this tile. + this._noteFocusedTile(entry.tile); + if (options.focus !== false) entry.tile.terminal?.focus(); + }, + + /** Header connection state while tiles own the terminal: every live tile socket open, or not. */ + _tileGridSocketState() { + for (const { tile } of this._tileGrid?.tiles.values() || []) { + // A tile stopped for good (its session exited) has nothing to reconnect. + if (tile._stoppedCode !== null && tile._stoppedCode !== undefined) continue; + if (!tile._wsReady) return 'reconnecting'; + } + return 'connected'; + }, + + /** + * handleInit (page state reloaded, or SSE back after a server restart) with + * the grid open: tiles whose sessions are gone or popped out are removed, the + * rest are KEPT (never rebuilt) and told to reconnect now instead of waiting + * out their backoff, and focus stays on a live tile. + * + * @returns {boolean} whether the grid is still open (handleInit then skips + * restoring the main terminal) + */ + _reconcileTileGrid() { + const grid = this._tileGrid; + if (!grid?.open) return false; + for (const id of grid.ids.slice()) { + if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false }); + } + if (!grid.open) return false; + // Only a focus that is gone moves: re-selecting the same tile would hide an + // active web tab on every SSE blip (_selectTiledSession hides the web layer), + // which the single view's reconnect never does. + if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true }); + for (const { tile } of grid.tiles.values()) tile.reconnectNow(); + return true; + }, +}); diff --git a/test/tile-grid-park-guards.test.ts b/test/tile-grid-park-guards.test.ts new file mode 100644 index 00000000..c51c6a08 --- /dev/null +++ b/test/tile-grid-park-guards.test.ts @@ -0,0 +1,559 @@ +/** + * @fileoverview While the tile grid is open the main terminal is PARKED. + * + * Opening the grid (tile-grid.js `openTileGrid`) runs `_cleanupPreviousSession` + * once, which closes the main terminal's socket, and hides `.terminal-wrap`. + * With that socket closed `_wsReady` is false, so every SSE terminal handler + * would start writing the FOCUSED tile's output into the hidden xterm (they all + * key on `activeSessionId`), refetch captures for it, or reopen its socket onto + * a session a tile already shows. One predicate, `_tilesOwnTerminal()`, turns + * each of them into a no-op. This suite enumerates them, and checks each one + * BOTH ways (grid open: stands aside; grid closed: acts), so none can pass + * vacuously: + * + * - `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` + * (returns false, never undefined), `_scheduleDroppedOutputRecovery`; + * - the `terminal.writeln` in `_onSessionCompletion` / `_onSessionError`; + * - `retryConnection` and `handleInit` (which reconnect the main socket): + * they re-arm the TILES instead, and handleInit keeps live tiles; + * - the backstops `sendResize`, `_maybeRefetchFullHistory`; + * - the WebGL long-task observer, which watches the WHOLE page: tile renders + * must not count toward the main terminal's sticky WebGL disable; + * - the header connection state, derived from the tile sockets. + * + * Plus the park and unpark themselves: one cleanup on enter; on exit every + * tile destroyed, `_lastResizeDims` reset, the main terminal's cached content + * for EVERY tiled id invalidated, and the focused session replayed fresh. + * + * Real code: constants.js + app.js + terminal-ui.js + tile-grid.js in one `vm` + * context, with a small fake DOM and a fake TerminalTile. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ +class FakeEl { + id = ''; + className = ''; + dataset: Record = {}; + style: Record = {}; + children: FakeEl[] = []; + parentElement: FakeEl | null = null; + attrs: Record = {}; + listeners: Record void>> = {}; + classList = { + add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)), + remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)), + toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)), + contains: (n: string) => this.className.split(/\s+/).includes(n), + }; + _setClass(name: string, on: boolean) { + const set = new Set(this.className.split(/\s+/).filter(Boolean)); + if (on) set.add(name); + else set.delete(name); + this.className = [...set].join(' '); + return on; + } + appendChild(child: FakeEl) { + child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child: FakeEl, ref: FakeEl | null) { + child.remove(); + child.parentElement = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i === -1) this.children.push(child); + else this.children.splice(i, 0, child); + return child; + } + get nextSibling() { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + siblings.splice(siblings.indexOf(this), 1); + this.parentElement = null; + } + setAttribute(k: string, v: string) { + this.attrs[k] = v; + } + addEventListener(type: string, fn: (ev: unknown) => void) { + (this.listeners[type] ||= []).push(fn); + } + removeEventListener() {} + getBoundingClientRect() { + return { width: 2400, height: 1200 }; + } + querySelector() { + return null; + } +} + +const main = new FakeEl(); +main.className = 'main'; +const wrap = new FakeEl(); +wrap.className = 'terminal-wrap'; +main.appendChild(wrap); +const section = new FakeEl(); +section.id = 'tileGrid'; +section.className = 'tile-grid'; +main.appendChild(section); + +const documentStub = { + addEventListener: vi.fn(), + documentElement: { dataset: {} }, + createElement: () => new FakeEl(), + getElementById: (id: string) => (id === 'tileGrid' ? section : null), + querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), + querySelectorAll: () => [], +}; + +/** A TerminalTile stand-in: records what the grid asks of it. */ +class FakeTile { + static all: FakeTile[] = []; + _wsReady = false; + _stoppedCode: number | null = null; + _destroyed = false; + fontSize: number | null; + terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record }; + connect = vi.fn(async () => {}); + reconnectNow = vi.fn(); + fit = vi.fn(); + destroy = vi.fn(() => { + this._destroyed = true; + }); + constructor( + public sessionId: string, + public mountEl: FakeEl, + public opts: Record + ) { + this.fontSize = (opts.fontSize as number) ?? null; + FakeTile.all.push(this); + } +} + +let observerCallback: ((list: { getEntries(): unknown[] }) => void) | null = null; +let clock = 100_000; +const localStore = new Map(); + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); +const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + innerWidth: 2400, + innerHeight: 1200, +}; +const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn() }, + performance: { now: () => clock }, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + requestIdleCallback: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + PerformanceObserver: class { + constructor(cb: (list: { getEntries(): unknown[] }) => void) { + observerCallback = cb; + } + observe() {} + disconnect() {} + }, + fetch: vi.fn(), + navigator: { onLine: true }, + location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, + document: documentStub, + localStorage: { + getItem: (k: string) => localStore.get(k) ?? null, + setItem: (k: string, v: string) => localStore.set(k, String(v)), + removeItem: (k: string) => localStore.delete(k), + }, + window: windowStub, + VoiceInput: { cleanup: vi.fn() }, + MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, +}); +vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + + 'globalThis.__CodemanApp = CodemanApp;', + context +); +windowStub.TerminalTile = FakeTile; +windowStub.TileLoadQueue = class { + schedule(_t: unknown, _k: string, run: () => Promise) { + return run(); + } + drop() {} +}; +const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; + +type App = Record; + +const IDS = ['s-a', 's-b', 's-c']; + +function makeApp(): App { + const app = Object.create(CodemanApp.prototype) as App; + app.sessions = new Map( + [...IDS, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }]) + ); + app.sessionOrder = ['s-other', ...IDS]; + app.detachedSessions = new Set(); + app.isSoloWindow = false; + app.activeSessionId = 's-a'; + app._selectGeneration = 0; + app._initGeneration = 1; + app._xtermSnapshots = new Map(); + app.terminalBufferCache = new Map(); + app._pendingDeliveries = new Map(); + app._closingSessions = new Set(); + app.pendingHooks = new Map(); + app.isOnline = true; + app._connectionStatus = 'connected'; + app._wsState = 'disconnected'; + app._lastResizeDims = { cols: 100, rows: 30 }; + app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } }; + // Everything around the grid that is not under test here. + for (const name of [ + '_cleanupPreviousSession', + 'hideWelcome', + 'showWelcome', + 'markIdleAlertSeen', + 'renderSessionTabs', + '_updateActiveTabImmediate', + '_refreshSessionPanels', + '_updateSseSubscription', + '_updateConnectionIndicator', + '_activateFileBrowserSession', + '_hideWebviewLayer', + 'closeSessionSidebarOnHandheld', + 'updateAttachmentHistoryBadge', + 'refreshHostWakeBanner', + 'selectSession', + 'batchTerminalWrite', + '_connectWs', + '_geometryForResizeRequest', + 'updateCost', + '_notifySession', + 'connectSSE', + '_updateConnectionLossUi', + ]) { + app[name] = vi.fn(); + } + app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 })); + app.loadAppSettingsFromStorage = () => ({}); + return app; +} + +beforeEach(() => { + FakeTile.all = []; + localStore.clear(); + windowStub.innerWidth = 2400; + section.children = []; + main.className = 'main'; +}); + +describe('parking the main terminal', () => { + it('opening parks it ONCE and puts a tile per session in its place, focused tile active', () => { + const app = makeApp(); + expect(app.openTileGrid(IDS, { focusedId: 's-b' })).toBe(true); + + expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1); + expect(main.classList.contains('tiles-active')).toBe(true); + expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS); + expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + expect(app.activeSessionId).toBe('s-b'); + expect(app._tilesOwnTerminal()).toBe(true); + // Grid tiles: the one load queue, the smaller scrollback, a bounded load. + expect(FakeTile.all[0].opts).toMatchObject({ scrollback: 10000, boundedLoad: true }); + expect(typeof FakeTile.all[0].opts.scheduleLoad).toBe('function'); + }); + + it('the focused tile connects first, so its capture leads the queue', () => { + const app = makeApp(); + const order: string[] = []; + app.openTileGrid(IDS, { focusedId: 's-c' }); + for (const t of FakeTile.all) order.push(`${t.sessionId}:${t.connect.mock.invocationCallOrder[0]}`); + const first = FakeTile.all.reduce((a, b) => + a.connect.mock.invocationCallOrder[0] < b.connect.mock.invocationCallOrder[0] ? a : b + ); + expect(first.sessionId).toBe('s-c'); + }); + + it('refuses below the desktop width, in a solo window, and with no live session', () => { + const narrow = makeApp(); + windowStub.innerWidth = 1179; + expect(narrow.openTileGrid(IDS)).toBe(false); + windowStub.innerWidth = 2400; + + const solo = makeApp(); + solo.isSoloWindow = true; + expect(solo.openTileGrid(IDS)).toBe(false); + + const none = makeApp(); + expect(none.openTileGrid(['gone'])).toBe(false); + expect(none._cleanupPreviousSession).not.toHaveBeenCalled(); + }); + + it('never tiles a detached session', () => { + const app = makeApp(); + app.detachedSessions.add('s-b'); + app.openTileGrid(IDS); + expect(FakeTile.all.map((t) => t.sessionId)).toEqual(['s-a', 's-c']); + }); + + it('closing destroys every tile and replays the focused session fresh in the single view', () => { + const app = makeApp(); + app.openTileGrid(IDS, { focusedId: 's-b' }); + app.closeTileGrid(); + + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true); + expect(main.classList.contains('tiles-active')).toBe(false); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app._lastResizeDims).toBeNull(); + expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true, auto: true }); + }); + + it("closing invalidates the main terminal's cached content for EVERY tiled id, and only those", () => { + const app = makeApp(); + for (const id of [...IDS, 's-other']) { + app._xtermSnapshots.set(id, 'old'); + app.terminalBufferCache.set(id, 'old'); + localStore.set(`codeman-xs-${id}`, 'old'); + } + app.openTileGrid(IDS); + app.closeTileGrid(); + + for (const id of IDS) { + expect(app._xtermSnapshots.has(id)).toBe(false); + expect(app.terminalBufferCache.has(id)).toBe(false); + expect(localStore.has(`codeman-xs-${id}`)).toBe(false); + } + expect(app._xtermSnapshots.get('s-other')).toBe('old'); + expect(localStore.get('codeman-xs-s-other')).toBe('old'); + }); + + it('closing with reselect: false leaves the next selection to the caller', () => { + const app = makeApp(); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + expect(app.selectSession).not.toHaveBeenCalled(); + }); +}); + +/** An app with the grid open on IDS (focus s-a), or closed, for the paired guard checks. */ +function appWithGrid(open: boolean) { + const app = makeApp(); + if (open) app.openTileGrid(IDS); + return app; +} + +describe('guards: each stands aside while tiles own the terminal, and acts otherwise', () => { + it.each([true, false])('_onSessionTerminal (grid open: %s)', (open) => { + const app = appWithGrid(open); + app.pendingWrites = []; + app._onSessionTerminal({ id: 's-a', data: 'output' }); + expect(app.batchTerminalWrite).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_onSessionNeedsRefresh (grid open: %s)', async (open) => { + const app = appWithGrid(open); + const result = await app._onSessionNeedsRefresh({ id: 's-a' }); + if (open) expect(result).toBe(false); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_onSessionClearTerminal (grid open: %s)', async (open) => { + const app = appWithGrid(open); + app._resetTerminalForReplay = vi.fn(); + app.sendResize = vi.fn(); + await app._onSessionClearTerminal({ id: 's-a' }); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_scheduleDroppedOutputRecovery (grid open: %s)', (open) => { + const app = appWithGrid(open); + app._scheduleDroppedOutputRecovery('s-a', 0, 1024); + expect(!!app._clientDropRecoveryTimer).toBe(!open); + clearTimeout(app._clientDropRecoveryTimer); + }); + + it.each([true, false])( + '_onSessionCompletion writes into the main terminal only when it is not parked (open: %s)', + (open) => { + const app = appWithGrid(open); + app.totalCost = 0; + app._onSessionCompletion({ id: 's-a', cost: 0.5 }); + expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 2); + expect(app.totalCost).toBe(0.5); + } + ); + + it.each([true, false])( + '_onSessionError writes into the main terminal only when it is not parked (open: %s)', + (open) => { + const app = appWithGrid(open); + app._onSessionError({ id: 's-a', error: 'boom' }); + expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 1); + // The notification is not the terminal's: it fires either way. + expect(app._notifySession).toHaveBeenCalledTimes(1); + } + ); + + it.each([true, false])('retryConnection re-arms the tiles, never the parked main socket (open: %s)', (open) => { + const app = appWithGrid(open); + app.retryConnection(); + clearTimeout(app._offlineRetryTimer); + expect(app._connectWs).toHaveBeenCalledTimes(open ? 0 : 1); + if (open) expect(FakeTile.all.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true); + }); + + it.each([true, false])('sendResize backstop (grid open: %s)', async (open) => { + const app = appWithGrid(open); + expect(await app.sendResize('s-a')).toBe(false); + expect(app._geometryForResizeRequest).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_maybeRefetchFullHistory backstop (grid open: %s)', async (open) => { + const app = appWithGrid(open); + app._fullHistoryRepullAt = new Map(); + app._fullHistoryRepullUseless = new Set(); + app._fetchTerminalCapture = vi.fn(async () => { + throw new Error('stop here'); + }); + await app._maybeRefetchFullHistory().catch(() => {}); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])( + 'the WebGL long-task observer counts nothing while tiles own the terminal (open: %s)', + (open) => { + const app = appWithGrid(open); + app._webglAddon = { dispose: vi.fn() }; + app._webglLongTaskObserver = null; + app._disableWebGLSticky = vi.fn(); + app._scheduleTerminalRepaint = vi.fn(); + app._installWebGLLongTaskGuard(); + clock += 60_000; // past the install grace period + const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clock - 100 * i })); + observerCallback?.({ getEntries: () => longTasks }); + expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1); + } + ); +}); + +describe('handleInit with the grid open (SSE back after a server restart)', () => { + function init(app: App, liveIds: string[]) { + app._initGeneration = 1; // gen > 1 on this call: a reconnect, not a page load + app._resetAllAppState = vi.fn(() => app.sessions.clear()); + for (const name of [ + '_clearTimer', + '_updateCjkInputState', + 'syncSessionOrder', + '_loadTabLayout', + 'cleanupAllFloatingWindows', + 'startSystemStatsPolling', + 'stopSystemStatsPolling', + ]) { + app[name] = vi.fn(); + } + app.$ = () => null; + app._onSessionNeedsRefresh = vi.fn(async () => false); + app.handleInit({ + sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })), + scheduledRuns: [], + }); + } + + it('keeps live tiles (never rebuilt), reconnects them now, and never touches the main socket', () => { + const app = appWithGrid(true); + const before = [...FakeTile.all]; + init(app, [...IDS, 's-other']); + + expect(FakeTile.all).toEqual(before); + expect(before.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + expect(before.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true); + expect(app._connectWs).not.toHaveBeenCalled(); + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + expect(app.selectSession).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('removes the tile of a session that did not come back and focuses a live one', () => { + const app = appWithGrid(true); + init(app, ['s-b', 's-c', 's-other']); + + const tileA = FakeTile.all.find((t) => t.sessionId === 's-a'); + expect(tileA?.destroy).toHaveBeenCalledTimes(1); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app._connectWs).not.toHaveBeenCalled(); + }); + + it('leaves an active web tab alone when the focused tile is still live (no re-select on every SSE blip)', () => { + const app = appWithGrid(true); + app.activeWebviewId = 'w1'; + app._hideWebviewLayer.mockClear(); + init(app, [...IDS, 's-other']); + expect(app._hideWebviewLayer).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('without the grid, the same reconnect resyncs the main terminal (the guard is what differs)', () => { + const app = appWithGrid(false); + init(app, [...IDS, 's-other']); + expect(app._connectWs).toHaveBeenCalledWith('s-a'); + }); +}); + +describe('the header connection state follows the tile sockets', () => { + it('reads connected only when every live tile socket is open; a stopped tile does not count', () => { + const app = appWithGrid(true); + // The main socket is parked on purpose; it must not decide the header. + app._wsState = 'disconnected'; + for (const t of FakeTile.all) t._wsReady = true; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected'); + + FakeTile.all[1]._wsReady = false; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot reconnecting'); + + FakeTile.all[1]._stoppedCode = 4009; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected'); + }); +}); + +describe('panes and fonts while the grid is open', () => { + it('_focusedPane() is the focused tile even when DOM focus left every terminal', () => { + const app = appWithGrid(true); + app._noteFocusedTile(null); + const pane = app._focusedPane(); + expect(pane.isPrimary).toBe(false); + expect(pane.sessionId).toBe('s-a'); + }); + + it('_forEachTile reaches every grid tile; { grid: false } skips them', () => { + const app = appWithGrid(true); + const seen: string[] = []; + app._forEachTile((t: FakeTile) => seen.push(t.sessionId)); + expect(seen).toEqual(IDS); + const none: string[] = []; + app._forEachTile((t: FakeTile) => none.push(t.sessionId), { grid: false }); + expect(none).toEqual([]); + }); + + it('Ctrl +/- sizes the tiles (their own per-device font), refitting each, not the main terminal', () => { + const app = appWithGrid(true); + app.setFontSize = vi.fn(); + app.increaseFontSize(); + expect(app.setFontSize).not.toHaveBeenCalled(); + expect(localStore.get('codeman-tile-font-size')).toBe('15'); + expect(FakeTile.all.every((t) => t.terminal.options.fontSize === 15 && t.fit.mock.calls.length > 0)).toBe(true); + }); +}); From 1a04a75c3d3e2e9b840597e1f2b7af481d1c8350 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:24:52 +0200 Subject: [PATCH 05/90] feat(tiles): selections with the grid open focus tiles, and never collapse it by themselves selectSession gets the tile branch, right after its "already active" early return: a tiled session is focused in its tile (_selectTiledSession: an activeSessionId change, the shared _refreshSessionPanels and xterm.focus(), no cleanup, replay, resize or socket of the parked main terminal). Decision 1: a USER-initiated pick of a session that is not tiled leaves the grid for the single view (the grid is remembered), and so does an explicit leaveTiles; an app-driven pick (auto) never collapses it. A followed #session= link passes leaveTiles (navigation). App-driven paths pick a tile instead of the first sessionOrder entry: - closeSession on the focused tile focuses the neighbouring tile (next in grid order, else previous), captured before the await like wasActive, since the delete broadcast may already have removed the tile; the last tile closes the grid and falls back to the normal pick. - A tiled session deleted elsewhere loses its tile and a neighbour takes focus with auto (the last one lands on the welcome screen as before); a close from this tab only drops the tile and leaves the follow-up to closeSession. - A tiled session popped out to its own window leaves the grid. Focus rules: pressing a tile is a human selection (pointerdown, never preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a human selection acknowledges an idle alert. Home leaves the grid (remembered); killing every session closes it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 50 +++++- src/web/public/tile-grid.js | 30 ++++ test/mocks/tile-grid-vm.ts | 240 ++++++++++++++++++++++++++ test/session-select-ack-gate.test.ts | 56 +++++- test/tile-grid-close-fallback.test.ts | 150 ++++++++++++++++ test/tile-grid-select-branch.test.ts | 176 +++++++++++++++++++ test/url-session-fragment.test.ts | 6 +- 7 files changed, 702 insertions(+), 6 deletions(-) create mode 100644 test/mocks/tile-grid-vm.ts create mode 100644 test/tile-grid-close-fallback.test.ts create mode 100644 test/tile-grid-select-branch.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index cda1a41f..6b1e59ae 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1471,7 +1471,9 @@ class CodemanApp { return false; } this._retireUrlSession(); - this.selectSession(id, { auto: true }); + // Following a link is navigation: a tiled id focuses its tile, any other + // leaves the tile grid for the single view (the grid is remembered). + this.selectSession(id, { auto: true, leaveTiles: true }); return true; } @@ -1610,6 +1612,9 @@ class CodemanApp { * Full re-renders re-apply the class from this.detachedSessions. */ _markDetached(id, on) { if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id); + // A popped-out session's window owns its PTY size now, so it leaves the + // tile grid (one place per session in this browser tab). + if (on && this._tileGrid?.has(id)) this.removeTile(id, { refocus: true, auto: true }); const container = this.$('sessionTabs'); const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`); if (tab) tab.classList.toggle('detached', on); @@ -7973,6 +7978,20 @@ class CodemanApp { if (userInitiated) this.markIdleAlertSeen(sessionId); return; } + // Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and + // never loaded into the parked main terminal. Decision 1: only a USER- + // initiated pick of a session that is not tiled (or an explicit + // `leaveTiles`, a followed link) leaves the grid for the single view, the + // grid remembered for one-click return. An app-driven pick (`auto`) never + // collapses it. + if (this._tileGrid?.open) { + if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options); + if (options?.auto === true && !options?.leaveTiles) return; + this.closeTileGrid({ keepStored: true, reselect: false }); + // The parked terminal still holds what it showed before the grid opened; + // with no active id, the switch below snapshots none of it. + this.activeSessionId = null; + } if (this.activeSessionId === sessionId && forceReload) { this.terminalBufferCache?.delete(sessionId); this._xtermSnapshots?.delete(sessionId); @@ -8828,12 +8847,24 @@ class CodemanApp { // next session or dumped you on the home screen, depending on which path // won the race (both outcomes measured on one build, 2026-08-17). const wasActive = this.activeSessionId === sessionId; + // Tile grid open: the fallback is the NEIGHBOURING TILE, never the first + // sessionOrder entry (often not tiled, which would collapse the grid). + // Captured here for the same reason as wasActive: the SSE delete can remove + // the tile while the request is still in flight. + const grid = this._tileGrid; + const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null; this._closingSessions.add(sessionId); try { await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`); this._cleanupSessionData(sessionId); + // The last tile leaving closes the grid (no reselect): the pick below runs. + if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false }); - if (wasActive) { + if (wasActive && grid?.open) { + // `auto`: the app chose this tile because the previous one went away. + const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0]; + this._selectTiledSession(target, { auto: true }); + } else if (wasActive) { this.activeSessionId = null; try { localStorage.removeItem('codeman-active-session'); } catch {} // Next tab in the user's own order, skipping ids the cleanup has not @@ -8918,6 +8949,12 @@ class CodemanApp { } nextSession() { + // With the tile grid open, Ctrl+Tab and Alt+] cycle through the tiles. + if (this._tileGrid?.open) { + const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, 1); + if (id) this.selectSession(id); + return; + } if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); @@ -8926,6 +8963,11 @@ class CodemanApp { } prevSession() { + if (this._tileGrid?.open) { + const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, -1); + if (id) this.selectSession(id); + return; + } if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); @@ -8941,6 +8983,8 @@ class CodemanApp { // Going Home is choosing something else, so a `#session=` link still // waiting for its session must not take the screen later. this._retireUrlSession(); + // Home is a choice to leave the grid too; it is remembered for Tiles. + this.closeTileGrid?.({ keepStored: true, reselect: false }); // Deselect active session and show welcome screen this.activeSessionId = null; try { localStorage.removeItem('codeman-active-session'); } catch {} @@ -8991,6 +9035,8 @@ class CodemanApp { try { await this._apiDelete('/api/sessions'); + // Every tiled session is gone: nothing left to remember or reselect. + this.closeTileGrid?.({ keepStored: false, reselect: false }); this.sessions.clear(); this.terminalBuffers.clear(); this.terminalBufferCache.clear(); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index adf15291..bc96e631 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -309,6 +309,15 @@ Object.assign(CodemanApp.prototype, { const body = document.createElement('div'); body.className = 'tile-body'; el.appendChild(body); + // Pressing a tile is a human selection: it focuses the tile and + // acknowledges its idle alert (the already-focused tile hits + // selectSession's early return, which acknowledges too). pointerdown, not + // click, so focus moves before the press reaches xterm, and never + // preventDefault: xterm's own mousedown focuses its textarea and starts + // selections. + el.addEventListener('pointerdown', () => { + if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); + }); this._tileGridSection().appendChild(el); const tile = new window.TerminalTile(sessionId, body, { mode: session.mode, @@ -479,3 +488,24 @@ Object.assign(CodemanApp.prototype, { return true; }, }); + +// A tiled session deleted (here or elsewhere) loses its tile; if it held focus, +// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is +// spent). Done BEFORE the original handler, so activeSessionId no longer names +// the deleted id and its welcome-screen handoff stays out of it. The last tile +// closes the grid without a reselect, and the original handler then shows the +// welcome screen as in the single view. A close started from this tab +// (closeSession, in _closingSessions) owns its own follow-up: only the tile goes. +const _tileGridOriginalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted; +CodemanApp.prototype._onSessionDeleted = function (data) { + const grid = this._tileGrid; + if (grid?.has(data.id)) { + const wasFocused = grid.focusedId === data.id; + const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); + this.removeTile(data.id, { refocus: false }); + if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { + this._selectTiledSession(neighbor, { auto: true }); + } + } + return _tileGridOriginalOnSessionDeleted.call(this, data); +}; diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts new file mode 100644 index 00000000..bb85244a --- /dev/null +++ b/test/mocks/tile-grid-vm.ts @@ -0,0 +1,240 @@ +/** + * @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 + * context, a small fake DOM (just what the grid touches) and a fake + * TerminalTile that records what the grid asks of it. + * + * `makeGridApp()` returns an app instance with everything around the grid that + * a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs + * (or deletes a stub to run the real method from the prototype). + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { vi } from 'vitest'; + +/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ +export class FakeEl { + id = ''; + className = ''; + dataset: Record = {}; + style: Record = {}; + children: FakeEl[] = []; + parentElement: FakeEl | null = null; + attrs: Record = {}; + listeners: Record void>> = {}; + classList = { + add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)), + remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)), + toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)), + contains: (n: string) => this.className.split(/\s+/).includes(n), + }; + _setClass(name: string, on: boolean) { + const set = new Set(this.className.split(/\s+/).filter(Boolean)); + if (on) set.add(name); + else set.delete(name); + this.className = [...set].join(' '); + return on; + } + appendChild(child: FakeEl) { + child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child: FakeEl, ref: FakeEl | null) { + child.remove(); + child.parentElement = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i === -1) this.children.push(child); + else this.children.splice(i, 0, child); + return child; + } + get nextSibling() { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + siblings.splice(siblings.indexOf(this), 1); + this.parentElement = null; + } + setAttribute(k: string, v: string) { + this.attrs[k] = v; + } + getAttribute(k: string) { + return this.attrs[k] ?? null; + } + addEventListener(type: string, fn: (ev: unknown) => void) { + (this.listeners[type] ||= []).push(fn); + } + removeEventListener() {} + dispatch(type: string, ev: unknown = {}) { + for (const fn of this.listeners[type] ?? []) fn(ev); + } + getBoundingClientRect() { + return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 }; + } + querySelector() { + return null; + } +} + +/** A TerminalTile stand-in: records what the grid asks of it. */ +export class FakeTile { + static all: FakeTile[] = []; + _wsReady = false; + _stoppedCode: number | null = null; + _destroyed = false; + fontSize: number | null; + terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record }; + connect = vi.fn(async () => {}); + reconnectNow = vi.fn(); + fit = vi.fn(); + destroy = vi.fn(() => { + this._destroyed = true; + }); + onExit: ((code: number) => void) | null; + constructor( + public sessionId: string, + public mountEl: FakeEl, + public opts: Record + ) { + this.fontSize = (opts.fontSize as number) ?? null; + this.onExit = (opts.onExit as (code: number) => void) ?? null; + FakeTile.all.push(this); + } +} + +export const main = new FakeEl(); +main.className = 'main'; +const wrap = new FakeEl(); +wrap.className = 'terminal-wrap'; +main.appendChild(wrap); +export const section = new FakeEl(); +section.id = 'tileGrid'; +section.className = 'tile-grid'; +main.appendChild(section); + +export const localStore = new Map(); +/** What the code under test deferred with requestIdleCallback; a test runs them. */ +export const idleCallbacks: Array<() => void> = []; +export const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + innerWidth: 2400, + innerHeight: 1200, +}; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8'); +const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + performance: { now: () => 100_000 }, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + navigator: { onLine: true }, + location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, + history: { replaceState: vi.fn(), state: null }, + document: { + addEventListener: vi.fn(), + documentElement: { dataset: {} }, + createElement: () => new FakeEl(), + getElementById: (id: string) => (id === 'tileGrid' ? section : null), + querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), + querySelectorAll: () => [], + }, + localStorage: { + getItem: (k: string) => localStore.get(k) ?? null, + setItem: (k: string, v: string) => localStore.set(k, String(v)), + removeItem: (k: string) => localStore.delete(k), + }, + window: windowStub, + VoiceInput: { cleanup: vi.fn() }, + MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, +}); +vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + + 'globalThis.__CodemanApp = CodemanApp;', + context +); +windowStub.TerminalTile = FakeTile; +windowStub.TileLoadQueue = class { + schedule(_t: unknown, _k: string, run: () => Promise) { + return run(); + } + drop() {} +}; + +export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record } }).__CodemanApp; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GridApp = Record; + +/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */ +const STUBS = [ + '_cleanupPreviousSession', + 'hideWelcome', + 'showWelcome', + 'markIdleAlertSeen', + 'renderSessionTabs', + 'renderRalphStatePanel', + 'renderProjectInsightsPanel', + '_updateActiveTabImmediate', + '_refreshSessionPanels', + '_updateSseSubscription', + '_updateConnectionIndicator', + '_activateFileBrowserSession', + '_hideWebviewLayer', + 'closeSessionSidebarOnHandheld', + 'updateAttachmentHistoryBadge', + 'refreshHostWakeBanner', + '_connectWs', + 'sendResize', + 'showToast', + '_cleanupSessionData', + 'stopSystemStatsPolling', +]; + +/** + * An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and + * never tiled), `s-a` active, nothing open. + */ +export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp { + const app = Object.create(CodemanApp.prototype) as GridApp; + app.sessions = new Map( + [...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }]) + ); + app.sessionOrder = ['s-other', ...ids]; + app.detachedSessions = new Set(); + app.isSoloWindow = false; + app.activeSessionId = ids[0]; + app._selectGeneration = 0; + app._xtermSnapshots = new Map(); + app.terminalBufferCache = new Map(); + app._pendingDeliveries = new Map(); + app._closingSessions = new Set(); + app.pendingHooks = new Map(); + app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } }; + for (const name of STUBS) app[name] = vi.fn(); + app.loadAppSettingsFromStorage = () => ({}); + return app; +} + +/** Resets the shared fake DOM and tile registry between tests. */ +export function resetGridHarness() { + FakeTile.all = []; + idleCallbacks.length = 0; + localStore.clear(); + windowStub.innerWidth = 2400; + section.children = []; + main.className = 'main'; +} diff --git a/test/session-select-ack-gate.test.ts b/test/session-select-ack-gate.test.ts index 904b5de9..cc3a73af 100644 --- a/test/session-select-ack-gate.test.ts +++ b/test/session-select-ack-gate.test.ts @@ -110,6 +110,60 @@ describe('selectSession acknowledgement gate', () => { }); }); + describe('with the tile grid open (tile-grid.js)', () => { + // A plain stand-in for the grid: the branch reads only `open` and `has(id)`. + function withGrid(app: ReturnType, tiled: string[]) { + const a = app as Record; + a._tileGrid = { open: true, has: (id: string) => tiled.includes(id) }; + a._selectTiledSession = vi.fn(); + a.closeTileGrid = vi.fn(); + a._connectWs = vi.fn(); + return a; + } + + it('a tiled session goes to its tile, never the main terminal, and the tile decides the ack', async () => { + const app = withGrid(makeApp('other'), [SID]); + await (app.selectSession as (id: string) => Promise)(SID); + expect(app._selectTiledSession).toHaveBeenCalledWith(SID, {}); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app._connectWs).not.toHaveBeenCalled(); + // The branch returns before the main path's own acknowledgement line. + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('passes `auto` through, so an app-driven focus stays unacknowledged', async () => { + const app = withGrid(makeApp('other'), [SID]); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { auto: true }); + expect(app._selectTiledSession).toHaveBeenCalledWith(SID, { auto: true }); + }); + + it('an `auto` selection of a session that is not tiled leaves the grid open and acknowledges nothing', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { auto: true }); + expect(app.closeTileGrid).not.toHaveBeenCalled(); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('a user-initiated one leaves the grid (remembered) and switches normally, acknowledging', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string) => Promise)(SID).catch(() => {}); + expect(app.closeTileGrid).toHaveBeenCalledWith({ keepStored: true, reselect: false }); + expect(app._cleanupPreviousSession).toHaveBeenCalled(); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID); + }); + + it('`leaveTiles` leaves it too, without acknowledging an `auto` pick', async () => { + const app = withGrid(makeApp('other'), []); + await (app.selectSession as (id: string, o: object) => Promise)(SID, { + auto: true, + leaveTiles: true, + }).catch(() => {}); + expect(app.closeTileGrid).toHaveBeenCalledTimes(1); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + }); + describe('the call sites the app drives itself', () => { // Source guard: these call sites are the reason the flag exists. If a refactor // moves or reformats them, fail loudly rather than silently going back to @@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => { ['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'], ['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'], ['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'], - ['a #session= link from another page', 'this.selectSession(id, { auto: true });'], + ['a #session= link from another page', 'this.selectSession(id, { auto: true, leaveTiles: true });'], ])('%s passes auto: true', (_label, call) => { expect(APP_SOURCE).toContain(call); }); diff --git a/test/tile-grid-close-fallback.test.ts b/test/tile-grid-close-fallback.test.ts new file mode 100644 index 00000000..46eb8479 --- /dev/null +++ b/test/tile-grid-close-fallback.test.ts @@ -0,0 +1,150 @@ +/** + * @fileoverview Closing or deleting a tiled session keeps the grid open and + * moves focus to the NEIGHBOURING tile. + * + * `closeSession()` normally falls back to the first remaining `sessionOrder` + * entry with `auto: true`, and that entry is often NOT tiled: an app-driven + * pick that, with the grid open, would be refused (auto never collapses the + * grid) and leave nothing focused. So the fallback is grid-aware, and it lives + * IN closeSession: the delete broadcast routinely lands while the request is in + * flight, and the delete handlers skip ids in `_closingSessions`. The neighbour + * is captured BEFORE the await, like `wasActive`, because that broadcast may + * already have removed the tile. + * + * - closing the focused tile: next tile in grid order, else the previous one; + * `s-other` is FIRST in sessionOrder and never tiled, so the old pick would + * have collapsed the grid; + * - the same with the broadcast arriving mid-request and after it; + * - closing the last tile closes the grid and falls back to the normal pick; + * - a tiled session deleted ELSEWHERE: its tile goes, a neighbour takes focus + * with `auto` (no idle alert spent); the last one leaves the welcome screen. + * + * 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 { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +/** A grid on `ids` focused on `focus`, with the DELETE request held open until `finish()`. */ +function setup(ids = IDS, focus = ids[0]) { + const app = makeGridApp(ids); + app.openTileGrid(ids, { focusedId: focus }); + app.selectSession = vi.fn(); + app.markIdleAlertSeen.mockClear(); + let finish: () => void = () => {}; + app._apiDelete = vi.fn(() => new Promise((r) => (finish = r))); + // The real cleanup touches a lot of panels; what the fallback reads is the session list. + app._cleanupSessionData = vi.fn((id: string) => { + app.sessions.delete(id); + app.sessionOrder = app.sessionOrder.filter((s: string) => s !== id); + }); + return { app: app as GridApp, finish: () => finish() }; +} + +async function settle() { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('closeSession on the focused tile', () => { + it('keeps the grid open and focuses the next tile, never the untiled first sessionOrder entry', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.selectSession).not.toHaveBeenCalled(); + // The app chose the neighbour: no idle alert spent. + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('the last tile in grid order hands focus back to the previous one', async () => { + const { app, finish } = setup(IDS, 's-c'); + const closing = app.closeSession('s-c'); + finish(); + await closing; + expect(app.activeSessionId).toBe('s-b'); + }); + + it('the delete broadcast arriving DURING the request changes nothing about the outcome', async () => { + const { app, finish } = setup(IDS, 's-b'); + const closing = app.closeSession('s-b'); + await settle(); + app._onSessionDeleted({ id: 's-b' }); + // Only the tile went; closeSession owns the follow-up (as the split's + // wrapper does for ids in _closingSessions), so focus has not moved yet. + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.showWelcome).not.toHaveBeenCalled(); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-c'); + expect(app.showWelcome).not.toHaveBeenCalled(); + }); + + it('the delete broadcast arriving AFTER the request is a no-op for the grid', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + app._onSessionDeleted({ id: 's-a' }); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + }); + + it('closing the LAST tile closes the grid and falls back to the normal pick', async () => { + const { app, finish } = setup(['s-a'], 's-a'); + const closing = app.closeSession('s-a'); + finish(); + await closing; + + expect(app._tilesOwnTerminal()).toBe(false); + expect(FakeTile.all[0].destroy).toHaveBeenCalledTimes(1); + expect(app.selectSession).toHaveBeenCalledWith('s-other', { auto: true }); + }); + + it('closing a tile that is NOT focused removes it and leaves focus alone', async () => { + const { app, finish } = setup(IDS, 's-a'); + const closing = app.closeSession('s-c'); + finish(); + await closing; + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + }); +}); + +describe('a tiled session deleted elsewhere', () => { + it('removes its tile and moves focus to the neighbour with `auto`', () => { + const { app } = setup(IDS, 's-b'); + app._onSessionDeleted({ id: 's-b' }); + + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + expect(app.showWelcome).not.toHaveBeenCalled(); + }); + + it('a deleted tile that was not focused just goes', () => { + const { app } = setup(IDS, 's-a'); + app._onSessionDeleted({ id: 's-c' }); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('the last tile deleted closes the grid and lands on the welcome screen, as in the single view', () => { + const { app } = setup(['s-a'], 's-a'); + app._onSessionDeleted({ id: 's-a' }); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.activeSessionId).toBeNull(); + expect(app.showWelcome).toHaveBeenCalled(); + }); +}); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts new file mode 100644 index 00000000..611785f7 --- /dev/null +++ b/test/tile-grid-select-branch.test.ts @@ -0,0 +1,176 @@ +/** + * @fileoverview The tile branch of `selectSession`, and focus moving between tiles. + * + * With the tile grid open the main terminal is parked, so a selection must + * never reach the half of selectSession that cleans it up, replays into it or + * reconnects its socket. A TILED session is focused in its tile + * (`_selectTiledSession`: an `activeSessionId` change plus `xterm.focus()`, the + * same panel refresh as a normal switch). Decision 1: a USER-initiated pick of + * a session that is NOT tiled leaves the grid for the single view (the grid is + * remembered), and so does an explicit `leaveTiles` (a followed link); an + * app-driven pick (`auto: true`) never collapses the grid. + * + * Focus and alert rules: only a human selection acknowledges an idle alert. + * Pressing a tile, Ctrl+Tab / Alt+[ ] (which cycle through the tiles) and a tab + * click are human; the delete fallback and a popped-out tile are the app's. + * + * Real code: constants.js + app.js + terminal-ui.js + tile-grid.js (the shared + * vm harness in test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeTile, idleCallbacks, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +/** A grid open on IDS (focus s-a), with selectSession REAL and the main terminal's select path tripwired. */ +function openGrid(): GridApp { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + delete app.selectSession; // the prototype's, for real + app._cleanupPreviousSession.mockClear(); + app.markIdleAlertSeen.mockClear(); + // Anything past the tile branch: the main terminal's own select path. + app._shouldFocusTerminalForTabSwitch = vi.fn(() => false); + app._setTerminalLoadState = vi.fn(() => { + throw new Error('the main terminal select path ran'); + }); + return app; +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('selectSession with the grid open', () => { + it('focuses a tiled session in its tile and never touches the main terminal', async () => { + const app = openGrid(); + await app.selectSession('s-b'); + + expect(app.activeSessionId).toBe('s-b'); + expect(app._tileGrid.focusedId).toBe('s-b'); + expect(app._cleanupPreviousSession).not.toHaveBeenCalled(); + expect(app._connectWs).not.toHaveBeenCalled(); + expect(app.sendResize).not.toHaveBeenCalled(); + expect(FakeTile.all.find((t) => t.sessionId === 's-b')?.terminal.focus).toHaveBeenCalled(); + expect(app._focusedPane().sessionId).toBe('s-b'); + }); + + it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => { + const app = openGrid(); + await app.selectSession('s-b'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b'); + + app.markIdleAlertSeen.mockClear(); + await app.selectSession('s-c', { auto: true }); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('runs the same deferred panel refresh as a normal switch, tagged with its own generation', async () => { + const app = openGrid(); + idleCallbacks.length = 0; + await app.selectSession('s-b'); + const genB = app._selectGeneration; + expect(app._refreshSessionPanels).not.toHaveBeenCalled(); // deferred + for (const cb of idleCallbacks.splice(0)) cb(); + expect(app._refreshSessionPanels).toHaveBeenCalledWith('s-b', genB); + }); + + it('an `auto` selection of a session that is NOT tiled leaves the grid open and changes nothing', async () => { + const app = openGrid(); + await app.selectSession('s-other', { auto: true }); + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-a'); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + }); + + it('a user-initiated selection of a session that is NOT tiled leaves the grid, remembered', async () => { + const app = openGrid(); + let activeAtCleanup: unknown = 'unset'; + app._cleanupPreviousSession = vi.fn(() => { + activeAtCleanup = app.activeSessionId; + }); + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._isStaleSelect = vi.fn(() => true); // stop right after the switch itself + await app.selectSession('s-other').catch(() => {}); + + expect(app._tilesOwnTerminal()).toBe(false); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true); + expect(app._tileGridRemembered).toEqual({ ids: IDS, focusedId: 's-a' }); + // The parked terminal's stale content must not be saved as s-a's snapshot. + expect(activeAtCleanup).toBeNull(); + expect(app.activeSessionId).toBe('s-other'); + }); + + it('`leaveTiles` makes even an `auto` selection leave the grid (a followed link is navigation)', async () => { + const app = openGrid(); + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._isStaleSelect = vi.fn(() => true); + await app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}); + expect(app._tilesOwnTerminal()).toBe(false); + }); + + it('a followed `#session=` link passes leaveTiles, and a tiled one just focuses its tile', async () => { + const app = openGrid(); + app._urlSessionId = 's-c'; + app._selectUrlSession(); + await Promise.resolve(); + expect(app._tilesOwnTerminal()).toBe(true); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); +}); + +describe('moving focus between tiles', () => { + it('pressing a tile is a human selection of its session', async () => { + const app = openGrid(); + app.selectSession = vi.fn(); + const tileB = section.children.find((el) => el.dataset.sessionId === 's-b'); + tileB?.dispatch('pointerdown'); + expect(app.selectSession).toHaveBeenCalledWith('s-b'); + }); + + it('Ctrl+Tab / Alt+] and Alt+[ cycle through the tiles (wrapping), user-initiated, and only the tiles', () => { + const app = openGrid(); + app.selectSession = vi.fn(); + // From the last tile, the next one wraps to the first tile; the tab order + // would have wrapped to s-other (first in sessionOrder, not tiled). + app.activeSessionId = 's-c'; + app.nextSession(); + expect(app.selectSession.mock.calls).toEqual([['s-a']]); + app.selectSession.mockClear(); + app.activeSessionId = 's-a'; + app.prevSession(); + expect(app.selectSession.mock.calls).toEqual([['s-c']]); + }); + + it('without the grid they walk the tab order as before', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.nextSession(); + expect(app.selectSession).toHaveBeenLastCalledWith('s-b'); + }); + + it('a tiled session popped out to its own window leaves the grid; a neighbour takes focus', () => { + const app = openGrid(); + app.$ = () => null; + app._markDetached('s-a', true); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('going Home leaves the grid, remembered', () => { + const app = openGrid(); + app.goHome(); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app._tileGridRemembered?.ids).toEqual(IDS); + expect(app.activeSessionId).toBeNull(); + expect(app.showWelcome).toHaveBeenCalled(); + }); +}); diff --git a/test/url-session-fragment.test.ts b/test/url-session-fragment.test.ts index 40e8ec6a..12e348ff 100644 --- a/test/url-session-fragment.test.ts +++ b/test/url-session-fragment.test.ts @@ -136,7 +136,7 @@ describe('dashboard handling of a #session= link', () => { const app = make(['a']); app._urlSessionId = 'a'; expect(app._selectUrlSession()).toBe(true); - expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true, leaveTiles: true }); expect(app._urlSessionId).toBeNull(); }); @@ -149,7 +149,7 @@ describe('dashboard handling of a #session= link', () => { app._onSessionCreated({ id: 'other', name: 'other' }); expect(app.selectSession).not.toHaveBeenCalled(); app._onSessionCreated({ id: 'new', name: 'new' }); - expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true }); expect(app._urlSessionId).toBeNull(); }); @@ -234,7 +234,7 @@ describe('a #session= link that is still waiting', () => { app._selectUrlSession(); vi.advanceTimersByTime(waitMs - 1); app._onSessionCreated({ id: 'new', name: 'new' }); - expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true }); + expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true }); expect(app._urlSessionWaitTimer).toBeNull(); vi.advanceTimersByTime(waitMs); expect(app.showToast).not.toHaveBeenCalled(); From 6adf750c502a2f062ec05df2811f9b812db5f30e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:31:29 +0200 Subject: [PATCH 06/90] 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) --- src/web/public/app.js | 59 ++++ src/web/public/styles.css | 6 + src/web/public/terminal-split.js | 5 + src/web/public/terminal-tile.js | 4 + src/web/public/terminal-ui.js | 5 + src/web/public/tile-grid.js | 109 +++++++- test/mocks/tile-grid-vm.ts | 37 ++- test/tile-grid-shortcuts.test.ts | 331 +++++++++++++++++++++++ test/tile-grid-split-coexistence.test.ts | 124 +++++++++ 9 files changed, 672 insertions(+), 8 deletions(-) create mode 100644 test/tile-grid-shortcuts.test.ts create mode 100644 test/tile-grid-split-coexistence.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 6b1e59ae..b5075b32 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -525,6 +525,56 @@ const DEFAULT_SHORTCUTS = [ bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }], 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', 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 // disables (App Settings → Shortcuts) take effect. Every dispatchable // binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ab9f2f73..0bee818a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2510,6 +2510,12 @@ html[data-tab-orientation='vertical'].tab-rail-compact 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 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 diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 8a5d839b..70df4081 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, { // the old exact-node check below) bubbled straight through to // `document` and self-closed the menu it just opened. 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) { this.closeSplitPane(); return; @@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, { // see _applySplitButtonVisibility's comment for why both a JS check and // a CSS backstop exist. 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 // (the welcome overlay is showing) — without this, a split opened from // the home screen still created the container and connected Pane B, just diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 76b3b445..6424ab7b 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -208,6 +208,10 @@ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { 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 // 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 diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d11e7f92..4dfa7364 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, { 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 // ^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 diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index bc96e631..1c7eca2a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -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 // window drag sends each tile's PTY one resize, not one per frame. 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. */ class TileGridModel { @@ -137,8 +147,18 @@ Object.assign(CodemanApp.prototype, { if (!this.canOpenTileGrid()) return false; const grid = (this._tileGrid ||= new TileGridModel()); 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 = []; - for (const id of ids || []) { + for (const id of requested) { if (typeof id !== 'string' || wanted.includes(id)) continue; if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; wanted.push(id); @@ -175,6 +195,7 @@ Object.assign(CodemanApp.prototype, { this._installTileGridWidthGate(); this._selectTiledSession(focus, { auto }); this._updateConnectionIndicator?.(); + this._updateSplitButtonForTiles(); return true; }, @@ -230,9 +251,95 @@ Object.assign(CodemanApp.prototype, { this.terminalBufferCache?.delete(id); } this._updateConnectionIndicator?.(); + this._updateSplitButtonForTiles(); 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, // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index bb85244a..e26ac6dc 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -1,7 +1,7 @@ /** * @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 - * context, a small fake DOM (just what the grid touches) and a fake + * app around it: constants.js + app.js + terminal-ui.js + terminal-split.js + + * 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. * * `makeGridApp()` returns an app instance with everything around the grid that @@ -57,7 +57,8 @@ export class FakeEl { remove() { if (!this.parentElement) return; 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; } setAttribute(k: string, v: string) { @@ -76,7 +77,15 @@ export class FakeEl { getBoundingClientRect() { 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; } } @@ -117,6 +126,11 @@ section.id = 'tileGrid'; section.className = 'tile-grid'; main.appendChild(section); +/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */ +export const bySelector = new Map(); +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(); /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; @@ -145,11 +159,13 @@ const context = vm.createContext({ location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, history: { replaceState: vi.fn(), state: null }, document: { - addEventListener: vi.fn(), + addEventListener: documentAddEventListener, documentElement: { dataset: {} }, createElement: () => new FakeEl(), 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: () => [], }, localStorage: { @@ -162,7 +178,8 @@ const context = vm.createContext({ MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, }); 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;', context ); @@ -237,4 +254,10 @@ export function resetGridHarness() { windowStub.innerWidth = 2400; section.children = []; 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 = []; } diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts new file mode 100644 index 00000000..afd7e493 --- /dev/null +++ b/test/tile-grid-shortcuts.test.ts @@ -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) => ({ + 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>; 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) => boolean) | null = null; + options: Record; + cols = 80; + rows = 24; + buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; + textarea = { addEventListener() {}, removeEventListener() {} }; + constructor(options: Record) { + this.options = options; + FakeTerminal.last = this; + } + loadAddon() {} + open() {} + onData() {} + attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { + this.keyHandler = fn; + } + registerLinkProvider() {} + write() {} + clear() {} + dispose() {} + } + const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) })); + const windowStub: Record = { + 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; 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); + }); + }); +}); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts new file mode 100644 index 00000000..2a82a5b6 --- /dev/null +++ b/test/tile-grid-split-coexistence.test.ts @@ -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); + }); +}); From 88447e6c2b7fb3d7441a37a6f1b36cf9c886e4d9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:36:06 +0200 Subject: [PATCH 07/90] fix(tiles): no black band under a tile's last row xterm paints its viewport black, and a tile's rows rarely fill it exactly, so every tile showed a black strip between its last row and its bottom edge. The main terminal's container already makes the viewport transparent; tiles get the same rule, so the gap shows the terminal background. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/styles.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 0bee818a..c756f2af 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19413,6 +19413,13 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle height: 100%; } +/* As .terminal-container's: xterm paints its viewport black, and the rows + rarely fill the tile exactly, so without this every tile had a black band + under its last row instead of the terminal background. */ +.tile-body .xterm-viewport { + background: transparent !important; +} + /* Quiet "loading" state while the tile's capture waits in the grid's one load queue (or runs). */ .tile.tile--loading .tile-body::after { From 13b2989886860ec4e2362b4defedc838a05a859d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 17:41:00 +0200 Subject: [PATCH 08/90] test(tiles): park-guards runs on the shared grid harness tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced the wrong element when a child was no longer listed (fixed in the shared copy only). It now uses the shared harness, which gains what the guards need: a settable clock behind performance.now, the PerformanceObserver callbacks the code under test registers, and localFit on the fake tile. Same 35 cases. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/mocks/tile-grid-vm.ts | 18 ++- test/tile-grid-park-guards.test.ts | 233 ++++------------------------- 2 files changed, 43 insertions(+), 208 deletions(-) diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index e26ac6dc..f9b05715 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -101,6 +101,7 @@ export class FakeTile { connect = vi.fn(async () => {}); reconnectNow = vi.fn(); fit = vi.fn(); + localFit = vi.fn(); destroy = vi.fn(() => { this._destroyed = true; }); @@ -132,6 +133,14 @@ 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(); +/** The clock behind `performance.now` inside the context; tests move it with advanceClock(). */ +let clock = 100_000; +export function advanceClock(ms: number) { + clock += ms; +} +export const clockNow = () => clock; +/** Every callback the code under test handed a PerformanceObserver, newest last. */ +export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = []; /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; export const windowStub: Record = { @@ -145,7 +154,14 @@ export const windowStub: Record = { const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8'); const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, - performance: { now: () => 100_000 }, + performance: { now: () => clock }, + PerformanceObserver: class { + constructor(cb: (list: { getEntries(): unknown[] }) => void) { + perfObserverCallbacks.push(cb); + } + observe() {} + disconnect() {} + }, setInterval: vi.fn(), clearInterval: vi.fn(), setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), diff --git a/test/tile-grid-park-guards.test.ts b/test/tile-grid-park-guards.test.ts index c51c6a08..b995ce60 100644 --- a/test/tile-grid-park-guards.test.ts +++ b/test/tile-grid-park-guards.test.ts @@ -25,217 +25,41 @@ * tile destroyed, `_lastResizeDims` reset, the main terminal's cached content * for EVERY tiled id invalidated, and the focused session replayed fresh. * - * Real code: constants.js + app.js + terminal-ui.js + tile-grid.js in one `vm` - * context, with a small fake DOM and a fake TerminalTile. Port: N/A. + * Real code: constants.js + app.js + terminal-ui.js + terminal-split.js + + * tile-grid.js in one `vm` context, with the shared fake DOM and fake + * TerminalTile (test/mocks/tile-grid-vm.ts). Port: N/A. */ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import vm from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeTile, + advanceClock, + clockNow, + localStore, + main, + makeGridApp, + perfObserverCallbacks, + resetGridHarness, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; -/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ -class FakeEl { - id = ''; - className = ''; - dataset: Record = {}; - style: Record = {}; - children: FakeEl[] = []; - parentElement: FakeEl | null = null; - attrs: Record = {}; - listeners: Record void>> = {}; - classList = { - add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)), - remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)), - toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)), - contains: (n: string) => this.className.split(/\s+/).includes(n), - }; - _setClass(name: string, on: boolean) { - const set = new Set(this.className.split(/\s+/).filter(Boolean)); - if (on) set.add(name); - else set.delete(name); - this.className = [...set].join(' '); - return on; - } - appendChild(child: FakeEl) { - child.remove(); - child.parentElement = this; - this.children.push(child); - return child; - } - insertBefore(child: FakeEl, ref: FakeEl | null) { - child.remove(); - child.parentElement = this; - const i = ref ? this.children.indexOf(ref) : -1; - if (i === -1) this.children.push(child); - else this.children.splice(i, 0, child); - return child; - } - get nextSibling() { - const siblings = this.parentElement?.children ?? []; - return siblings[siblings.indexOf(this) + 1] ?? null; - } - remove() { - if (!this.parentElement) return; - const siblings = this.parentElement.children; - siblings.splice(siblings.indexOf(this), 1); - this.parentElement = null; - } - setAttribute(k: string, v: string) { - this.attrs[k] = v; - } - addEventListener(type: string, fn: (ev: unknown) => void) { - (this.listeners[type] ||= []).push(fn); - } - removeEventListener() {} - getBoundingClientRect() { - return { width: 2400, height: 1200 }; - } - querySelector() { - return null; - } -} - -const main = new FakeEl(); -main.className = 'main'; -const wrap = new FakeEl(); -wrap.className = 'terminal-wrap'; -main.appendChild(wrap); -const section = new FakeEl(); -section.id = 'tileGrid'; -section.className = 'tile-grid'; -main.appendChild(section); - -const documentStub = { - addEventListener: vi.fn(), - documentElement: { dataset: {} }, - createElement: () => new FakeEl(), - getElementById: (id: string) => (id === 'tileGrid' ? section : null), - querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), - querySelectorAll: () => [], -}; - -/** A TerminalTile stand-in: records what the grid asks of it. */ -class FakeTile { - static all: FakeTile[] = []; - _wsReady = false; - _stoppedCode: number | null = null; - _destroyed = false; - fontSize: number | null; - terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record }; - connect = vi.fn(async () => {}); - reconnectNow = vi.fn(); - fit = vi.fn(); - destroy = vi.fn(() => { - this._destroyed = true; - }); - constructor( - public sessionId: string, - public mountEl: FakeEl, - public opts: Record - ) { - this.fontSize = (opts.fontSize as number) ?? null; - FakeTile.all.push(this); - } -} - -let observerCallback: ((list: { getEntries(): unknown[] }) => void) | null = null; -let clock = 100_000; -const localStore = new Map(); - -const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); -const windowStub: Record = { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - CodemanBase: { base: '' }, - innerWidth: 2400, - innerHeight: 1200, -}; -const context = vm.createContext({ - console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn() }, - performance: { now: () => clock }, - setInterval: vi.fn(), - clearInterval: vi.fn(), - setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), - clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), - requestAnimationFrame: vi.fn(), - requestIdleCallback: vi.fn(), - HTMLCanvasElement: class HTMLCanvasElement {}, - WebSocket: { OPEN: 1 }, - PerformanceObserver: class { - constructor(cb: (list: { getEntries(): unknown[] }) => void) { - observerCallback = cb; - } - observe() {} - disconnect() {} - }, - fetch: vi.fn(), - navigator: { onLine: true }, - location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, - document: documentStub, - localStorage: { - getItem: (k: string) => localStore.get(k) ?? null, - setItem: (k: string, v: string) => localStore.set(k, String(v)), - removeItem: (k: string) => localStore.delete(k), - }, - window: windowStub, - VoiceInput: { cleanup: vi.fn() }, - MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, -}); -vm.runInContext( - `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + - 'globalThis.__CodemanApp = CodemanApp;', - context -); -windowStub.TerminalTile = FakeTile; -windowStub.TileLoadQueue = class { - schedule(_t: unknown, _k: string, run: () => Promise) { - return run(); - } - drop() {} -}; -const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; - -type App = Record; +type App = GridApp; const IDS = ['s-a', 's-b', 's-c']; +/** + * The shared app plus what these guards exercise: the REAL sendResize (the + * harness stubs it), and spies on the main terminal's own paths. + */ function makeApp(): App { - const app = Object.create(CodemanApp.prototype) as App; - app.sessions = new Map( - [...IDS, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }]) - ); - app.sessionOrder = ['s-other', ...IDS]; - app.detachedSessions = new Set(); - app.isSoloWindow = false; - app.activeSessionId = 's-a'; - app._selectGeneration = 0; + const app = makeGridApp(IDS); + delete app.sendResize; app._initGeneration = 1; - app._xtermSnapshots = new Map(); - app.terminalBufferCache = new Map(); - app._pendingDeliveries = new Map(); - app._closingSessions = new Set(); - app.pendingHooks = new Map(); app.isOnline = true; app._connectionStatus = 'connected'; app._wsState = 'disconnected'; app._lastResizeDims = { cols: 100, rows: 30 }; - app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } }; - // Everything around the grid that is not under test here. for (const name of [ - '_cleanupPreviousSession', - 'hideWelcome', - 'showWelcome', - 'markIdleAlertSeen', - 'renderSessionTabs', - '_updateActiveTabImmediate', - '_refreshSessionPanels', - '_updateSseSubscription', - '_updateConnectionIndicator', - '_activateFileBrowserSession', - '_hideWebviewLayer', - 'closeSessionSidebarOnHandheld', - 'updateAttachmentHistoryBadge', - 'refreshHostWakeBanner', 'selectSession', 'batchTerminalWrite', '_connectWs', @@ -248,16 +72,11 @@ function makeApp(): App { app[name] = vi.fn(); } app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 })); - app.loadAppSettingsFromStorage = () => ({}); return app; } beforeEach(() => { - FakeTile.all = []; - localStore.clear(); - windowStub.innerWidth = 2400; - section.children = []; - main.className = 'main'; + resetGridHarness(); }); describe('parking the main terminal', () => { @@ -441,9 +260,9 @@ describe('guards: each stands aside while tiles own the terminal, and acts other app._disableWebGLSticky = vi.fn(); app._scheduleTerminalRepaint = vi.fn(); app._installWebGLLongTaskGuard(); - clock += 60_000; // past the install grace period - const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clock - 100 * i })); - observerCallback?.({ getEntries: () => longTasks }); + advanceClock(60_000); // past the install grace period + const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clockNow() - 100 * i })); + perfObserverCallbacks.at(-1)?.({ getEntries: () => longTasks }); expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1); } ); From 83d0caa20989baf4835ccd214c363eb5b631ff92 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 17:45:28 +0200 Subject: [PATCH 09/90] feat(tiles): tile header (status dot, name, menu, remove) and the in-tiles tab marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`. - The dot is the six-state classifier the tab rows and both home screens share (_sidebarRichRow), with the existing .home-sessions-dot--* classes; hovering the header says the state and for how long ("working 3m"). A tile whose session waits on a permission prompt or question gets a pulsing red border (box-shadow only, never layout; still under reduced motion). - The name is text with data-i18n-skip; a double-click renames it through the tab rename's own write queue (Enter or leaving the field commits, Escape cancels, an IME composition owns Enter), and an in-flight name shows as already applied, as on the tab. - ⋯ is the tab rail's session menu (options, new window, close session with its confirm); × removes the tile ONLY, the session keeps running. Neither button focuses a tile that is not focused. - The header is fixed at 24px so nothing in it can resize the body, and with it the xterm and its PTY (#464). Every tab render refreshes the headers, so they follow status and name changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the tab strip re-renders when tiles come and go. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 5 +- src/web/public/styles.css | 96 +++++++++++++++ src/web/public/tile-grid.js | 152 +++++++++++++++++++++++- test/mocks/tile-grid-vm.ts | 18 +++ test/tile-grid-chrome.test.ts | 212 ++++++++++++++++++++++++++++++++++ 5 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 test/tile-grid-chrome.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index b5075b32..32774db0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5647,6 +5647,9 @@ class CodemanApp { else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); } else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); } + // Tile grid membership (tile-grid.js): a tiled session's tab says so. + tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id)); + // Inject tab-number badge if missing (added after initial render) if (!tab.querySelector('.tab-number')) { const idx = this.sessionOrder.indexOf(id); @@ -6000,7 +6003,7 @@ class CodemanApp { const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - rowHtml.set(id, ` diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts index 6f2dc12e..45d4d4c1 100644 --- a/test/tile-grid-per-device-setting.test.ts +++ b/test/tile-grid-per-device-setting.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const PUBLIC = join(HERE, '../src/web/public'); @@ -52,6 +53,37 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU }); }); +describe('App Settings search finds Split and Tiles', () => { + // The real filter (settings-ui.js) over the real markup: it matches each chip + // by its own data-search and its text, so the chips carry their own keywords. + const html = read('index.html'); + const settingsSrc = read('settings-ui.js'); + const start = settingsSrc.indexOf(' _filterSettings(query) {'); + const end = settingsSrc.indexOf('\n },', start); + const dom = new JSDOM(html); + const doc = dom.window.document; + const filter = new Function('document', `return ({${settingsSrc.slice(start, end + 4)}});`)(doc) as { + _filterSettings(q: string): void; + }; + const shown = (id: string) => !doc.getElementById(id)!.closest('.set-chip')!.classList.contains('set-hit-hidden'); + + it.each([ + ['tiles', true, false], + ['tile grid', true, false], + ['side by side', true, true], + ['split', false, true], + ['split pane', false, true], + ])('"%s": Tiles shown %s, Split shown %s', (query, tiles, split) => { + filter._filterSettings(query); + expect(shown('appSettingsShowTileGridButton')).toBe(tiles); + expect(shown('appSettingsShowSplitButton')).toBe(split); + }); + + it('the Header buttons group names both in its keywords', () => { + expect(html).toMatch(/data-search="header buttons [^"]*\bsplit tiles\b[^"]*"/); + }); +}); + describe('markup and styles', () => { const html = read('index.html'); const css = read('styles.css'); From be04d3e5e0b750098d929622327b257a5d19abd8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 00:10:38 +0200 Subject: [PATCH 27/90] docs(tiles): the wiki's Ctrl/Cmd+click entry says what opens with the grid closed With the grid closed, Ctrl/Cmd+click on a tab opens what the Tiles button would show (decision 8) plus that session; the wiki only said it opens the grid. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/wiki/Tile-Grid.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 460e3da6..ba78acc3 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -23,9 +23,9 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. tab order, starting with the tiles you have (or had), greying out the rest once the grid is full. **Open tiles** shows them, replacing what the grid showed. - **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does. -- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it, - opening the grid if it was closed. On macOS use `Cmd`: `Ctrl`+click there opens the tab's - rename instead. +- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With + the grid closed it opens what the Tiles button would show, plus that session. On macOS use + `Cmd`: `Ctrl`+click there opens the tab's rename instead. - **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps running), or onto an empty slot to add it. Dragging a session that is already tiled onto another tile swaps the two. From 6c5b4a7a2585831e879ceeb226227f1552822de1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 01:09:35 +0200 Subject: [PATCH 28/90] fix(tiles): a translated tile label is not rewritten on every refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three refreshes compared the DOM with the English source: the tile header's tooltip, the Attach overlay's text and the zoom button's title. With App Settings language set to 简体中文 the i18n observer writes the translation into the DOM, so the comparison never matched again and every chrome refresh (each session:updated, several a second with busy tiles) wrote the English back for the observer to translate once more. Each now remembers the last English value on the tile entry and compares with that. English mode behaves exactly as before. Also: tileShortcutFor's comment still called the inert chord a default pending the owner's answer; it is owner decision 6. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 22 +++++++++++++++----- test/tile-grid-chrome.test.ts | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 8fa08206..bd9fc979 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -647,8 +647,8 @@ Object.assign(CodemanApp.prototype, { * The tile chord `e` asks for, if it applies right now, else null. The * toggle applies while the grid is open, or where one could open AND the * per-device `showTileGridButton` setting is on: with it off (the default) - * the chord is inert and reaches the terminal like any unbound key. (The - * applied default pending the owner's answer; one line to change.) The focus, + * the chord is inert and reaches the terminal like any unbound key (owner + * decision 6 in docs/tile-grid-plan.md). The focus, * zoom and remove chords apply only while the grid is open, however it was * opened. Registry-aware (rebinds and disables in App Settings, Shortcuts). * The capture handler (app.js) dispatches it; every xterm key handler returns @@ -1096,7 +1096,13 @@ Object.assign(CodemanApp.prototype, { } entry.overlay.hidden = false; const text = busy ? 'Attaching\u2026' : reason.text; - if (entry.overlayText.textContent !== text) entry.overlayText.textContent = text; + // Compared with the last English text set, never the DOM: with the zh-CN + // translator on, the DOM holds the translation and would never match, so + // every refresh would rewrite English for it to translate again. + if (entry.overlayLabel !== text) { + entry.overlayLabel = text; + entry.overlayText.textContent = text; + } const attachable = busy || reason.attachable; entry.overlayBtn.hidden = !attachable; entry.overlayHint.hidden = attachable; @@ -1223,7 +1229,11 @@ Object.assign(CodemanApp.prototype, { if (entry.dot.className !== dotClass) entry.dot.className = dotClass; const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : ''; const label = row ? [row.pill, since].filter(Boolean).join(' ') : ''; - if (entry.header.title !== label) entry.header.title = label; + // Against the last English label, never the DOM (translated in zh-CN; see _renderTileOverlay). + if (entry.headerLabel !== label) { + entry.headerLabel = label; + entry.header.title = label; + } // The input of a rename in progress has taken the name's place in the // header, so updating the detached name never touches what is being typed. // A rename still in flight shows as already done, as on the tab. @@ -1359,7 +1369,9 @@ Object.assign(CodemanApp.prototype, { const on = id === zoomed; const label = on ? 'Restore the grid' : 'Zoom this tile'; zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); - if (zoomBtn.title !== label) { + // Against the last English label, never the DOM (translated in zh-CN). + if (entry.zoomLabel !== label) { + entry.zoomLabel = label; zoomBtn.title = label; zoomBtn.setAttribute('aria-label', label); } diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index e86e3822..650d98fc 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -161,6 +161,44 @@ describe('header button size (owner feedback: the 12px glyphs read as tiny)', () }); }); +describe('a translated label survives a refresh (zh-CN)', () => { + // The i18n observer writes the translation into the DOM. Comparing the DOM + // with the English source would never match again, so each refresh would + // rewrite English for the observer to translate again. The guards compare + // with the last English value set instead. + it('the header tooltip, the Attach overlay text and the zoom title stay translated until they change', () => { + const app = makeGridApp(['s-a', 's-b']); + let pill = 'idle'; + app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); + app._mobileOverviewStampText = () => '3m'; + app.sessions.get('s-b').pid = null; + app.openTileGrid(['s-a', 's-b']); + const a = app._tileGrid.tiles.get('s-a'); + const b = app._tileGrid.tiles.get('s-b'); + expect(a.header.title).toBe('idle 3m'); + expect(b.overlayText.textContent).toBe('Not attached'); + app.zoomTile('s-a'); + expect(a.zoomBtn.title).toBe('Restore the grid'); + + // What the translator does to them. + a.header.title = '空闲 3m'; + b.overlayText.textContent = '未附加'; + a.zoomBtn.title = '恢复平铺网格'; + app._renderTileChrome(); + app._applyTileLayout(); + expect(a.header.title).toBe('空闲 3m'); + expect(b.overlayText.textContent).toBe('未附加'); + expect(a.zoomBtn.title).toBe('恢复平铺网格'); + + // A real change still writes the new English, for the translator to take. + pill = 'working'; + app._renderTileChrome(); + expect(a.header.title).toBe('working 3m'); + app.zoomTile('s-a'); + expect(a.zoomBtn.title).toBe('Zoom this tile'); + }); +}); + describe('rename', () => { function startRename(app: GridApp, id: string) { headerOf(id).children[1].dispatch('dblclick', { stopPropagation: vi.fn() }); From c848e7cf2717d763a5597f6a475b1bc9259428c4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 01:15:43 +0200 Subject: [PATCH 29/90] feat(i18n): the tile grid in Chinese (zh-CN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request: with App Settings language set to 简体中文, the grid reads fully in Chinese. Every string the grid puts on screen gets its own ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles button (both states, with the right-click hint), the Tiles and Split chips, the grid region, the Help modal's Tiles rows, the shortcut registry's Tiles group and labels (overlay and App Settings list, and "not bound"), "Open group as tiles", the picker, a tile's +, the header buttons, the Attach overlay (not attached, attaching, exited, ended, the hint), the empty slot, the dividers, the Split button while tiles are open, and the toasts. Strings with a count, an exit code or a duration are translateDynamic patterns: the cap texts (both wordings, with and without ": the new session opens on its own"), "This window fits N tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the crash-restart confirm (the existing confirm wrapper runs it through t(); the session name passes through untranslated, in the single view too), and the tile header tooltip ("idle 3m"), which requires the duration so bare state words stay out of the table (they collide with other surfaces, see mobile-overview.js). Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体, 案例, as the table already has them. Key names stay; Click, Right-click (mouse actions) and Arrows in the Help modal's key column are translated. English reads exactly as before (only additions to the table). test/tile-grid-i18n.test.ts drives the real tile code through every state that writes text, harvests each string and requires Chinese with no Latin word left beyond key names and durations, and the same English in en; plus the markup through the real translator in JSDOM, and session and group names (also when they equal a UI word) staying untranslated. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 + docs/tile-grid-plan.md | 4 + src/web/public/i18n.js | 85 +++++++ test/tile-grid-i18n.test.ts | 383 ++++++++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 test/tile-grid-i18n.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8f2b1f01..e3341d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9d6669cf..497cd7cb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -809,6 +809,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). +⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. + ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 37559654..f83f84fe 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -32,6 +32,10 @@ or settled a question the spec left open. The invariants as built are in - **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the first build's 12px glyphs read as tiny next to the name), the size of the app header's own icon buttons; the header grew from 24 to 28px to hold them. +- **The grid is translated for 简体中文 (zh-CN)** (owner request): 平铺 for the feature, 窗格 for + one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`, + `Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes + compare with the last English value, not the translated DOM. - **The Tiles button opens the grid at once** (owner decision 8): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens `tileGridOpenSet` (constants.js): the grid this tab last had, else an open split's two sessions, else the open sessions in tab diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a38d3f31..7cbebf3e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -96,6 +96,56 @@ 'Split: close the second session': '分屏:关闭第二个会话', 'Close split': '关闭分屏', 'No other sessions to split with': '没有其他可用于分屏的会话', + // Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the + // button, the setting, the grid), 窗格 one tile in it. Key names stay as + // they are; Click / Right-click are mouse actions, Arrows the arrow keys. + // Counts, exit codes and durations are patterns in translateDynamic. + Tiles: '平铺', + Split: '分屏', + 'Tiled sessions': '平铺的会话', + 'Tiles: show several sessions side by side (right-click to choose which)': + '平铺:并排显示多个会话(右键单击可选择会话)', + 'Tiles: back to a single session (right-click to choose which sessions)': + '平铺:返回单个会话(右键单击可选择会话)', + 'Split: unavailable while tiles are open': '分屏:平铺打开时不可用', + 'Toggle Tile Grid': '切换平铺网格', + 'Focus Tile Left': '聚焦左侧窗格', + 'Focus Tile Right': '聚焦右侧窗格', + 'Focus Tile Up': '聚焦上方窗格', + 'Focus Tile Down': '聚焦下方窗格', + 'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格', + 'Zoom Focused Tile': '放大聚焦的窗格', + 'Remove Focused Tile': '移除聚焦的窗格', + 'Add the Session to the Tile Grid': '将该会话加入平铺网格', + 'Choose Which Sessions to Tile': '选择要平铺的会话', + 'a tab': '标签页', + 'the Tiles button': '平铺按钮', + Click: '单击', + 'Right-click': '右键单击', + Arrows: '方向键', + 'not bound': '未绑定', + 'Open group as tiles': '以平铺方式打开分组', + 'Show sessions as tiles': '以平铺方式显示会话', + 'Open tiles': '打开平铺', + 'No sessions to show as tiles': '没有可平铺显示的会话', + 'This group has no session to show as tiles': '此分组没有可平铺显示的会话', + 'Add a session to the grid': '向平铺网格添加会话', + 'Every open session is already tiled': '所有打开的会话都已平铺', + 'New session in this case': '在此案例中新建会话', + 'This session is not in a case': '此会话不属于任何案例', + 'Zoom this tile': '放大此窗格', + 'Restore the grid': '恢复平铺网格', + 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', + 'Drop a tab here': '将标签页拖放到此处', + 'Resize tile columns': '调整窗格列宽', + 'Resize tile rows': '调整窗格行高', + Attach: '附加', + 'Attaching…': '正在附加…', + 'Not attached': '未附加', + 'The session ended': '会话已结束', + 'The agent exited': '智能体已退出', + 'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。', + 'Could not attach the session': '无法附加会话', 'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体', 'Open ultracode workflow agents': '打开 Ultracode 工作流智能体', Notifications: '通知', @@ -975,6 +1025,41 @@ /^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, ], + // Tile grid: counts, exit codes and durations pass through. + [/^Up to (\d+) tiles$/, (_m, n) => `最多 ${n} 个窗格`], + [/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`], + [/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`], + [ + /^The grid already holds what this window fits \((\d+)\)$/, + (_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`, + ], + [ + /^The grid holds at most (\d+) tiles: the new session opens on its own$/, + (_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`, + ], + [ + /^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/, + (_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`, + ], + [ + /^The window is too small for (\d+) tiles: showing the focused one$/, + (_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`, + ], + [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], + [/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`], + // A session name is user text: it passes through untranslated. + [ + /^(.+) was stopped after crashing repeatedly\. Restart it\?$/, + (_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`, + ], + // A tile header's tooltip: a state and how long ("idle 3m"). The duration + // is required: bare state words stay out of the table, they collide with + // state strings on other surfaces (see mobile-overview.js). + [ + /^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/, + (_m, state, duration) => + `${{ 'needs you': '需要你', error: '错误', waiting: '等待中', working: '工作中', idle: '空闲', done: '已完成', exited: '已退出' }[state]} ${duration}`, + ], ]; for (const [pattern, replacement] of patterns) { const match = source.match(pattern); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts new file mode 100644 index 00000000..437edf96 --- /dev/null +++ b/test/tile-grid-i18n.test.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview The tile grid in Chinese (zh-CN): every string it puts on + * screen has its own entry or pattern in i18n.js, so nothing is left in + * English and nothing falls through to the generic leading-verb fallback. + * + * - Runtime strings are HARVESTED from the real tile code (the shared vm + * harness, test/mocks/tile-grid-vm.ts) driven through every state that + * writes text: the picker (cap and window wording), a tile's +, the Attach + * overlay (not attached, attaching, exited, ended), zoom, the header + * tooltip, the dividers, the empty slot, every toast, the crash-restart + * confirm, the Tiles and Split button titles. Each must translate to text + * with no Latin word left beyond key names and durations, and read + * unchanged in English. + * - Static strings: the shortcut registry's tile entries (overlay and App + * Settings list), and index.html run through the real translator in JSDOM + * (the Tiles button, the App Settings chips, the Help modal's Tiles rows). + * - User text stays as typed: session names (tile header, picker, + menu) + * and group names carry data-i18n-skip, and a session name inside the + * confirm passes through the pattern untranslated. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + body, + bySelector, + fetchSpy, + makeGridApp, + resetGridHarness, + section, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8'); +const I18N = read('i18n.js'); +const INDEX = read('index.html'); + +type Api = { + t(s: string, v?: Record): string; + configure(o: Record): void; + start(): void; +}; +function translator(language: string) { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.configure({ language }); + return { api, dom }; +} +const zh = translator('zh-CN'); +const en = translator('en'); +afterAll(() => { + zh.dom.window.close(); + en.dom.window.close(); +}); + +// What may stay Latin in a translation: key names, compact durations, glyphs. +const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G)\b|<1m|\b\d+[dhm]\b/g; +const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? []; + +/** Every string a translation must handle, with where it was seen. */ +const seen = new Map(); +/** Text in a data-i18n-skip subtree (user text): must never be translated. */ +const userText = new Set(); + +function harvest(root: FakeEl | null | undefined, where: string) { + const walk = (el: FakeEl, inSkip: boolean) => { + const skip = inSkip || 'data-i18n-skip' in el.attrs; + const add = (value: unknown, kind: string) => { + if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return; + if (skip) userText.add(value.trim()); + else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`); + }; + add(el.textContent, 'text'); + add(el.title, 'title'); + add(el.attrs['aria-label'], 'aria-label'); + add(el.attrs.title, 'title attribute'); + for (const child of el.children) walk(child, skip); + }; + if (root) walk(root, false); +} + +function harvestAll(app: GridApp, where: string) { + harvest(section, where); + harvest(body, where); + harvest(bySelector.get('.btn-tile-grid'), where); + harvest(bySelector.get('.btn-split'), where); + for (const [msg] of (app.showToast as ReturnType).mock.calls) { + if (typeof msg === 'string' && !seen.has(msg)) seen.set(msg, `${where} (toast)`); + } +} + +let wrapRect = { width: 2400, height: 1200 }; +function setUp() { + resetGridHarness(); + wrapRect = { width: 2400, height: 1200 }; + const wrap = new FakeEl(); + wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height }); + bySelector.set('.terminal-wrap', wrap); + for (const cls of ['btn-tile-grid', 'btn-split']) { + const btn = new FakeEl(); + btn.className = `btn-icon-header ${cls}`; + bySelector.set(`.${cls}`, btn); + } +} + +const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`); + +/** Drives the real code through every state that writes text, harvesting as it goes. */ +async function exercise() { + setUp(); + const app = makeGridApp(EIGHT); + let pill = 'idle'; + app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); + app._mobileOverviewStampText = () => '3m'; + app.cases = [{ name: 'proj', path: '/w' }]; + app._mobileOverviewCaseFor = (_dir: string, cases: Array<{ name: string }>) => cases[0] ?? null; + app.sessions.get('s-2').pid = null; + app.sessions.get('s-3').paneExit = { status: 3 }; + app.sessions.get('s-4').paneExit = { signal: 9 }; + // A session named like a UI string: user text, never translated. + app.sessions.get('s-5').name = 'Open tiles'; + + // The picker, the cap wording, then the window wording. + app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + const boxes = () => + body.children.find((c) => c.id === 'tilePickerMenu')!.children[0].children.map((r) => r.children[0]); + for (const b of boxes()) { + if (b.checked || b.disabled) continue; + b.checked = true; + b.dispatch('change'); + } + harvestAll(app, 'picker, cap'); + app.closeTilePicker(); + wrapRect = { width: 1200, height: 900 }; + app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + for (const b of boxes()) { + if (b.checked || b.disabled) continue; + b.checked = true; + b.dispatch('change'); + } + harvestAll(app, 'picker, window'); + app.closeTilePicker(); + // A window that fits one tile words it in the singular. + wrapRect = { width: 700, height: 500 }; + app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + harvestAll(app, 'picker, one tile'); + app.closeTilePicker(); + wrapRect = { width: 2400, height: 1200 }; + + // The grid: five tiles first (an empty slot), then the sixth. + app.openTileGrid(EIGHT.slice(0, 5)); + harvestAll(app, 'grid of five'); + app.addTile('s-6'); + app._renderTileChrome(); + harvestAll(app, 'grid of six'); + for (const state of ['needs you', 'error', 'waiting', 'working', 'done', 'exited']) { + pill = state; + app._renderTileChrome(); + harvestAll(app, `tooltip ${state}`); + } + pill = 'idle'; + + // Zoom and back. + app.zoomTile('s-1'); + harvestAll(app, 'zoomed'); + app.zoomTile('s-1'); + + // A tile's + on a full grid, from a session in a case and from one outside. + app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); + harvestAll(app, '+ menu, full'); + app.closeTileAddMenu(); + app._mobileOverviewCaseFor = () => null; + app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); + harvestAll(app, '+ menu, no case'); + app.closeTileAddMenu(); + + // The toasts of a full grid, by the cap and by the window. + app._joinTileGridFromRun('s-7'); + app.addSessionToTiles('s-7'); + wrapRect = { width: 1200, height: 900 }; + section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 }); + app._joinTileGridFromRun('s-7'); + app.addSessionToTiles('s-8'); + // Too small for six: the auto-zoom hint. + section.getBoundingClientRect = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 }); + app._applyTileLayout(); + delete (section as unknown as Record).getBoundingClientRect; + wrapRect = { width: 2400, height: 1200 }; + harvestAll(app, 'full grid'); + + // Attach: in flight, failed, the crash-restart confirm, a socket that ended. + let release: (v: unknown) => void = () => {}; + fetchSpy.mockImplementationOnce(() => new Promise((r) => (release = r))); + const pending = app.attachTileSession('s-2'); + harvestAll(app, 'attaching'); + release({ ok: false, json: async () => ({ success: false }) }); + await pending; + harvestAll(app, 'attach failed'); + const confirmText: string[] = []; + windowStub.confirm = vi.fn((msg: string) => { + confirmText.push(msg); + return false; + }); + app.sessions.get('s-5').pid = null; + app.sessions.get('s-5').respawnBlocked = true; + app._renderTileChrome(); + await app.attachTileSession('s-5'); + for (const msg of confirmText) seen.set(msg, 'confirm'); + const tile6 = app._tileFor('s-6'); + tile6._stoppedCode = 4009; + app._renderTileChrome(); + harvestAll(app, 'socket ended'); + + // A group with no live session, and a toggle with nothing to open. + app.tabLayout = { groups: [{ id: 'g', name: 'Tiles', refs: [{ kind: 'session', id: 'gone' }] }] }; + app.openGroupAsTiles('g'); + harvestAll(app, 'empty group'); + app.closeTileGrid({ reselect: false }); + harvestAll(app, 'grid closed'); + const empty = makeGridApp([]); + empty.sessions.clear(); + empty.sessionOrder = []; + empty.toggleTileGrid(); + harvestAll(empty, 'nothing to open'); + return { confirmText }; +} + +// The harvest runs once, in the first test; the others read what it saw. +let confirmText: string[] = []; + +describe('every tile grid string the code puts on screen translates to zh-CN', () => { + it('harvests the strings it should (the harvest itself is not vacuous)', async () => { + ({ confirmText } = await exercise()); + // ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.) + const expected = [ + 'Show sessions as tiles', + 'Open tiles', + 'Up to 6 tiles', + 'The grid holds at most 6 tiles', + 'This window fits 4 tiles', + 'This window fits 1 tile', + 'The grid already holds what this window fits (4)', + 'Drop a tab here', + 'Resize tile columns', + 'Resize tile rows', + 'Zoom this tile', + 'Restore the grid', + 'Session actions', + 'Add a session to the grid', + 'Remove tile (the session keeps running)', + 'idle 3m', + 'needs you 3m', + 'exited 3m', + 'Not attached', + 'Attach', + 'Attaching…', + 'The agent exited (3)', + 'The agent exited (signal 9)', + 'The session ended', + 'It cannot be restarted in place: close it from ⋯ (Close session).', + 'New session in this case', + 'This session is not in a case', + 'The grid holds at most 6 tiles: the new session opens on its own', + 'The grid already holds what this window fits (4): the new session opens on its own', + 'The grid already holds what this window fits (4)', + 'The window is too small for 6 tiles: showing the focused one', + 'Could not attach the session', + 'This group has no session to show as tiles', + 'No sessions to show as tiles', + 'Split: unavailable while tiles are open', + 'Tiles: show several sessions side by side (right-click to choose which)', + 'Tiles: back to a single session (right-click to choose which sessions)', + 'Open tiles was stopped after crashing repeatedly. Restart it?', + ]; + const missing = expected.filter((s) => !seen.has(s)); + expect(missing).toEqual([]); + }); + + it('each one: Chinese with no English left, and no half-translated fallback', () => { + const bad: string[] = []; + for (const [source, where] of seen) { + const text = zh.api.t(source); + // The session name inside the confirm is user text, allowed to stay. + const words = leftover(text.replace('Open tiles', '')); + if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`); + } + expect(bad).toEqual([]); + }); + + it('each one reads exactly as before in English', () => { + const changed = [...seen.keys()].filter((s) => en.api.t(s) !== s); + expect(changed).toEqual([]); + }); + + it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => { + const app = read('app.js'); + const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]); + expect(labels).toHaveLength(7); + expect(app).toContain("label: 'Open group as tiles'"); + expect(app).toContain('not bound'); + const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => { + const text = zh.api.t(s); + return text === s || leftover(text).length > 0; + }); + expect(bad).toEqual([]); + }); +}); + +describe('the static markup through the real translator (JSDOM, zh-CN)', () => { + const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const doc = dom.window.document; + const extra = doc.createElement('div'); + extra.innerHTML = + 'Open tiles' + + 'Tiles' + + 'Tiles'; + doc.body.appendChild(extra); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN' }); + + it('the Tiles header button: title and accessible name', () => { + const btn = doc.querySelector('.btn-tile-grid')!; + for (const text of [btn.getAttribute('title')!, btn.getAttribute('aria-label')!]) { + expect(text).toContain('平铺'); + expect(leftover(text)).toEqual([]); + } + }); + + it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => { + const chip = (id: string) => doc.getElementById(id)!.closest('label')!.textContent!.trim(); + expect(chip('appSettingsShowTileGridButton')).toBe('平铺'); + expect(chip('appSettingsShowSplitButton')).toBe('分屏'); + expect(doc.getElementById('tileGrid')!.getAttribute('aria-label')).toBe('平铺的会话'); + }); + + it("the Help modal's Tiles rows: Chinese around the key names", () => { + const tiles = [...doc.querySelectorAll('#helpModal .shortcut-section')].find( + (s) => s.querySelector('h4')!.textContent === '平铺' + ); + expect(tiles).toBeTruthy(); + expect(leftover(tiles!.textContent!)).toEqual([]); + expect(tiles!.textContent).toContain('切换平铺网格'); + expect(tiles!.textContent).toContain('右键单击'); + }); + + it('user text stays as typed: a session name and a group name that are also UI words', () => { + expect(doc.getElementById('u-session')!.textContent).toBe('Open tiles'); + expect(doc.getElementById('u-group')!.textContent).toBe('Tiles'); + expect(doc.getElementById('control')!.textContent).toBe('平铺'); + }); +}); + +describe('user text in the tile code', () => { + it('session names in a tile header, the picker and the + menu are marked data-i18n-skip', () => { + // The harvest saw them only inside skipped subtrees. + expect(userText.has('Open tiles')).toBe(true); + expect([...seen.keys()]).not.toContain('s-1'); + const src = read('tile-grid.js'); + expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it('the group names of the grouped rail are marked data-i18n-skip', () => { + expect(read('tab-layout-browser.js')).toContain('class="tab-layout-group-name" id="${nameId}" data-i18n-skip>'); + }); + + it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => { + expect(confirmText).toEqual(['Open tiles was stopped after crashing repeatedly. Restart it?']); + expect(zh.api.t(confirmText[0])).toBe('Open tiles 因反复崩溃已被停止。要重启吗?'); + }); +}); From dfb9f32e23f743ecc66f9745f93e534131a60db5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 01:20:22 +0200 Subject: [PATCH 30/90] fix(tiles): a tile's + menu items get their disabled reason in Chinese Found live in zh-CN: the + menu's session items stayed titled "The grid holds at most 6 tiles". Each item carried data-i18n-skip on the whole button to keep the session name as typed, and the translator skips an element's attributes along with its text. Only the name is skipped now (a child span, as the picker does), so the title is translated. The zh-CN coverage test classified a title inside a skipped subtree as user text, which is how this got past it; such a label is now a failure of its own ("no UI label sits inside a skipped subtree"). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 8 ++++++-- test/tile-grid-cap.test.ts | 2 +- test/tile-grid-i18n.test.ts | 13 ++++++++++++- test/tile-grid-picker.test.ts | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index bd9fc979..3d4e1289 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -529,8 +529,12 @@ Object.assign(CodemanApp.prototype, { const item = document.createElement('button'); item.type = 'button'; item.setAttribute('role', 'menuitem'); - item.setAttribute('data-i18n-skip', ''); - item.textContent = c.label; + // Only the name is user text: skipping the whole button would keep its + // title (why it is disabled) out of the translator too. + const name = document.createElement('span'); + name.setAttribute('data-i18n-skip', ''); + name.textContent = c.label; + item.appendChild(name); item.disabled = full; if (full) item.title = limit.full; item.addEventListener('click', () => { diff --git a/test/tile-grid-cap.test.ts b/test/tile-grid-cap.test.ts index ced5c79e..d0e8f85d 100644 --- a/test/tile-grid-cap.test.ts +++ b/test/tile-grid-cap.test.ts @@ -67,7 +67,7 @@ describe('every way in stops at the cap', () => { const app = fullGrid(); app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!; - const items = menu.children.filter((c) => c.textContent === 's-7' || c.textContent === 's-8'); + const items = menu.children.filter((c) => ['s-7', 's-8'].includes(c.children[0]?.textContent ?? '')); expect(items).toHaveLength(2); for (const item of items) { expect(item.disabled).toBe(true); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 437edf96..08258393 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -72,13 +72,20 @@ const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) const seen = new Map(); /** Text in a data-i18n-skip subtree (user text): must never be translated. */ const userText = new Set(); +/** + * A title or accessible name inside a skipped subtree: the translator skips + * the element's attributes along with its text, so a UI label there stays + * English. Only the user text itself may be skipped. + */ +const labelsInSkip: string[] = []; function harvest(root: FakeEl | null | undefined, where: string) { const walk = (el: FakeEl, inSkip: boolean) => { const skip = inSkip || 'data-i18n-skip' in el.attrs; const add = (value: unknown, kind: string) => { if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return; - if (skip) userText.add(value.trim()); + if (skip && kind === 'text') userText.add(value.trim()); + else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`); else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`); }; add(el.textContent, 'text'); @@ -298,6 +305,10 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( expect(bad).toEqual([]); }); + it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => { + expect(labelsInSkip).toEqual([]); + }); + it('each one reads exactly as before in English', () => { const changed = [...seen.keys()].filter((s) => en.api.t(s) !== s); expect(changed).toEqual([]); diff --git a/test/tile-grid-picker.test.ts b/test/tile-grid-picker.test.ts index c58eb6db..63235bcc 100644 --- a/test/tile-grid-picker.test.ts +++ b/test/tile-grid-picker.test.ts @@ -212,7 +212,7 @@ describe("a tile's + menu", () => { app.markIdleAlertSeen.mockClear(); addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null }); const items = addMenu()!.children.filter((i) => i.className !== 'tile-add-new'); - expect(items.map((i) => i.textContent)).toEqual(['s-other', 's-c']); + expect(items.map((i) => i.children[0].textContent)).toEqual(['s-other', 's-c']); items[1].dispatch('click'); expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); expect(app.activeSessionId).toBe('s-c'); From 66da91f78bef4c4992576711052f59a3545ca562 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 02:07:27 +0200 Subject: [PATCH 31/90] feat(i18n): the tab's exited-agent badge in Chinese (zh-CN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request: translate the EXITED badge. The badge carried data-i18n-skip on purpose, because its in-place update compared the DOM with the English label: a translated badge would never have matched, and every incremental tab pass would have written English back for the translator to redo. The tab's accessible name (which carries the exit, the badge being aria-hidden) was set unconditionally on every pass, the same trap once it is translated. Now the badge is left to the translator. applyPaneExitBadge remembers the last English badge text (data-label) and accessible name (data-aria-source), both also seeded by the full render, and compares with those, never the DOM. The tab strip's incremental path updates tabs in place (no row-HTML comparison), so nothing else re-renders on a translated badge. i18n.js: "exited" -> 已退出, patterns for "exited (N)" -> 已退出(N) and "exited (signal N)" -> 已退出(信号 N), and for the accessible name " session, agent exited ..." -> " 会话,智能体已退出 ...", the session name passed through untranslated. The header strip, the session sidebar and the vertical rail all host the same tab markup, so this covers all three. English reads exactly as before. Tests: session-pane-exit-ui pins the new markup, the remembered English and that a translated badge and accessible name survive an unchanged pass; i18n-exit-run-help runs every paneExitLabel form (and the accessible name, with names that are dictionary words) through the real translator in both languages. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 30 ++++++++---- src/web/public/i18n.js | 13 +++++ test/i18n-exit-run-help.test.ts | 80 +++++++++++++++++++++++++++++++ test/session-pane-exit-ui.test.ts | 29 +++++++++-- 4 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 test/i18n-exit-run-help.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index d7246a4f..0381fa77 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -381,8 +381,17 @@ function applyPaneExitBadge(tab, paneExit) { tab.classList.toggle('tab-agent-exited', !!label); // The tab's aria-label overrides its contents for the accessible name, and the // badge is aria-hidden like its siblings, so the exit has to ride the label. + // Compared with the last English label set (data-aria-source, seeded by the + // full render too), never the attribute: in zh-CN the translator rewrites it, + // and writing English back on every pass would have it translate again. const name = tab.querySelector('.tab-name')?.dataset?.fullName; - if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label)); + if (name) { + const aria = paneExitAriaLabel(name, label); + if (tab.dataset.ariaSource !== aria) { + tab.dataset.ariaSource = aria; + tab.setAttribute('aria-label', aria); + } + } if (!label) { existing?.remove(); return; @@ -391,16 +400,19 @@ function applyPaneExitBadge(tab, paneExit) { const badge = document.createElement('span'); badge.className = 'tab-exited-badge'; badge.setAttribute('aria-hidden', 'true'); - // Generated status text, like the status pills: it carries data-i18n-skip - // rather than a dictionary entry. Without it the translator would rewrite - // the badge and the next render pass would rewrite it back, because the - // comparison below is against the English string. - badge.setAttribute('data-i18n-skip', ''); + // Translated like any other text (i18n.js has "exited" and its exit-code + // forms). The comparison below is with the last English label (data-label), + // never the DOM, which holds the translation in zh-CN: a DOM compare would + // write the English back on every pass for the translator to redo. + badge.dataset.label = label; badge.textContent = label; tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge); return; } - if (existing.textContent !== label) existing.textContent = label; + if (existing.dataset.label !== label) { + existing.dataset.label = label; + existing.textContent = label; + } } const DEFAULT_SHORTCUTS = [ @@ -6022,7 +6034,7 @@ class CodemanApp { const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - rowHtml.set(id, ` - diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ce2d9b70..aca95c6d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19369,10 +19369,10 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle } /* ── Tile grid (tile-grid.js, docs/tile-grid-plan.md) ────────────────────── - 1 to 9 sessions side by side, each a TerminalTile. The section is a SIBLING of - .terminal-wrap; .main.tiles-active swaps one for the other (the main terminal - is parked while the grid is open). Columns and rows are set inline by - _applyTileLayout() from the tile count. */ + 1 to TILE_GRID_MAX (6) sessions side by side, each a TerminalTile. The + section is a SIBLING of .terminal-wrap; .main.tiles-active swaps one for the + other (the main terminal is parked while the grid is open). Columns and rows + are set inline by _applyTileLayout() from the tile count. */ .tile-grid { display: none; } @@ -19437,7 +19437,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle background: transparent !important; } -/* Tile header: `● name ......... ⋯ ×`. FIXED height (#464): the body below +/* Tile header: `● name ......... ⋯ ⤢ ×`. FIXED height (#464): the body below holds the xterm, so a header that grew (a wrapped name, the rename input) would shrink the body with nothing refitting the xterm or its PTY. */ .tile-header { @@ -19492,8 +19492,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle opacity: 0.7; } -/* The ellipsis and the cross are thin glyphs: a step up so all four read at - the same visual size as the arrow and the plus. */ +/* The ellipsis and the cross are thin glyphs: a step up so they read at the + same visual size as the zoom arrow. */ .tile-btn.tile-menu, .tile-btn.tile-remove { font-size: 19px; diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 73b7a1e6..ef8aa64b 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -1,9 +1,9 @@ // src/web/public/tile-grid.js /** - * @fileoverview The tile grid: 1 to 9 live sessions side by side in one - * window, each in its own TerminalTile (terminal-tile.js), laid out by count - * (window.CodemanTileGrid, constants.js). Desktop only; see + * @fileoverview The tile grid: 1 to TILE_GRID_MAX (6) live sessions side by + * side in one window, each in its own TerminalTile (terminal-tile.js), laid + * out by count (window.CodemanTileGrid, constants.js). Desktop only; see * docs/tile-grid-plan.md. * * While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden, @@ -1054,9 +1054,9 @@ Object.assign(CodemanApp.prototype, { }, /** - * `● name ......... ⋯ ×`: the status dot (the six-state classifier the tab + * `● name ......... ⋯ ⤢ ×`: the status dot (the six-state classifier the tab * rows and both home screens share), the session name (double-click - * renames), the session menu (the tab rail's own) and remove-tile. Its + * renames), the session menu (the tab rail's own), zoom and remove-tile. Its * buttons stop pointerdown, so acting on a tile that is not focused does not * also focus it (and spend its idle alert). */ diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 96f7e437..15abd939 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -1,5 +1,5 @@ /** - * @fileoverview A tile's header: `● name ......... ⋯ ×`, and the tab marker. + * @fileoverview A tile's header: `● name ......... ⋯ ⤢ ×`, and the tab marker. * * - The dot uses the six-state classifier the tab rows and both home screens * share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*` From 6af38abc76ec87fcb104e32c5707843ff040b14a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:40:05 +0200 Subject: [PATCH 46/90] refactor(tiles): removeTile has no `auto` option, its refocus is always the app's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeTile took `auto` for the neighbour it focuses, but no caller ever passed anything but true: a tile leaving is never a human picking its neighbour. The option is gone (the refocus passes `auto: true` itself, and the doc comment says so), and the four call sites that spelled out the defaults (the header's ×, remove-tile, a stopped socket, a popped-out session) are plain removeTile(id). `refocus: false` callers are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 2 +- src/web/public/tile-grid.js | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index bcb2cf99..8c083d61 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1708,7 +1708,7 @@ class CodemanApp { if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id); // A popped-out session's window owns its PTY size now, so it leaves the // tile grid (one place per session in this browser tab). - if (on && this._tileGrid?.has(id)) this.removeTile(id, { refocus: true, auto: true }); + if (on && this._tileGrid?.has(id)) this.removeTile(id); const container = this.$('sessionTabs'); const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`); if (tab) tab.classList.toggle('detached', on); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index ef8aa64b..489f9b59 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -622,7 +622,7 @@ Object.assign(CodemanApp.prototype, { removeFocusedTile() { const grid = this._tileGrid; if (!grid?.open || !grid.focusedId) return; - this.removeTile(grid.focusedId, { refocus: true, auto: true }); + this.removeTile(grid.focusedId); }, // The single view after the grid closes: the session the grid was focused on, @@ -666,11 +666,11 @@ Object.assign(CodemanApp.prototype, { /** * Removes one tile; the session keeps running. When it held focus, `refocus` - * moves focus to the neighbouring tile (next in grid order, else previous). - * The last tile leaving closes the grid: with `refocus` the single view then + * moves focus to the neighbouring tile (next in grid order, else previous), + * as the app's choice (`auto`: no idle alert is spent). The last tile leaving closes the grid: with `refocus` the single view then * shows that session, without it the caller decides what comes next. */ - removeTile(sessionId, { refocus = true, auto = true } = {}) { + removeTile(sessionId, { refocus = true } = {}) { const grid = this._tileGrid; const entry = grid?.open ? grid.tiles.get(sessionId) : null; if (!entry) return false; @@ -695,7 +695,7 @@ Object.assign(CodemanApp.prototype, { this._applyTileLayout(); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); - if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto }); + if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true }); return true; }, @@ -1099,9 +1099,7 @@ Object.assign(CodemanApp.prototype, { // picker, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) // Removes the tile ONLY: the session keeps running. Killing it stays // behind the menu's Close session and its confirm. - button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => - this.removeTile(sessionId, { refocus: true, auto: true }) - ) + button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) ); el.append(dot, name, actions); return { el, dot, name, zoomBtn }; @@ -1225,7 +1223,7 @@ Object.assign(CodemanApp.prototype, { this._renderTileOverlay(sessionId); return; } - this.removeTile(sessionId, { refocus: true, auto: true }); + this.removeTile(sessionId); }, /** Columns x rows for the current tile count, applied to the grid section. */ From a24b5483899e9517c6c650be783b4dcd281cbd68 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:41:59 +0200 Subject: [PATCH 47/90] refactor(tiles): one helper drops a tile's queued loads and destroys it closeTileGrid, removeTile, dropSessionOnTile and _remountTile each spelled out `grid.queue?.drop(tile); tile.destroy()`. They now call _destroyTerminalTile(tile), which keeps that order (the waiting loads are resolved and the loading state cleared before the tile goes) and says why once. The tile's element stays the caller's to remove. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 489f9b59..dad1aca4 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -263,8 +263,7 @@ Object.assign(CodemanApp.prototype, { grid.resizeObserver?.disconnect(); grid.resizeObserver = null; for (const { tile, el } of grid.tiles.values()) { - grid.queue?.drop(tile); - tile.destroy(); + this._destroyTerminalTile(tile); el.remove(); } grid.tiles.clear(); @@ -685,8 +684,7 @@ Object.assign(CodemanApp.prototype, { // The zoomed tile leaving restores the grid (an automatic zoom moves to // the neighbour with focus, below). if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null; - grid.queue?.drop(entry.tile); - entry.tile.destroy(); + this._destroyTerminalTile(entry.tile); entry.el.remove(); grid.tiles.delete(sessionId); grid.ids.splice(grid.ids.indexOf(sessionId), 1); @@ -802,8 +800,7 @@ Object.assign(CodemanApp.prototype, { grid.ids.pop(); grid.ids.splice(index, 1, draggedId); const old = grid.tiles.get(targetId); - grid.queue?.drop(old.tile); - old.tile.destroy(); + this._destroyTerminalTile(old.tile); old.el.remove(); grid.tiles.delete(targetId); if (grid.zoomedId === targetId) grid.zoomedId = grid.autoZoom ? draggedId : null; @@ -908,6 +905,17 @@ Object.assign(CodemanApp.prototype, { return tile; }, + /** + * A grid tile's TerminalTile goes for good: its loads still waiting in the + * grid's queue are dropped first (resolved, never run, its loading state + * cleared), then the tile itself (socket, xterm, listeners). Its element is + * the caller's. + */ + _destroyTerminalTile(tile) { + this._tileGrid.queue?.drop(tile); + tile.destroy(); + }, + /** * Replaces a tile's TerminalTile with a fresh one in the same place (after * Attach: a tile whose socket stopped for good cannot reconnect, and a fresh @@ -917,8 +925,7 @@ Object.assign(CodemanApp.prototype, { const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null; if (!entry) return; const hadKeyboard = this._focusedTile === entry.tile; - this._tileGrid.queue?.drop(entry.tile); - entry.tile.destroy(); + this._destroyTerminalTile(entry.tile); entry.tile = this._newTerminalTile(sessionId, entry.body); this._connectTile(sessionId); if (hadKeyboard) this._noteFocusedTile(entry.tile); From 1202e9baa206088da0f3a24093bbe0ae93810a9a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:43:35 +0200 Subject: [PATCH 48/90] fix(tiles): "Open group as tiles" keeps the focused session focused With the grid already open, openGroupAsTiles closed it (which sets activeSessionId to null, so re-parking does not snapshot the parked terminal) and only then chose the focus, so the active session was never "in the group" and the group's first session always took focus. The picker's Open chooses before closing. Now both do: the focused session keeps focus when the group holds it, otherwise the group's first session gets it. tile-grid-entry-points covers both cases, with the grid open and closed; the open-grid case failed before this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 4 +++- test/tile-grid-entry-points.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index dad1aca4..fd4ac15e 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -879,13 +879,15 @@ Object.assign(CodemanApp.prototype, { this.showToast?.('This group has no session to show as tiles', 'info'); return false; } + // Before the open grid closes (which drops activeSessionId): the session in + // focus keeps it if the group holds it, as with the picker's Open. + const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; if (this._tilesOwnTerminal()) { this.closeTileGrid({ keepStored: false, reselect: false }); // As selectSession's tile branch: the parked terminal still holds what it // showed before the grid, and re-parking must not snapshot it. this.activeSessionId = null; } - const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; return this.openTileGrid(ids, { focusedId: focus }); }, diff --git a/test/tile-grid-entry-points.test.ts b/test/tile-grid-entry-points.test.ts index 59e08567..fe4b56b4 100644 --- a/test/tile-grid-entry-points.test.ts +++ b/test/tile-grid-entry-points.test.ts @@ -213,6 +213,28 @@ describe('"Open group as tiles"', () => { expect(app._tileGrid.ids).toEqual(['s-c', 's-a']); }); + it('keeps the focused session focused when the group holds it, open grid or not', () => { + for (const gridOpen of [false, true]) { + resetGridHarness(); + const app = makeGridApp(IDS); + withGroups(app); + if (gridOpen) app.openTileGrid(['s-b', 's-a'], { focusedId: 's-a' }); + else app.activeSessionId = 's-a'; + app.openGroupAsTiles('g1'); + expect(app._tileGrid.ids).toEqual(['s-c', 's-a']); + expect(app._tileGrid.focusedId).toBe('s-a'); + expect(app.activeSessionId).toBe('s-a'); + } + }); + + it("focuses the group's first session when it does not hold the focused one", () => { + const app = makeGridApp(IDS); + withGroups(app); + app.openTileGrid(['s-b']); + app.openGroupAsTiles('g1'); + expect(app._tileGrid.focusedId).toBe('s-c'); + }); + it('is not offered where the grid cannot open', () => { const app = makeGridApp(IDS); const actions = withGroups(app); From be7328c8eb1d67a64d08cddd14548df4627b24de Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:45:07 +0200 Subject: [PATCH 49/90] refactor(tiles): the picker's Open and "Open group as tiles" share _replaceTileGrid Both put a new set of sessions on the grid in place of the open one: choose the focus (the session in focus if the set holds it, else the first), close an open grid forgotten, drop activeSessionId so re-parking snapshots nothing, then open. They now call one _replaceTileGrid(ids), which carries the reason for the order once. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index fd4ac15e..978a2170 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -449,16 +449,7 @@ Object.assign(CodemanApp.prototype, { open.addEventListener('click', () => { const ids = boxes.filter((b) => b.checked).map((b) => b.value); this.closeTilePicker(); - if (ids.length === 0) return; - const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; - if (this._tilesOwnTerminal()) { - // A new set for the open grid, as "Open group as tiles" does it: the - // parked terminal still holds what it showed before the grid, and - // re-parking must not snapshot it. - this.closeTileGrid({ keepStored: false, reselect: false }); - this.activeSessionId = null; - } - this.openTileGrid(ids, { focusedId: focus }); + if (ids.length > 0) this._replaceTileGrid(ids); }); document.body.appendChild(menu); @@ -879,13 +870,23 @@ Object.assign(CodemanApp.prototype, { this.showToast?.('This group has no session to show as tiles', 'info'); return false; } - // Before the open grid closes (which drops activeSessionId): the session in - // focus keeps it if the group holds it, as with the picker's Open. + return this._replaceTileGrid(ids); + }, + + /** + * Opens the grid on `ids` in place of whatever it shows (the picker's Open, + * "Open group as tiles"): the session in focus keeps focus when `ids` holds + * it, otherwise the first one takes it. Chosen BEFORE an open grid closes, + * because the close drops activeSessionId: as in selectSession's tile + * branch, the parked terminal still holds what it showed before the grid, + * and re-parking must not snapshot it. + * + * @returns {boolean} whether the grid is open afterwards + */ + _replaceTileGrid(ids) { const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; if (this._tilesOwnTerminal()) { this.closeTileGrid({ keepStored: false, reselect: false }); - // As selectSession's tile branch: the parked terminal still holds what it - // showed before the grid, and re-parking must not snapshot it. this.activeSessionId = null; } return this.openTileGrid(ids, { focusedId: focus }); From 6cbaf3f7b65c0f22dc4cd46ae860e0f4d143bbb1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:46:37 +0200 Subject: [PATCH 50/90] refactor(tiles): Ctrl+Tab and Alt+[ / ] cycle tiles through one helper nextSession and prevSession each carried the same grid branch (cycleTile from the active session, a human selection, skip the tab walk). It is now _cycleTileFocus(delta) in tile-grid.js, next to the other focus moves; both call it optionally, so a page or harness without tile-grid.js walks the tabs as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 14 +++----------- src/web/public/tile-grid.js | 13 +++++++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 8c083d61..0c8be117 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -9063,12 +9063,8 @@ class CodemanApp { } nextSession() { - // With the tile grid open, Ctrl+Tab and Alt+] cycle through the tiles. - if (this._tileGrid?.open) { - const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, 1); - if (id) this.selectSession(id); - return; - } + // With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles. + if (this._cycleTileFocus?.(1)) return; if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); @@ -9077,11 +9073,7 @@ class CodemanApp { } prevSession() { - if (this._tileGrid?.open) { - const id = window.CodemanTileGrid.cycleTile(this._tileGrid.ids, this.activeSessionId, -1); - if (id) this.selectSession(id); - return; - } + if (this._cycleTileFocus?.(-1)) return; if (this.sessionOrder.length <= 1) return; const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 978a2170..13857ec1 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -608,6 +608,19 @@ Object.assign(CodemanApp.prototype, { if (id) this.selectSession(id); }, + /** + * Ctrl+Tab / Alt+] (delta 1) and Alt+[ (delta -1) while the grid is open: a + * human selection of the next tile in reading order, wrapping. Returns + * whether the grid took the chord (it is open), so the tab walk is skipped. + */ + _cycleTileFocus(delta) { + const grid = this._tileGrid; + if (!grid?.open) return false; + const id = window.CodemanTileGrid.cycleTile(grid.ids, this.activeSessionId, delta); + if (id) this.selectSession(id); + return true; + }, + /** Removes the focused tile (the session keeps running); a neighbour takes focus. */ removeFocusedTile() { const grid = this._tileGrid; From 0746cffe6d39204b1af519d7bab71448e65e9abb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:47:55 +0200 Subject: [PATCH 51/90] refactor(tiles): the layout helpers read the minimum tile size directly computeTileLayout and tileGridCapacity took minTileW / minTileH, defaulted to TILE_MIN_W / TILE_MIN_H, and no caller or test ever passed them. The parameters are gone and both read the constants; the spec's signature line says so. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/tile-grid-plan.md | 5 +++-- src/web/public/constants.js | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 930823bc..f518cd81 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -401,8 +401,9 @@ xterm. Nothing may outlive a removed tile (24-hour sessions rule). ### 2. `TileGrid` controller and layout helper -- `computeTileLayout({ count, width, height, minTileW, minTileH })` and - `tileGridCapacity(...)`: pure, in `constants.js`, exported on +- `computeTileLayout({ count, width, height })` and + `tileGridCapacity({ width, height })` (against the minimum tile size, + `TILE_MIN_W` x `TILE_MIN_H`): pure, in `constants.js`, exported on `window.CodemanTileGrid` beside the existing helper namespaces. `sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place. - The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as diff --git a/src/web/public/constants.js b/src/web/public/constants.js index bdcbd790..b0a802c0 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1672,13 +1672,13 @@ const TILE_GRID_SSE_FILTER = 'tile-grid'; /** * Columns and rows for `count` tiles, by count (the spec's table), and whether - * that layout gives every cell at least the minimum tile size in a grid area - * of `width` x `height` px. + * that layout gives every cell at least the minimum tile size (TILE_MIN_W x + * TILE_MIN_H) in a grid area of `width` x `height` px. * - * @param {{count: number, width?: number, height?: number, minTileW?: number, minTileH?: number}} p + * @param {{count: number, width?: number, height?: number}} p * @returns {{cols: number, rows: number, fits: boolean}} */ -function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { +function computeTileLayout({ count, width = Infinity, height = Infinity }) { const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX); let cols; let rows; @@ -1692,7 +1692,7 @@ function computeTileLayout({ count, width = Infinity, height = Infinity, minTile else if (n === 4) { cols = 2; rows = 2; } else if (n <= 6) { cols = 3; rows = 2; } else { cols = 3; rows = 3; } - const fits = width / cols >= minTileW && height / rows >= minTileH; + const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H; return { cols, rows, fits }; } @@ -1701,13 +1701,13 @@ function computeTileLayout({ count, width = Infinity, height = Infinity, minTile * whose layout, and every smaller count's layout, fits. 0 when not even one * tile fits. * - * @param {{width: number, height: number, minTileW?: number, minTileH?: number}} p + * @param {{width: number, height: number}} p * @returns {number} */ -function tileGridCapacity({ width, height, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { +function tileGridCapacity({ width, height }) { let capacity = 0; for (let n = 1; n <= TILE_GRID_MAX; n++) { - if (!computeTileLayout({ count: n, width, height, minTileW, minTileH }).fits) break; + if (!computeTileLayout({ count: n, width, height }).fits) break; capacity = n; } return capacity; From c54867d9f0470c5d8e792942476c81d21a7c35d7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:49:21 +0200 Subject: [PATCH 52/90] refactor(tiles): _tileGridLimit measures the terminal area itself _tileGridCapacityNow had one caller, _tileGridLimit, and only measured the area (the grid section, or the single view the grid would replace) for it. The measurement now sits in _tileGridLimit, whose doc comment says what is measured. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 13857ec1..d2ef4461 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -336,24 +336,21 @@ Object.assign(CodemanApp.prototype, { btn.setAttribute('aria-label', title); }, - /** How many tiles the terminal area can hold right now (the grid section, or the single view it would replace). */ - _tileGridCapacityNow() { + /** + * How many tiles the grid takes here and now: what the terminal area fits + * (the grid section, or the single view it would replace), at most the cap + * (TILE_GRID_MAX), never less than one. The texts say which of the two + * binds, so a large monitor never reads "this window fits 6". + */ + _tileGridLimit() { + const T = window.CodemanTileGrid; + const max = T.TILE_GRID_MAX; const el = this._tilesOwnTerminal() ? this._tileGridSection() : document.querySelector('.terminal-wrap'); const rect = el?.getBoundingClientRect?.() || { width: 0, height: 0 }; - return window.CodemanTileGrid.tileGridCapacity({ + const fits = T.tileGridCapacity({ width: rect.width || window.innerWidth, height: rect.height || window.innerHeight, }); - }, - - /** - * How many tiles the grid takes here and now: what the window fits, at most - * the cap (TILE_GRID_MAX), never less than one. The texts say which of the - * two binds, so a large monitor never reads "this window fits 6". - */ - _tileGridLimit() { - const max = window.CodemanTileGrid.TILE_GRID_MAX; - const fits = this._tileGridCapacityNow(); const capacity = Math.max(1, Math.min(fits, max)); const byCap = fits >= max; return { From e6eb3dd8496ed9b5a2cc27c3a3bca7c2bd28e6e0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:51:26 +0200 Subject: [PATCH 53/90] style(tiles): no CSS hides dividers and slots that a zoom never leaves `.tile-grid--zoomed .tile-divider` and `.tile-grid--zoomed .tile-slot` hid elements that do not exist while a tile is zoomed: _applyTileLayout toggles the zoomed class in the same pass that syncs zero dividers and zero slots, and it is the only place either is created. Both rules are gone. The dividers test asserted the CSS rule; it now asserts what the user sees (no divider elements while zoomed, two again on restore), and tile-grid-entry-points gains the same check for the empty slot of three tiles in a 2x2. Both fail if the zoom stops removing them. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/styles.css | 8 -------- test/tile-grid-dividers.test.ts | 10 +++++----- test/tile-grid-entry-points.test.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index aca95c6d..e972d6f8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19557,10 +19557,6 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle background: var(--accent, #4a9eff); } -.tile-grid.tile-grid--zoomed .tile-divider { - display: none; -} - /* While a divider drags: the resize cursor everywhere and no text selection (mirrors body.split-pane-resizing). */ body.tile-grid-resizing, @@ -19592,10 +19588,6 @@ body.tile-grid-resizing--row * { opacity: 0.6; } -.tile-grid.tile-grid--zoomed .tile-slot { - display: none; -} - /* A tab dragged over a tile (replace it) or an empty slot (add it). */ .tile.tile--drop-target, .tile-slot.tile--drop-target { diff --git a/test/tile-grid-dividers.test.ts b/test/tile-grid-dividers.test.ts index 4071c8f7..9f3e7604 100644 --- a/test/tile-grid-dividers.test.ts +++ b/test/tile-grid-dividers.test.ts @@ -12,8 +12,6 @@ * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, @@ -204,11 +202,13 @@ describe('fractions on fewer columns', () => { }); describe('zoomed', () => { - it('shows no dividers', () => { + it('has no dividers while a tile fills the grid, and gets them back on restore', () => { const app = grid(['s-a', 's-b', 's-c', 's-d']); + const dividerEls = () => section.children.filter((el) => el.className.startsWith('tile-divider')); app.zoomTile('s-a'); expect(app._tileGrid.dividers.size).toBe(0); - const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); - expect(css).toMatch(/\.tile-grid\.tile-grid--zoomed \.tile-divider\s*\{\s*display: none;/); + expect(dividerEls()).toHaveLength(0); + app.zoomTile('s-a'); + expect(dividerEls()).toHaveLength(2); }); }); diff --git a/test/tile-grid-entry-points.test.ts b/test/tile-grid-entry-points.test.ts index fe4b56b4..2c181677 100644 --- a/test/tile-grid-entry-points.test.ts +++ b/test/tile-grid-entry-points.test.ts @@ -120,6 +120,15 @@ describe('dragging a tab onto an empty slot', () => { app.openTileGrid(['s-a', 's-b']); expect(slots()).toHaveLength(0); }); + + it('a zoomed grid has no slot; restoring the grid brings it back', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.zoomTile('s-a'); + expect(slots()).toHaveLength(0); + app.zoomTile('s-a'); + expect(slots()).toHaveLength(1); + }); }); describe('Ctrl/Cmd+click on a tab', () => { From fbab0ffbf87ae563ba02bf745c2b6a94dc0566c7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:53:36 +0200 Subject: [PATCH 54/90] style(tiles): one rule for the grid's two accent buttons, one accent token .tile-attach-btn and .tile-picker-open repeated the same seven declarations and the same :disabled opacity; they now share one rule, and each keeps only what differs (the picker's narrower padding, the in-flight attach's progress cursor). .tile.focused read --accent-color, an alias of --accent, while every other grid rule reads --accent; it reads --accent too. Computed styles of 22 grid elements (headless Chromium, the default, daylight-blue and og skins) are identical before and after. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/styles.css | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e972d6f8..5843cdfc 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19414,7 +19414,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle /* The focused tile has the keyboard and is the active session. */ .tile.focused { - border-color: var(--accent-color, #4a9eff); + border-color: var(--accent, #4a9eff); } .tile-body { @@ -19665,7 +19665,10 @@ body.tile-grid-resizing--row * { text-align: center; } -.tile-attach-btn { +/* The grid's accent buttons: the Attach overlay's, and the picker's Open + (which narrows the padding in its own block below). */ +.tile-attach-btn, +.tile-picker-open { padding: 4px 14px; font: inherit; color: var(--accent-ink, #fff); @@ -19675,8 +19678,14 @@ body.tile-grid-resizing--row * { cursor: pointer; } -.tile-attach-btn:disabled { +.tile-attach-btn:disabled, +.tile-picker-open:disabled { opacity: 0.5; + cursor: default; +} + +/* An attach in flight. */ +.tile-attach-btn:disabled { cursor: progress; } @@ -19756,17 +19765,6 @@ body.tile-grid-resizing--row * { .tile-picker-open { padding: 4px 12px; - font: inherit; - color: var(--accent-ink, #fff); - background: var(--accent, #4a9eff); - border: none; - border-radius: 4px; - cursor: pointer; -} - -.tile-picker-open:disabled { - opacity: 0.5; - cursor: default; } /* Split-picker menu: a small popover listing sessions to split with, appended From b9ce850a2cd283594c2b1c3ec0d8b79ed17ec065 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:54:50 +0200 Subject: [PATCH 55/90] refactor(tiles): the picker's position comes from its stylesheet alone openTilePicker set `position: fixed` inline, which .tile-picker-menu already declares; the inline copy (carried over from the split picker, whose menu has the same rule) is gone. The top/right offsets under the Tiles button stay inline, since they are measured. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index d2ef4461..15716545 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -452,8 +452,8 @@ Object.assign(CodemanApp.prototype, { document.body.appendChild(menu); const btn = document.querySelector('.btn-tile-grid'); if (btn?.getBoundingClientRect) { + // Under the Tiles button (.tile-picker-menu is position: fixed). const rect = btn.getBoundingClientRect(); - menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; menu.style.right = `${window.innerWidth - rect.right}px`; } From c101b70678054bfeca693280f61b940c1642c764 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:56:38 +0200 Subject: [PATCH 56/90] refactor(tiles): each tile's zoom state is painted by _syncTileZoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _applyTileLayout carried a loop that set every tile's zoomed class and its ⤢ button's pressed state and label. That loop is now _syncTileZoom, beside _syncTileSlots and _syncTileDividers, which _applyTileLayout calls the same way. Same order of writes, same last-English-label compare. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 15716545..59138143 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -1273,21 +1273,7 @@ Object.assign(CodemanApp.prototype, { } const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null; section.classList.toggle('tile-grid--zoomed', !!zoomed); - for (const [id, entry] of grid.tiles) { - entry.el.classList.toggle('tile--zoomed', id === zoomed); - const zoomBtn = entry.zoomBtn; - if (zoomBtn) { - const on = id === zoomed; - const label = on ? 'Restore the grid' : 'Zoom this tile'; - zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); - // Against the last English label, never the DOM (translated in zh-CN). - if (entry.zoomLabel !== label) { - entry.zoomLabel = label; - zoomBtn.title = label; - zoomBtn.setAttribute('aria-label', label); - } - } - } + this._syncTileZoom(zoomed); // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so // they measure nothing and send no resize. Otherwise every tile is placed // explicitly in reading order, with a divider track between columns and @@ -1305,6 +1291,25 @@ Object.assign(CodemanApp.prototype, { this._persistTileGrid(); }, + // Each tile's zoom state: the zoomed one is shown alone (CSS), and every ⤢ + // button says what pressing it does next. + _syncTileZoom(zoomed) { + for (const [id, entry] of this._tileGrid.tiles) { + const on = id === zoomed; + entry.el.classList.toggle('tile--zoomed', on); + const zoomBtn = entry.zoomBtn; + if (!zoomBtn) continue; + zoomBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + const label = on ? 'Restore the grid' : 'Zoom this tile'; + // Against the last English label, never the DOM (translated in zh-CN). + if (entry.zoomLabel !== label) { + entry.zoomLabel = label; + zoomBtn.title = label; + zoomBtn.setAttribute('aria-label', label); + } + } + }, + // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a // 3x2): drop targets for a tab, after the tiles in reading order. _syncTileSlots(count, cols) { From 17eea3c2302c0a50a9a4578b2aa0461441df15fc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 03:58:21 +0200 Subject: [PATCH 57/90] test(tiles): the connect() static guard's end anchor resolves again terminal-tile-unit slices connect() out of terminal-tile.js up to 'async _loadBuffer()'. The load queue commit (fca7acd0) gave _loadBuffer a `{ refresh }` parameter, so that anchor stopped matching, indexOf returned -1 and the slice ran to the end of the file: every check in the test passed against code outside connect(). The anchor is now 'async _loadBuffer(' and the test asserts both anchors resolve, so a rename fails it instead of widening it (checked by putting the old anchor back). Co-Authored-By: Claude Opus 5.5 (1M context) --- test/terminal-tile-unit.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/terminal-tile-unit.test.ts b/test/terminal-tile-unit.test.ts index 07a46294..5606abf2 100644 --- a/test/terminal-tile-unit.test.ts +++ b/test/terminal-tile-unit.test.ts @@ -792,7 +792,13 @@ describe('TerminalTile scroll-to-top history pull', () => { it('connect() installs the wheel listener (static guard)', () => { // connect() needs a whole xterm to run, so its wiring is pinned by source // rather than executed; the listener's behaviour is exercised above. - const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()')); + const start = SOURCE.indexOf('async connect()'); + const end = SOURCE.indexOf('async _loadBuffer('); + // Both anchors must resolve, or the slice runs to the end of the file and + // every check below passes against code outside connect(). + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const connect = SOURCE.slice(start, end); expect(connect).toContain('this._installWheelListener();'); expect(connect).toContain('this._onLiveClear();'); expect(connect).not.toContain('this.terminal.clear();'); From 4b2e6a7c8366709a0f989282254d78d09c28200a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 05:37:50 +0200 Subject: [PATCH 58/90] docs(split): comments say what TerminalTile does now The rename from SplitTerminalPane carried comments over that the PR 1 changes made untrue: - constants.js buildSplitPickerSessions said TerminalTile._sendResize has no detached check (it stands aside like the primary pane) and that Pane B sends no `seq` (its input rides the exactly-once queue). The conclusions stay: a detached session's window owns its PTY size, and a session with no PTY has a pane nothing feeds or reads. - terminal-split.js said TerminalTile has no "dims unchanged" skip (it has _lastSentDims), and its @loadorder still ended at respawn-ui.js rather than tile-grid.js. - terminal-tile.js still called every pane "Pane B", said a shell load lands in "a 50000-line xterm" (the scrollback is an option now) and that a TUI session always gets a full replay (with boundedLoad, grid tiles get the bounded window), and told some reasons as history ("an earlier draft", "used to", "It LOOKED intermittent"). Those now give the reason in the present tense, and the comments touched lose their em dashes. writeChunked's doc comment is left as it is: the perf work rewrites that function and owns its comment. Comments only. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 17 ++-- src/web/public/terminal-split.js | 9 +- src/web/public/terminal-tile.js | 139 +++++++++++++++---------------- 3 files changed, 78 insertions(+), 87 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index b0a802c0..f4ab30ad 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1612,21 +1612,18 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds const result = []; for (const id of sessionOrder) { if (id === excludeId) continue; - // A detached (popped-out) session's own window already yields its PTY - // size (see sendResize's detachedElsewhere guard in terminal-ui.js) — - // Pane B's TerminalTile._sendResize() has no such check, so letting - // one into the picker put its detached window and Pane B in a fight over - // the same PTY's dimensions. + // A detached (popped-out) session's own window owns its PTY size (see + // sendResize's detachedElsewhere guard in terminal-ui.js; + // TerminalTile._sendResize() stands aside the same way), so Pane B could + // only show it at a size it cannot set. if (detachedIds?.has?.(id)) continue; const session = sessions.get(id); if (!session) continue; // A session with no PTY attached (exited CLI, a crash-looped session // whose breaker tripped, a restore that failed to re-attach) has nothing - // reading its tmux pane. TerminalTile never does selectSession()'s - // re-attach POST, so its socket would open onto a pane nothing feeds: - // no terminal events, and Session.write() silently drops every keystroke - // with no ack either way (Pane B sends no `seq`), so the loss is - // invisible — the healthy socket never trips the disconnect banner. + // reading its tmux pane, and the split never does selectSession()'s + // re-attach POST: Pane B would open a healthy-looking socket onto a pane + // that nothing feeds and nothing reads. if (session.pid === null) continue; result.push({ id, label: session.name || 'Session' }); } diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 70df4081..88b450af 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -8,7 +8,7 @@ * * @dependency terminal-tile.js (window.TerminalTile) * @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH) - * @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js + * @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js */ Object.assign(CodemanApp.prototype, { @@ -281,10 +281,9 @@ Object.assign(CodemanApp.prototype, { // frame). Coalesced to one call per animation frame below — a raw // mousemove stream fires far faster than the browser repaints, and // without the rAF gate each event did a full xterm reflow on BOTH - // panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has - // no client-side "dims unchanged" skip), which fanned out into a - // `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty - // of each dragging across half a wide viewport. + // panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out + // into a `tmux resize-window` child plus a SIGWINCH per frame, roughly + // fifty of each dragging across half a wide viewport. const applyDragPercent = (clientX) => { const container = divider.parentElement; // The split can auto-collapse mid-drag (the other pane's session diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 10ab66a8..240df0fb 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -21,7 +21,7 @@ */ (function (global) { - // How long a scroll-to-top history pull may hold Pane B's live output. + // How long a scroll-to-top history pull may hold this pane's live output. const HISTORY_PULL_TIMEOUT_MS = 10000; // How much of a replay is queued in xterm at once: a 1 MiB load goes in one @@ -85,10 +85,10 @@ this.mountEl = mountEl; this.sessionMode = opts.mode; this.fontSettings = opts.fontSettings || {}; - // Live reference (not a snapshot) to the app's detachedSessions Set — - // detaching this session AFTER the split is already open must still be - // seen by _sendResize() below, or it re-creates the exact PTY-size - // fight the split picker already refuses to open at pick time. + // Live reference (not a snapshot) to the app's detachedSessions Set: + // detaching this session AFTER the pane opened must still be seen by + // _sendResize() below, or this pane and the session's own window fight + // over the PTY's size (which the split picker refuses at pick time). this.detachedSessions = opts.detachedSessions; // Lines of scrollback this pane's xterm keeps (the grid passes its smaller // TILE_SCROLLBACK) and its font size (the grid's own tile font); absent, @@ -200,17 +200,18 @@ this.terminal.onData((data) => this._onTerminalData(data)); - // Pane B has no gates of its own by default, so every app-level chord - // that the document capture-phase handler (app.js) only preventDefault()s - // — never stopPropagation()s — reaches xterm here too and writes its raw - // byte/escape sequence into THIS session's PTY on top of whatever the app - // action already did to Pane A (COD-153; mirrors the primary pane's own - // gates at terminal-ui.js's attachCustomKeyEventHandler: command palette, - // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter - // newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same - // registry-aware predicates so a rebind or a disable restores plain - // terminal behavior here too. Ctrl+V goes through the primary pane's - // paste trap (image-input.js), aimed at this pane (below). + // xterm has no gates of its own, so every app-level chord that the + // document capture-phase handler (app.js) only preventDefault()s (never + // stopPropagation()s) would reach this xterm too and write its raw byte + // or escape sequence into THIS session's PTY on top of whatever the app + // action did (COD-153). These are the primary pane's gates + // (terminal-ui.js attachCustomKeyEventHandler): command palette, + // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, the tile grid's chords, + // Ctrl+Z suspend, Shift/Ctrl+Enter newline, and smart-copy + // Ctrl+C/Ctrl+Shift+C. Routed through the same registry-aware + // predicates so a rebind or a disable restores plain terminal behavior + // here too. Ctrl+V goes through the primary pane's paste trap + // (image-input.js), aimed at this pane (below). this.terminal.attachCustomKeyEventHandler((ev) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( @@ -240,13 +241,11 @@ global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId }); return false; } - // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own - // swallow — in a plain shell session this is the user's own - // job-control tool and must reach the PTY, but in every other mode - // (claude/omp/pi/codex/...) it silently stops an unattended agent - // loop dead. Pane B has its own PTY/session and must not send a - // suspend into a non-shell one just because the primary pane's own - // gate lives elsewhere. + // Ctrl+Z (SIGTSTP/job-control suspend), as terminal-ui.js swallows it: + // in a plain shell session this is the user's own job-control tool and + // must reach the PTY, but in every other mode (claude/omp/pi/codex/...) + // it silently stops an unattended agent loop dead. This pane has its + // own session and applies the same rule to it. if ( ev.type === 'keydown' && ev.key.toLowerCase() === 'z' && @@ -258,15 +257,14 @@ ) { return false; } - // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting. - // Mirrors terminal-ui.js's own handling — xterm sends plain \r for - // every Enter variant, so an Ink app (Claude Code) can't tell a - // newline from a submit. Without this gate, Pane B's onData would - // send that bare \r straight over the WS and submit an incomplete - // prompt instead of adding a line to it. Targets THIS pane's own - // session (this.sessionId), never the primary pane's + // Shift+Enter / Ctrl+Enter: insert a newline instead of submitting, as + // terminal-ui.js does. xterm sends plain \r for every Enter variant, + // so an Ink app (Claude Code) can't tell a newline from a submit, and + // without this gate this pane's onData would send that bare \r and + // submit an incomplete prompt instead of adding a line to it. Targets + // THIS pane's own session (this.sessionId), never the primary pane's // activeSessionId, and has no local-echo overlay of its own to flush - // first (Pane B is deliberately plainer — see the fileoverview). + // first (this pane is deliberately plainer, see the fileoverview). // Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends. if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) { if (ev.type === 'keydown') { @@ -316,17 +314,13 @@ // Load existing scrollback before going live. The WS below is // subscribe-only (ws-routes.ts sends nothing on connect, only future - // 'terminal' events), so without this Pane B stays blank until the - // target session happens to produce new output. It LOOKED - // intermittent rather than always-broken because _sendResize() below - // often nudges the shared session's real tmux window to a new size, - // and tmux repaints its current screen on resize — that repaint was - // getting captured and streamed here, incidentally populating the - // pane. When Pane B's computed dimensions happened to already match - // the session's last-known size, Session.resize() (session.ts) skips - // the resize as a no-op, no repaint fires, and the pane stayed blank. - // The await covers the whole chunked replay, not just the fetch, so a - // live frame from the socket below can never land in the middle of it. + // 'terminal' events), so without this the pane stays blank until the + // session happens to produce new output. The resize _sendResize() sends + // on open is no substitute: tmux repaints on a resize, but + // Session.resize() (session.ts) skips one that matches the session's + // last size, and then nothing repaints at all. The await covers the + // whole chunked replay, not just the fetch, so a live frame from the + // socket below can never land in the middle of it. await this._loadBuffer(); if (this._destroyed) return; @@ -372,8 +366,8 @@ } else if (msg.t === 'r') { // Server-triggered refresh (SSE backpressure cleared, terminal // data was dropped). The primary pane routes this to - // _onSessionNeedsRefresh (app.js) — Pane B has its own - // buffer loader for the same reason connect() does. + // _onSessionNeedsRefresh (app.js); this pane has its own buffer + // loader for the same reason connect() does. this._refreshBuffer(); } else if (msg.t === 'ia') { // Input ACK. The frame names no session, so it is this pane's. @@ -382,7 +376,7 @@ this._onPtyGeometryReport(msg.c, msg.r); } } catch { - /* Malformed frame — ignore, matches primary pane's tolerance. */ + /* Malformed frame: ignored, as in the primary pane. */ } }; @@ -397,7 +391,7 @@ }; ws.onerror = () => { - // onclose fires after onerror — cleanup happens there. + // onclose fires after onerror: cleanup happens there. }; } @@ -407,8 +401,8 @@ if (!ws) return; ws.onopen = null; ws.onmessage = null; - // onclose fires asynchronously AFTER close(); without this it ran its - // "disconnected" write against a pane already torn down or replaced. + // onclose fires asynchronously AFTER close(); without this it would run + // its "disconnected" write against a pane already torn down or replaced. ws.onclose = null; ws.onerror = null; try { @@ -575,7 +569,7 @@ // Fetches and writes the session's current scrollback. Used both by // connect() (initial load) and by the `{t:'r'}` server-refresh frame - // (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is + // (above). The primary pane's own _onSessionNeedsRefresh (app.js) is // scoped to `this.activeSessionId` and clears/rewrites the primary // terminal, neither of which applies to this independent pane, so this is // a standalone equivalent rather than a call into it. @@ -584,10 +578,11 @@ // _onSessionNeedsRefresh): a shell session can retain hundreds of // thousands of plain scrollback lines, so pulling `?full=1` there parses // an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body - // into a 50000-line xterm on every load. Non-shell (TUI) sessions still - // get one full replay. `fetch` here goes through the global wrapper - // (constants.js), which already prefixes CodemanBase — unlike the raw - // WebSocket URL above, which does not. + // into this xterm on every load; a shell loads the `tail=` window. A + // non-shell (TUI) session gets one full replay, or the same bounded window + // with `boundedLoad` (grid tiles). `fetch` here goes through the global + // wrapper (constants.js), which already prefixes CodemanBase, unlike the + // raw WebSocket URL above, which does not. // // Single-flight: the flag is held across the fetch AND the chunked write // (writeChunked resolves after its last chunk), so two replays can never @@ -721,20 +716,20 @@ // Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of // output (`cat` of a file longer than the screen) instead of scrolling it, // so this pane's xterm ends up with about one screen of scrollback while - // tmux holds every line — and nothing here ever went back to ask, so the - // history was unreachable. The primary pane has the same pull - // (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its - // own. Shell only: a non-shell CLI's history is out of scope for this pull - // (its load already takes `full=1`; codex and Claude's inline renderer do - // grow tmux history, this just isn't how they recover it). The alternate- - // screen skip (nano, vim, less) only matters for a direct-PTY shell — under - // tmux the browser xterm never enters the alternate buffer. + // tmux holds every line, and without this pull the history is + // unreachable. The primary pane has the same pull + // (app.js _maybeRefetchFullHistory); a tile is a separate xterm and needs + // its own. Shell only: a non-shell CLI's history is out of scope for this + // pull (its load already takes `full=1`; codex and Claude's inline renderer + // do grow tmux history, this just isn't how they recover it). The + // alternate-screen skip (nano, vim, less) only matters for a direct-PTY + // shell: under tmux the browser xterm never enters the alternate buffer. _maybeLoadMoreHistory() { if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return; if (this._bufferLoading) return; // Mirrors app.js _maybeRefetchFullHistory and this pane's own // _sendResize(): a detached session's own window already owns its PTY - // size and scrollback, so Pane B has nothing of its own to reconcile. + // size and scrollback, so this pane has nothing of its own to reconcile. if (this.detachedSessions?.has(this.sessionId)) return; const active = this.terminal.buffer.active; if (active.type !== 'normal' || active.viewportY !== 0) return; @@ -795,7 +790,7 @@ capturedAt = performance.now(); // Opened only now: a frame from before the response is either replaced by // the capture or written unchanged, so holding it for the round trip - // bought nothing and froze the pane for as long as the fetch took. + // would buy nothing and freeze the pane for as long as the fetch took. this._liveQueue = []; const payload = (await res.json())?.data; clearTimeout(abortTimer); @@ -842,7 +837,7 @@ if (delta > 0) this.terminal.scrollToLine(delta); else this.terminal.scrollToTop(); } catch { - /* Best-effort — live output keeps arriving whatever happens here. */ + /* Best-effort: live output keeps arriving whatever happens here. */ } finally { clearTimeout(abortTimer); this._loadAbort = null; @@ -880,7 +875,7 @@ } // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh - // frames in a row used to start two concurrent replays, each clearing + // frames in a row must not start two concurrent replays, each clearing // the terminal under the other's chunked write. A refresh that arrives // mid-replay is COALESCED into one trailing re-run rather than ignored: // the in-flight fetch may predate the drop the new frame is reporting, @@ -893,8 +888,8 @@ void this._loadBuffer({ refresh: true }); } - // Local reflow only — no PTY resize frame. Split out so a divider drag - // can reflow both panes at the browser's paint rate (rAF) while sending + // Local reflow only, no PTY resize frame. Split out so a divider drag + // can reflow the panes at the browser's paint rate (rAF) while sending // the actual `{t:'z'}` resize once, at drag end, matching the primary // pane's own convention (throttledResize in terminal-ui.js). localFit() { @@ -915,8 +910,8 @@ if (!this._wsReady || !this.fitAddon || !this.terminal) return; // One PTY cannot hold two sizes (mirrors sendResize's own // detachedElsewhere yield in terminal-ui.js): the session got detached - // to its own window AFTER this split was opened, so its own window now - // owns the PTY's size and Pane B must stand aside. + // to its own window AFTER this pane was opened, so its own window now + // owns the PTY's size and this pane must stand aside. if (this.detachedSessions?.has(this.sessionId)) return; // A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and // fit() then leaves the xterm alone: there is no size worth reporting. @@ -924,9 +919,9 @@ if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return; // Report what the xterm actually holds, so the PTY gets exactly the size // the pane renders at. Unclamped, unlike the primary pane's 40x10 floor: - // a floor here misreported Pane B's width at the divider's reachable 20% - // position (about 28 columns), causing real output-wrapping bugs, and a - // floored xterm would be wider than its container. The server enforces + // a floor would misreport the split's Pane B at its divider's reachable + // 20% position (about 28 columns) and wrap output wrongly, and a floored + // xterm would be wider than its container. The server enforces // its own valid range ([1,500]/[1,200] in ws-routes.ts). const cols = this.terminal.cols; const rows = this.terminal.rows; From a07c663ca9923092a3727a1709a626eceede6b5d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 04:05:30 +0200 Subject: [PATCH 59/90] test(tiles): the grid harness finds a tile's element, and serves the pure helpers Five tile-grid tests defined the same `tileEl(id)` lookup and three more inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and they import it. tile-grid-open-set built a second vm context just to read constants.js, although the harness it already imports has loaded the same file: it reads windowStub.CodemanTileGrid instead. No assertion changed. Mutation-checked: tiles without their data-session-id fail 34 tests across the seven files, and a broken tileGridOpenSet fails the open-set cases. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/mocks/tile-grid-vm.ts | 2 ++ test/tile-grid-attach.test.ts | 4 +--- test/tile-grid-auto-join.test.ts | 4 ++-- test/tile-grid-chrome.test.ts | 5 ++--- test/tile-grid-dividers.test.ts | 2 +- test/tile-grid-entry-points.test.ts | 2 +- test/tile-grid-open-set.test.ts | 12 ++++-------- test/tile-grid-select-branch.test.ts | 4 ++-- test/tile-grid-zoom.test.ts | 11 +++++++++-- 9 files changed, 24 insertions(+), 22 deletions(-) diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 2b1863cf..82f74712 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -159,6 +159,8 @@ export const section = new FakeEl(); section.id = 'tileGrid'; section.className = 'tile-grid'; main.appendChild(section); +/** The grid's element for one tiled session (its `.tile`), found by its data-session-id. */ +export const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; /** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */ export const bySelector = new Map(); diff --git a/test/tile-grid-attach.test.ts b/test/tile-grid-attach.test.ts index 9d5a281b..f4eeb094 100644 --- a/test/tile-grid-attach.test.ts +++ b/test/tile-grid-attach.test.ts @@ -18,18 +18,16 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - FakeEl, FakeTile, fetchSpy, makeGridApp, resetGridHarness, - section, windowStub, type GridApp, + tileEl, } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; -const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; const overlayOf = (id: string) => tileEl(id).children[1].children.find((c) => c.className === 'tile-attach') ?? null; const visible = (id: string) => !!overlayOf(id) && !overlayOf(id)!.hidden; const textOf = (id: string) => overlayOf(id)!.children[0].textContent; diff --git a/test/tile-grid-auto-join.test.ts b/test/tile-grid-auto-join.test.ts index 9de44700..c63ccaed 100644 --- a/test/tile-grid-auto-join.test.ts +++ b/test/tile-grid-auto-join.test.ts @@ -18,7 +18,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; +import { FakeTile, makeGridApp, resetGridHarness, section, type GridApp, tileEl } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b']; const addSession = (app: GridApp, id: string, workingDir = '/w') => @@ -52,7 +52,7 @@ describe('Run from this tab', () => { app.openTileGrid(IDS); addSession(app, 's-new'); app._joinTileGridFromRun('s-new'); - const tile = section.children.find((el) => el.dataset.sessionId === 's-new') as FakeEl; + const tile = tileEl('s-new'); const overlay = tile.children[1].children.find((c) => c.className === 'tile-attach'); expect(!overlay || overlay.hidden).toBe(true); }); diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 15abd939..3b5a07da 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -18,7 +18,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; +import { FakeEl, FakeTile, makeGridApp, resetGridHarness, type GridApp, tileEl } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -38,7 +38,6 @@ function openGrid(): GridApp { return app; } -const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; const headerOf = (id: string) => tileEl(id).children[0]; const buttonOf = (id: string, cls: string) => headerOf(id).children[2].children.find((b) => b.className.includes(cls)) as FakeEl; @@ -99,7 +98,7 @@ describe('the header', () => { describe('header buttons', () => { it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => { const app = openGrid(); - const tile = section.children.find((el) => el.dataset.sessionId === 's-a') as FakeEl; + const tile = tileEl('s-a'); const actions = tile.children[0].children.find((c) => c.className === 'tile-actions') as FakeEl; expect(actions.children.map((b) => b.className)).toEqual([ 'tile-btn tile-menu', diff --git a/test/tile-grid-dividers.test.ts b/test/tile-grid-dividers.test.ts index 9f3e7604..1e3e8fbe 100644 --- a/test/tile-grid-dividers.test.ts +++ b/test/tile-grid-dividers.test.ts @@ -22,10 +22,10 @@ import { resetGridHarness, section, type GridApp, + tileEl, } from './mocks/tile-grid-vm.js'; const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; -const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl; const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile; diff --git a/test/tile-grid-entry-points.test.ts b/test/tile-grid-entry-points.test.ts index 2c181677..194ade22 100644 --- a/test/tile-grid-entry-points.test.ts +++ b/test/tile-grid-entry-points.test.ts @@ -24,10 +24,10 @@ import { section, windowStub, type GridApp, + tileEl, } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; -const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; const slots = () => section.children.filter((el) => el.className === 'tile-slot'); const dragEvent = () => ({ preventDefault: vi.fn(), stopPropagation: vi.fn(), dataTransfer: { dropEffect: '' } }); diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index de4775dc..b8c5f2d6 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -11,12 +11,11 @@ * (`toggleTileGrid`); right-click (contextmenu) opens the picker. With the * grid open the picker shows the current tiles, and Open replaces them. * - * Pure helper via `vm`, the app via the shared harness (test/mocks/tile-grid-vm.ts). + * The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts). * Port: N/A. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import vm from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, @@ -25,17 +24,14 @@ import { documentAddEventListener, makeGridApp, resetGridHarness, + windowStub, } from './mocks/tile-grid-vm.js'; type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null; type Helpers = { tileGridOpenSet(p: Record): OpenSet; TILE_GRID_MAX: number }; -function loadHelpers(): Helpers { - const context = vm.createContext({ window: {}, globalThis: {} }); - vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'), context); - return (context.window as { CodemanTileGrid: Helpers }).CodemanTileGrid; -} -const T = loadHelpers(); +// constants.js as the harness loaded it (window.CodemanTileGrid). +const T = windowStub.CodemanTileGrid as Helpers; const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index b52b1a91..14c9b115 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -26,8 +26,8 @@ import { localStore, makeGridApp, resetGridHarness, - section, type GridApp, + tileEl, } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -163,7 +163,7 @@ describe('moving focus between tiles', () => { it('pressing a tile is a human selection of its session', async () => { const app = openGrid(); app.selectSession = vi.fn(); - const tileB = section.children.find((el) => el.dataset.sessionId === 's-b'); + const tileB = tileEl('s-b'); tileB?.dispatch('pointerdown'); expect(app.selectSession).toHaveBeenCalledWith('s-b'); }); diff --git a/test/tile-grid-zoom.test.ts b/test/tile-grid-zoom.test.ts index 70f22a36..7dce6516 100644 --- a/test/tile-grid-zoom.test.ts +++ b/test/tile-grid-zoom.test.ts @@ -16,10 +16,17 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeEl, FakeTile, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; +import { + FakeEl, + FakeTile, + makeGridApp, + resetGridHarness, + section, + type GridApp, + tileEl, +} from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c', 's-d']; -const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; const zoomButton = (id: string) => tileEl(id).children[0].children[2].children.find((b) => b.className.includes('tile-zoom')) as FakeEl; From 9f9bdbb671ad06a5bf7b6a0c4d5a2279b59ead5e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 04:13:30 +0200 Subject: [PATCH 60/90] docs(tiles): rewrap removeTile's doc comment f9709c3a added a clause to removeTile's doc comment without rewrapping it, leaving one line far past the file's width. Comment only. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 59138143..2cb1673e 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -667,8 +667,9 @@ Object.assign(CodemanApp.prototype, { /** * Removes one tile; the session keeps running. When it held focus, `refocus` * moves focus to the neighbouring tile (next in grid order, else previous), - * as the app's choice (`auto`: no idle alert is spent). The last tile leaving closes the grid: with `refocus` the single view then - * shows that session, without it the caller decides what comes next. + * as the app's choice (`auto`: no idle alert is spent). The last tile + * leaving closes the grid: with `refocus` the single view then shows that + * session, without it the caller decides what comes next. */ removeTile(sessionId, { refocus = true } = {}) { const grid = this._tileGrid; From 4bfe23908302f624467c16c1b366c7b4fac0d459 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 09:39:07 +0200 Subject: [PATCH 61/90] docs(tiles): each SSE doc comment sits above its own function _sseFilterSessionId() and its doc comment landed between _updateSseSubscription()'s doc comment and that function, so two doc blocks sat back to back and _updateSseSubscription had none. Each block is now above its own function; the moved one's em dash became a colon. Comments only. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 0c8be117..ecf64b61 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1476,13 +1476,6 @@ class CodemanApp { // SSE Connection // ═══════════════════════════════════════════════════════════════ - /** - * POST a live subscription update so the server filters terminal events - * to the given session(s) for this client. Fire-and-forget — failures - * are non-fatal because we'll still get every event we don't want - * (just at higher cost), and the next reconnect carries the filter via - * the SSE query string. - */ /** * The session id the SSE filter names for `sessionId`: itself, or while the * tile grid owns the terminal the grid's fixed filter (TILE_GRID_SSE_FILTER, @@ -1495,6 +1488,13 @@ class CodemanApp { return sessionId; } + /** + * POST a live subscription update so the server filters terminal events + * to the given session(s) for this client. Fire-and-forget: failures + * are non-fatal because we'll still get every event we don't want + * (just at higher cost), and the next reconnect carries the filter via + * the SSE query string. + */ _updateSseSubscription(sessionId) { try { const filterId = this._sseFilterSessionId(sessionId); From 8ce2e5acbb630dfb23955e2617ede71f02034bd2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 04:07:35 +0200 Subject: [PATCH 62/90] test(split): TerminalTile's socket, xterm and fit fakes live in test/mocks terminal-tile-input defined FakeSocket, FakeFit and FakeTerminal inline; they move unchanged to test/mocks/terminal-tile-fakes.ts so the grid's load-queue test can drive a real TerminalTile on the same fakes instead of its own near-copies. No assertion changed (a tile that never registers its input socket still fails six cases). Co-Authored-By: Claude Opus 5.5 (1M context) --- test/mocks/terminal-tile-fakes.ts | 122 ++++++++++++++++++++++++++++++ test/terminal-tile-input.test.ts | 117 +--------------------------- 2 files changed, 125 insertions(+), 114 deletions(-) create mode 100644 test/mocks/terminal-tile-fakes.ts diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts new file mode 100644 index 00000000..d4081cc4 --- /dev/null +++ b/test/mocks/terminal-tile-fakes.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a + * `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in + * the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs + * `connect()` and the socket handlers for real. + */ +import { vi } from 'vitest'; + +export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number }; + +/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */ +export class FakeSocket { + static OPEN = 1; + static instances: FakeSocket[] = []; + readyState = 0; + sent: Frame[] = []; + onopen: (() => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onclose: ((ev?: { code: number }) => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) { + FakeSocket.instances.push(this); + } + send(data: string) { + this.sent.push(JSON.parse(data) as Frame); + } + close = vi.fn(() => { + this.readyState = 3; + }); + open() { + this.readyState = 1; + this.onopen?.(); + } + receive(msg: object) { + this.onmessage?.({ data: JSON.stringify(msg) }); + } + inputFrames() { + return this.sent.filter((f) => f.t === 'i'); + } +} + +/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */ +export class FakeFit { + static proposed = { cols: 80, rows: 24 }; + term: FakeTerminal | null = null; + fit() { + const { cols, rows } = FakeFit.proposed; + if (!Number.isFinite(cols) || !Number.isFinite(rows)) return; + this.term?.resize(cols, rows); + } + proposeDimensions() { + return { ...FakeFit.proposed }; + } +} + +/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */ +export class FakeTerminal { + static last: FakeTerminal | null = null; + options: Record; + cols = 80; + rows = 24; + dataCb: ((data: string) => void) | null = null; + buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; + constructor(options: Record) { + this.options = { ...options }; + FakeTerminal.last = this; + } + loadAddon(addon: FakeFit) { + addon.term = this; + } + open() {} + onData(cb: (data: string) => void) { + this.dataCb = cb; + } + keyHandler: ((ev: Record) => boolean) | null = null; + focusListeners: Array<() => void> = []; + textarea = { + addEventListener: (type: string, fn: () => void) => { + if (type === 'focus') this.focusListeners.push(fn); + }, + removeEventListener: (type: string, fn: () => void) => { + if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn); + }, + }; + focusTextarea() { + for (const fn of this.focusListeners) fn(); + } + attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { + this.keyHandler = fn; + } + registerLinkProvider() {} + writes: string[] = []; + write(data: string, cb?: () => void) { + this.writes.push(data); + cb?.(); + } + clear() { + this.writes.push(''); + } + resizes: Array<[number, number]> = []; + resize(cols: number, rows: number) { + this.resizes.push([cols, rows]); + this.cols = cols; + this.rows = rows; + } + dispose() {} + type(data: string) { + this.dataCb?.(data); + } + /** What a drag selected; '' is no selection. */ + selection = ''; + hasSelection() { + return this.selection !== ''; + } + getSelection() { + return this.selection; + } + clearSelection = vi.fn(() => { + this.selection = ''; + }); + focus = vi.fn(); +} diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index f703166d..3eddf5d9 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -18,126 +18,15 @@ * * Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the * shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the - * fit addon and WebSocket are fakes; `connect()` runs for real. + * fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts); + * `connect()` runs for real. */ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number }; - -class FakeSocket { - static OPEN = 1; - static instances: FakeSocket[] = []; - readyState = 0; - sent: Frame[] = []; - onopen: (() => void) | null = null; - onmessage: ((ev: { data: string }) => void) | null = null; - onclose: ((ev?: { code: number }) => void) | null = null; - onerror: (() => void) | null = null; - constructor(public url: string) { - FakeSocket.instances.push(this); - } - send(data: string) { - this.sent.push(JSON.parse(data) as Frame); - } - close = vi.fn(() => { - this.readyState = 3; - }); - open() { - this.readyState = 1; - this.onopen?.(); - } - receive(msg: object) { - this.onmessage?.({ data: JSON.stringify(msg) }); - } - inputFrames() { - return this.sent.filter((f) => f.t === 'i'); - } -} - -/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */ -class FakeFit { - static proposed = { cols: 80, rows: 24 }; - term: FakeTerminal | null = null; - fit() { - const { cols, rows } = FakeFit.proposed; - if (!Number.isFinite(cols) || !Number.isFinite(rows)) return; - this.term?.resize(cols, rows); - } - proposeDimensions() { - return { ...FakeFit.proposed }; - } -} - -class FakeTerminal { - static last: FakeTerminal | null = null; - options: Record; - cols = 80; - rows = 24; - dataCb: ((data: string) => void) | null = null; - buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; - constructor(options: Record) { - this.options = { ...options }; - FakeTerminal.last = this; - } - loadAddon(addon: FakeFit) { - addon.term = this; - } - open() {} - onData(cb: (data: string) => void) { - this.dataCb = cb; - } - keyHandler: ((ev: Record) => boolean) | null = null; - focusListeners: Array<() => void> = []; - textarea = { - addEventListener: (type: string, fn: () => void) => { - if (type === 'focus') this.focusListeners.push(fn); - }, - removeEventListener: (type: string, fn: () => void) => { - if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn); - }, - }; - focusTextarea() { - for (const fn of this.focusListeners) fn(); - } - attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { - this.keyHandler = fn; - } - registerLinkProvider() {} - writes: string[] = []; - write(data: string, cb?: () => void) { - this.writes.push(data); - cb?.(); - } - clear() { - this.writes.push(''); - } - resizes: Array<[number, number]> = []; - resize(cols: number, rows: number) { - this.resizes.push([cols, rows]); - this.cols = cols; - this.rows = rows; - } - dispose() {} - type(data: string) { - this.dataCb?.(data); - } - /** What a drag selected; '' is no selection. */ - selection = ''; - hasSelection() { - return this.selection !== ''; - } - getSelection() { - return this.selection; - } - clearSelection = vi.fn(() => { - this.selection = ''; - }); - focus = vi.fn(); -} +import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js'; const fetchMock = vi.fn(); From 218b03ceb7a8f728f4262e46db80c6c67ed88673 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 04:09:04 +0200 Subject: [PATCH 63/90] test(tiles): the load-queue test drives TerminalTile on the shared fakes tile-grid-load-queue carried its own FakeSocket, FakeFit and FakeTerminal, near-copies of terminal-tile-input's. It now imports test/mocks/terminal-tile-fakes.ts, which gains what only it used: FakeSocket.drop(code), the terminal's scrollToLine / scrollToTop, and the replay-pace extension (an opt-in `holdParse` that keeps write callbacks from running, as on a disposed xterm, and empty writes left out of `writes`, since the replay queues one only to hear it was parsed). One definition serves both files with no per-file switch: terminal-tile-input passes unchanged with the extension in place, the shared fit resizes to the default 80x24 the tile already has, and FakeSocket.OPEN is the real value. Every assertion is unchanged. Mutation-checked through the shared fakes: dropping destroy()'s replay settle fails the destroy-while-parsing case, a queue that runs two loads at once fails eleven cases, and a tile that never registers its input socket fails six in terminal-tile-input. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/mocks/terminal-tile-fakes.ts | 15 +++++- test/tile-grid-load-queue.test.ts | 78 +------------------------------ 2 files changed, 15 insertions(+), 78 deletions(-) diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index d4081cc4..63eb82b9 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -37,6 +37,11 @@ export class FakeSocket { inputFrames() { return this.sent.filter((f) => f.t === 'i'); } + /** The connection drops: closed, and the tile hears `code`. */ + drop(code = 1006) { + this.readyState = 3; + this.onclose?.({ code }); + } } /** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */ @@ -90,9 +95,13 @@ export class FakeTerminal { } registerLinkProvider() {} writes: string[] = []; + /** Set by a test: write callbacks never run, as on a disposed xterm. */ + holdParse = false; write(data: string, cb?: () => void) { - this.writes.push(data); - cb?.(); + // An empty write puts nothing on screen; the replay queues one only to hear + // (its callback) that everything before it has been parsed. + if (data) this.writes.push(data); + if (!this.holdParse) cb?.(); } clear() { this.writes.push(''); @@ -103,6 +112,8 @@ export class FakeTerminal { this.cols = cols; this.rows = rows; } + scrollToLine() {} + scrollToTop() {} dispose() {} type(data: string) { this.dataCb?.(data); diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts index 59cea824..69ff3ae0 100644 --- a/test/tile-grid-load-queue.test.ts +++ b/test/tile-grid-load-queue.test.ts @@ -23,88 +23,14 @@ * * Real code under test: constants.js + app.js + terminal-ui.js + * terminal-tile.js in one `vm` context; xterm, the fit addon and WebSocket are - * fakes. Port: N/A. + * fakes (test/mocks/terminal-tile-fakes.ts). 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -class FakeSocket { - static instances: FakeSocket[] = []; - readyState = 0; - sent: Array> = []; - onopen: (() => void) | null = null; - onmessage: ((ev: { data: string }) => void) | null = null; - onclose: ((ev?: { code: number }) => void) | null = null; - onerror: (() => void) | null = null; - constructor(public url: string) { - FakeSocket.instances.push(this); - } - send(data: string) { - this.sent.push(JSON.parse(data)); - } - close = vi.fn(() => { - this.readyState = 3; - }); - open() { - this.readyState = 1; - this.onopen?.(); - } - receive(msg: object) { - this.onmessage?.({ data: JSON.stringify(msg) }); - } - drop(code = 1006) { - this.readyState = 3; - this.onclose?.({ code }); - } -} - -class FakeFit { - term: FakeTerminal | null = null; - fit() {} - proposeDimensions() { - return { cols: 80, rows: 24 }; - } -} - -class FakeTerminal { - options: Record; - cols = 80; - rows = 24; - buffer = { active: { type: 'normal', viewportY: 0, length: 24 } }; - writes: string[] = []; - constructor(options: Record) { - this.options = { ...options }; - } - loadAddon(addon: FakeFit) { - addon.term = this; - } - open() {} - onData() {} - attachCustomKeyEventHandler() {} - registerLinkProvider() {} - textarea = { addEventListener() {}, removeEventListener() {} }; - /** Set by a test: write callbacks never run, as on a disposed xterm. */ - holdParse = false; - write(data: string, cb?: () => void) { - // An empty write puts nothing on screen; the replay queues one only to hear - // (its callback) that everything before it has been parsed. - if (data) this.writes.push(data); - if (!this.holdParse) cb?.(); - } - clear() { - this.writes.push(''); - } - resize(cols: number, rows: number) { - this.cols = cols; - this.rows = rows; - } - scrollToLine() {} - scrollToTop() {} - dispose() {} -} +import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js'; /** One `/terminal` fetch the test answers (or lets hang) by hand. */ type Capture = { url: string; settled: boolean; aborted: boolean; answer(body: string): void }; From d044406f8f0add27bc1d1c74af239a47b4fcaa0f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 4 Oct 2026 23:52:31 +0200 Subject: [PATCH 64/90] feat(web): show each CLI's logo in the Run menus instead of a colour dot The Run menus (toolbar dropdown, phone overview picker, Custom Endpoint rows, model picker) marked every backend with an 8px colour dot, so telling Codex from DeepSeek meant reading the label. Each known backend now draws its own logo in that slot. It is CSS only: every surface already renders `.run-mode-dot `, so no markup changes. - Brand-coloured marks (Claude, Gemini, Antigravity, DeepSeek, OMP) paint as a background image; monochrome ones (Codex, OpenCode, Pi, Grok, plus Shell and web URLs) are masks over the row's text colour, so they follow every skin. - Logos are inline SVG data URIs (img-src already allows data:), from @lobehub/icons-static-svg 1.95.1 (MIT); the OMP mark is omp.sh's own. - Drops the non-og skin overrides that re-tinted four dots with a `background:` shorthand, which would have wiped the logo. - An id with no logo (a clis.json addition) keeps a dot, now in --text-dim instead of being transparent. - test/run-menu-cli-logos.test.ts pins that every stock agent plus shell/web has a logo in exactly one paint group and that nothing resets the slot. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit d00229ee29bd4c65a37d512b773689ed4b521f16) --- src/web/public/styles.css | 88 ++++++++++++++++++++++++--------- test/run-menu-cli-logos.test.ts | 85 +++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 test/run-menu-cli-logos.test.ts diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5843cdfc..87121d60 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4361,8 +4361,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { transform: translateY(-1px); } -/* Antigravity: cyan identity, matching .btn-toolbar.btn-run.mode-antigravity and - .run-mode-dot.antigravity so the welcome action reads as the same backend. */ +/* Antigravity: cyan identity, matching .btn-toolbar.btn-run.mode-antigravity so + the welcome action reads as the same backend. */ .welcome-btn-antigravity { background: linear-gradient(135deg, #0b2b33 0%, #0e7490 55%, #0891b2 100%); border-color: rgba(34, 211, 238, 0.4); @@ -4378,8 +4378,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { transform: translateY(-1px); } -/* Pi (pi.dev): rose identity, matching .btn-toolbar.btn-run.mode-pi and - .run-mode-dot.pi so the welcome action reads as the same backend. */ +/* Pi (pi.dev): rose identity, matching .btn-toolbar.btn-run.mode-pi so the + welcome action reads as the same backend. */ .welcome-btn-pi { background: linear-gradient(135deg, #33121f 0%, #9d174d 55%, #be185d 100%); border-color: rgba(244, 114, 182, 0.4); @@ -4394,8 +4394,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #fff1f7; transform: translateY(-1px); } -/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and - .run-mode-dot.omp so the welcome action reads as the same backend. */ +/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp so the welcome + action reads as the same backend. */ .welcome-btn-omp { background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%); border-color: rgba(129, 140, 248, 0.4); @@ -4412,7 +4412,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { } /* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok - and .run-mode-dot.grok so the welcome action reads as the same backend. */ + so the welcome action reads as the same backend. */ .welcome-btn-grok { background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%); border-color: rgba(212, 212, 216, 0.4); @@ -4429,8 +4429,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { } /* DeepSeek Harness: the #4d6bfe blue identity, matching - .btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome - action reads as the same backend. */ + .btn-toolbar.btn-run.mode-deepseek so the welcome action reads as the same + backend. */ .welcome-btn-deepseek { background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%); border-color: rgba(124, 147, 255, 0.4); @@ -5649,17 +5649,62 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { height: 8px; border-radius: 50%; flex-shrink: 0; + background: var(--text-dim); } -.run-mode-dot.claude { background: #3b82f6; } -.run-mode-dot.opencode { background: #10b981; } -.run-mode-dot.codex { background: #a855f7; } -.run-mode-dot.gemini { background: #8ab4f8; } -.run-mode-dot.antigravity { background: #22d3ee; } -.run-mode-dot.pi { background: #f472b6; } -.run-mode-dot.grok { background: #a1a1aa; } -.run-mode-dot.deepseek { background: #4d6bfe; } -.run-mode-dot.omp { background: #818cf8; } -.run-mode-dot.shell { background: #94a3b8; } +/* Every backend the Run menus know draws its own logo in this slot instead of a + colour dot. The toolbar menu, the phone overview picker, the Custom Endpoint + rows and the model picker all render `.run-mode-dot `, so they pick this + up with no markup change. An id with no rule here (a CLI added through + ~/.codeman/clis.json, say) keeps the plain 8px dot. + Logos: @lobehub/icons-static-svg 1.95.1 (MIT, (c) LobeHub); the OMP mark is + the one omp.sh draws in its own header (oh-my-pi, MIT). The marks are their + owners' trademarks, shown only to name the CLI a row launches. */ +.run-mode-dot.claude, +.run-mode-dot.gemini, +.run-mode-dot.antigravity, +.run-mode-dot.deepseek, +.run-mode-dot.omp, +.run-mode-dot.codex, +.run-mode-dot.opencode, +.run-mode-dot.pi, +.run-mode-dot.grok, +.run-mode-dot.shell, +.run-mode-dot.web { + width: 1.15em; + height: 1.15em; + border-radius: 0; +} +/* Brand-coloured marks read on dark and light skins alike, so they paint as is. */ +.run-mode-dot.claude, +.run-mode-dot.gemini, +.run-mode-dot.antigravity, +.run-mode-dot.deepseek, +.run-mode-dot.omp { + background: var(--run-mode-logo) center / contain no-repeat; +} +/* Monochrome marks are masks over the row's own text colour, so they follow + every skin (light ones included) the way the label next to them does. */ +.run-mode-dot.codex, +.run-mode-dot.opencode, +.run-mode-dot.pi, +.run-mode-dot.grok, +.run-mode-dot.shell, +.run-mode-dot.web { + background-color: currentColor; + -webkit-mask: var(--run-mode-logo) center / contain no-repeat; + mask: var(--run-mode-logo) center / contain no-repeat; +} +.run-mode-dot.claude { --run-mode-logo: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z' fill='%23D97757' fill-rule='nonzero'/%3E%3C/svg%3E"); } +.run-mode-dot.gemini { --run-mode-logo: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z' fill='%233186FF'/%3E%3Cpath d='M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z' fill='url(%23gemini0)'/%3E%3Cpath d='M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z' fill='url(%23gemini1)'/%3E%3Cpath d='M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z' fill='url(%23gemini2)'/%3E%3Cdefs%3E%3ClinearGradient gradientUnits='userSpaceOnUse' id='gemini0' x1='7' x2='11' y1='15.5' y2='12'%3E%3Cstop stop-color='%2308B962'/%3E%3Cstop offset='1' stop-color='%2308B962' stop-opacity='0'/%3E%3C/linearGradient%3E%3ClinearGradient gradientUnits='userSpaceOnUse' id='gemini1' x1='8' x2='11.5' y1='5.5' y2='11'%3E%3Cstop stop-color='%23F94543'/%3E%3Cstop offset='1' stop-color='%23F94543' stop-opacity='0'/%3E%3C/linearGradient%3E%3ClinearGradient gradientUnits='userSpaceOnUse' id='gemini2' x1='3.5' x2='17.5' y1='13.5' y2='12'%3E%3Cstop stop-color='%23FABC12'/%3E%3Cstop offset='.46' stop-color='%23FABC12' stop-opacity='0'/%3E%3C/linearGradient%3E%3C/defs%3E%3C/svg%3E"); } +.run-mode-dot.antigravity { --run-mode-logo: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cmask height='23' id='antigravity0' maskUnits='userSpaceOnUse' width='24' x='0' y='1'%3E%3Cpath d='M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z' fill='%23fff'/%3E%3C/mask%3E%3Cg mask='url(%23antigravity0)'%3E%3Cg filter='url(%23antigravity1)'%3E%3Cpath d='M-1.018-3.992c-.408 3.591 2.686 6.89 6.91 7.37 4.225.48 7.98-2.043 8.387-5.633.408-3.59-2.686-6.89-6.91-7.37-4.225-.479-7.98 2.043-8.387 5.633z' fill='%23FFE432'/%3E%3C/g%3E%3Cg filter='url(%23antigravity2)'%3E%3Cpath d='M15.269 7.747c1.058 4.557 5.691 7.374 10.348 6.293 4.657-1.082 7.575-5.653 6.516-10.21-1.058-4.556-5.691-7.374-10.348-6.292-4.657 1.082-7.575 5.653-6.516 10.21z' fill='%23FC413D'/%3E%3C/g%3E%3Cg filter='url(%23antigravity3)'%3E%3Cpath d='M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z' fill='%2300B95C'/%3E%3C/g%3E%3Cg filter='url(%23antigravity4)'%3E%3Cpath d='M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z' fill='%2300B95C'/%3E%3C/g%3E%3Cg filter='url(%23antigravity5)'%3E%3Cpath d='M-7.608 14.703c3.352 3.424 9.126 3.208 12.896-.483 3.77-3.69 4.108-9.459.756-12.883C2.69-2.087-3.083-1.871-6.853 1.82c-3.77 3.69-4.108 9.458-.755 12.883z' fill='%2300B95C'/%3E%3C/g%3E%3Cg filter='url(%23antigravity6)'%3E%3Cpath d='M9.932 27.617c1.04 4.482 5.384 7.303 9.7 6.3 4.316-1.002 6.971-5.448 5.93-9.93-1.04-4.483-5.384-7.304-9.7-6.301-4.316 1.002-6.971 5.448-5.93 9.93z' fill='%233186FF'/%3E%3C/g%3E%3Cg filter='url(%23antigravity7)'%3E%3Cpath d='M2.572-8.185C.392-3.329 2.778 2.472 7.9 4.771c5.122 2.3 11.042.227 13.222-4.63 2.18-4.855-.205-10.656-5.327-12.955-5.122-2.3-11.042-.227-13.222 4.63z' fill='%23FBBC04'/%3E%3C/g%3E%3Cg filter='url(%23antigravity8)'%3E%3Cpath d='M-3.267 38.686c-5.277-2.072 3.742-19.117 5.984-24.83 2.243-5.712 8.34-8.664 13.616-6.592 5.278 2.071 11.533 13.482 9.29 19.195-2.242 5.713-23.613 14.298-28.89 12.227z' fill='%233186FF'/%3E%3C/g%3E%3Cg filter='url(%23antigravity9)'%3E%3Cpath d='M28.71 17.471c-1.413 1.649-5.1.808-8.236-1.878-3.135-2.687-4.531-6.201-3.118-7.85 1.412-1.649 5.1-.808 8.235 1.878s4.532 6.2 3.119 7.85z' fill='%23749BFF'/%3E%3C/g%3E%3Cg filter='url(%23antigravity10)'%3E%3Cpath d='M18.163 9.077c5.81 3.93 12.502 4.19 14.946.577 2.443-3.612-.287-9.727-6.098-13.658-5.81-3.931-12.502-4.19-14.946-.577-2.443 3.612.287 9.727 6.098 13.658z' fill='%23FC413D'/%3E%3C/g%3E%3Cg filter='url(%23antigravity11)'%3E%3Cpath d='M-.915 2.684c-1.44 3.473-.97 6.967 1.05 7.804 2.02.837 4.824-1.3 6.264-4.772 1.44-3.473.97-6.967-1.05-7.804-2.02-.837-4.824 1.3-6.264 4.772z' fill='%23FFEE48'/%3E%3C/g%3E%3C/g%3E%3Cdefs%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='17.587' id='antigravity1' width='19.838' x='-3.288' y='-11.917'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='1.117'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='38.565' id='antigravity2' width='38.9' x='4.251' y='-13.493'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='5.4'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='36.517' id='antigravity3' width='40.955' x='-21.889' y='-10.592'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='4.591'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='36.517' id='antigravity4' width='40.955' x='-21.889' y='-10.592'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='4.591'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='36.595' id='antigravity5' width='36.632' x='-19.099' y='-10.278'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='4.591'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='34.087' id='antigravity6' width='33.533' x='.981' y='8.758'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='4.363'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='35.276' id='antigravity7' width='35.978' x='-6.143' y='-21.659'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='3.954'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='46.523' id='antigravity8' width='45.114' x='-11.96' y='-.46'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='3.531'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='24.054' id='antigravity9' width='25.094' x='10.485' y='.58'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='3.159'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='30.007' id='antigravity10' width='33.508' x='5.833' y='-12.467'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='2.669'/%3E%3C/filter%3E%3Cfilter color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse' height='26.151' id='antigravity11' width='22.194' x='-8.355' y='-8.876'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'/%3E%3CfeGaussianBlur result='effect1_foregroundBlur_977_115' stdDeviation='3.303'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E"); } +.run-mode-dot.deepseek { --run-mode-logo: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z' fill='%234D6BFE'/%3E%3C/svg%3E"); } +.run-mode-dot.omp { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='8 11 48 48'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23ed4abf'/%3E%3Cstop offset='.5' stop-color='%239b4dff'/%3E%3Cstop offset='1' stop-color='%235ad8e6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath fill='url(%23g)' d='M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z'/%3E%3C/svg%3E"); } +.run-mode-dot.codex { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z'/%3E%3C/svg%3E"); } +.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); } +.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); } +.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); } +.run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); } +.run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); } /* Phone-only Enter button (see index.html). Hidden by default at every width; mobile.css turns it on inside @media (max-width: 599px), where it takes over @@ -15692,10 +15737,6 @@ html:not([data-skin="og"]) { color: var(--accent-ink); } .btn-toolbar.btn-run-gear:hover { background: var(--gear-hover); box-shadow: none; } -.run-mode-dot.claude { background: var(--accent); } -.run-mode-dot.opencode { background: var(--accent-soft); } -.run-mode-dot.codex { background: var(--accent-grad-b); } -.run-mode-dot.shell { background: var(--text-dim); } /* ---- Shell button: quiet neutral with a calm green tint ---- */ .btn-toolbar.btn-shell { @@ -15992,7 +16033,6 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .session-tab--web.active .tab-web-icon { color: var(--accent, #3ec8ee); } .session-tab--web.active { border-bottom-color: var(--accent, #3ec8ee); } -.run-mode-dot.web { background: #38bdf8; } .run-mode-webviews { max-height: 180px; overflow-y: auto; } /* Custom Model Endpoint Profiles' generated entries: `.run-mode-menu.active`'s own `gap: 2px` only spaces its DIRECT children, and this container (like diff --git a/test/run-menu-cli-logos.test.ts b/test/run-menu-cli-logos.test.ts new file mode 100644 index 00000000..1013f4b7 --- /dev/null +++ b/test/run-menu-cli-logos.test.ts @@ -0,0 +1,85 @@ +/** + * @fileoverview Static guard for the Run menus' per-CLI logos (styles.css). + * + * Every launch surface (toolbar Run menu, phone overview picker, Custom Endpoint + * rows, model picker) renders ``, and styles.css + * turns that slot into the CLI's logo through a `--run-mode-logo` data URI plus + * one of two paint groups: brand-coloured marks paint as a background image, + * monochrome marks as a mask over the row's text colour. Three ways that breaks + * silently, each pinned here: + * - a new stock CLI lands with no logo rule (its row shows a bare grey dot); + * - a logo rule exists but the id is in neither paint group (the variable is + * set and never painted, so the slot is a blank 15px gap); + * - a later `background:` shorthand on `.run-mode-dot.` (e.g. a skin + * override like the ones the non-og block used to carry) resets + * background-image and wipes the logo. + * + * Port: none (pure static analysis). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +// Comments stripped: they sit between rules and carry commas, which would bleed +// into the selector lists split below. +const styles = readFileSync(resolve('src/web/public/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** Every id a Run menu renders as `.run-mode-dot `: the stock agents, Shell, and web tabs. */ +const RUN_MENU_IDS = [ + ...STOCK_CLIS.filter((cli) => cli.kind === 'agent').map((cli) => cli.id as string), + 'shell', + 'web', +]; + +/** The selector list of the one rule whose body contains `marker`. */ +function groupIds(marker: string): string[] { + const rule = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[2].includes(marker)); + expect(rule, `exactly one rule paints via "${marker}"`).toHaveLength(1); + return rule[0][1] + .split(',') + .map((s) => s.trim().match(/^\.run-mode-dot\.([a-z0-9-]+)$/)?.[1]) + .filter((id): id is string => Boolean(id)); +} + +function logoSvg(id: string): string { + const m = styles.match( + new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\("data:image/svg\\+xml,([^"]+)"\\); \\}`) + ); + expect(m, `.run-mode-dot.${id} has a --run-mode-logo rule`).not.toBeNull(); + return decodeURIComponent(m![1]); +} + +describe('Run menu CLI logos', () => { + const colourIds = groupIds('background: var(--run-mode-logo)'); + const maskIds = groupIds('mask: var(--run-mode-logo)'); + + it.each(RUN_MENU_IDS)('%s has a logo that one paint group draws', (id) => { + const svg = logoSvg(id); + expect(svg.startsWith(' { + expect([...colourIds, ...maskIds].sort()).toEqual([...RUN_MENU_IDS].sort()); + }); + + it('carries no script or event handler inside a data URI', () => { + for (const id of RUN_MENU_IDS) { + const svg = logoSvg(id); + expect(svg).not.toMatch(/ { + const offenders = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter( + ([, selector, body]) => + /\.run-mode-dot\.[a-z]/.test(selector) && + /(^|[;\s])background\s*:/.test(body) && + !body.includes('var(--run-mode-logo)') + ); + expect(offenders.map((m) => m[1].trim())).toEqual([]); + }); +}); From 839285461966e19830d6de43b14e2bd7da02c59c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 12:22:45 +0200 Subject: [PATCH 65/90] feat(sessions): publish the model each session runs (displayModel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session header can only name the model a session runs if the server knows it, so SessionState gains `displayModel: { model, source }`, resolved in a pure module (src/session-display-model.ts), strongest first: - custom-endpoint: a Custom Model Endpoint Profile's modelId answers the session, whatever alias the CLI prints; - statusline / screen: the newest report from the running CLI itself. Claude's statusLine exporter already posts model.display_name on every render; the status-telemetry route now records it (only for a CLI with capabilities.statusLineTelemetry). A CLI whose registry entry declares the new capabilities.modelDetect has its footer read off the pane capture the idle/working probe already takes (no extra tmux call), so an in-session /model switch is followed at the next transition; - launch: the model the session was launched with (claude's --model or the app-wide default, another CLI's Config.model), read where the registry says the model param lives; - nothing known: no field, never a placeholder. modelDetect is registry data, measured on live panes: dsh-TUI's status line on the row under its composer (qwen3.8-27b on the owner's route) and codex's ` ·` footer on its last row. Both anchor on chrome only that CLI draws, over the last rows of the screen only; a transcript line shaped like the footer is never taken (fixture tests). The pattern goes through compileVersionRegex() with exactly one capture group, checked at load time. An unreadable or covered footer keeps the last model (unlike the watching label: a model does not stop running when something covers its row). Model text is untrusted: escape sequences and control characters are stripped and it is capped at 64 characters. A change emits displayModelChanged, broadcast (session:updated) and persisted; a restart restores a CLI-reported model until the next report. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config/cli-registry/patterns.ts | 13 + src/config/cli-registry/schema.ts | 21 +- src/config/cli-registry/stock.ts | 22 ++ src/config/cli-registry/types.ts | 17 + src/session-display-model.ts | 147 +++++++++ src/session.ts | 102 +++++- src/types/session.ts | 25 ++ src/web/routes/status-telemetry-routes.ts | 13 + src/web/server.ts | 4 + src/web/session-listener-wiring.ts | 13 + test/mocks/mock-session.ts | 8 + test/routes/status-telemetry-routes.test.ts | 59 +++- test/session-display-model.test.ts | 328 ++++++++++++++++++++ test/session-listener-wiring.test.ts | 27 +- 14 files changed, 794 insertions(+), 5 deletions(-) create mode 100644 src/session-display-model.ts create mode 100644 test/session-display-model.test.ts diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts index 20787a57..3590214c 100644 --- a/src/config/cli-registry/patterns.ts +++ b/src/config/cli-registry/patterns.ts @@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null { return null; } } + +/** + * How many capture groups a regex source declares (named ones included), or -1 when it + * does not compile. Matching the empty string against `source|` always succeeds through + * the empty alternative, and the match array then has one slot per group. + */ +export function countCaptureGroups(source: string): number { + try { + return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1; + } catch { + return -1; + } +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5391c5ac..c58247f3 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -13,7 +13,7 @@ */ import { z } from 'zod'; -import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js'; +import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; import type { McpConfigFormat } from './types.js'; @@ -370,6 +370,25 @@ const capabilitiesSchema = z model: z .object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() }) .strict(), + // Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs + // over the foot of a pane capture every time a session settles. Exactly one capture + // group (the model), checked here so a pattern without one fails at LOAD time instead + // of silently never naming a model. + modelDetect: z + .object({ + screenLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1, + 'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group' + ), + // Bounded hard, like watchingLines: every row it adds is one more row the agent + // itself may be able to write. + screenLines: z.number().int().min(1).max(4).optional(), + }) + .strict() + .optional(), privilegedParams: z .array( z diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 45c40c7a..30915aae 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -622,6 +622,16 @@ const CODEX: CliEntry = { watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`, watchingLines: 3, }, + // The footer under the composer, measured on a live 0.147.0 pane: + // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, + // cwd). It is the pane's LAST row, below the composer, so the transcript never + // reaches it, and the effort word right after the model is codex's own format: an + // open slash-command popup or a bare line of prose does not have that shape. A + // footer without an effort word (a model with no reasoning setting) is not read, + // and the session keeps its last known or launch model. + modelDetect: { + screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `, + }, // Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠` // markers sit in the gutter, prose continuations sit at 2, and a nested YAML block // the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, @@ -1222,6 +1232,18 @@ const DEEPSEEK: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Model is NOT a session field for dsh — it is a profile composition entry. model: { source: 'none' }, + // So the screen is where the model is known: dsh-TUI resolves the route itself + // (profile cordis.yml pin, else the persisted `/model` choice, else its default; + // lib/types/modelRoute.js) and its status line draws "the route requests actually + // take", model first (StatusLine.js; `statusBar.model` is on by default and forced + // on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box + // and, on the row right under its bottom border, ` qwen3.8-27b · medium · `. + // The border anchors it: nothing the agent writes can sit below the composer, and a + // suggestion popup there starts with `/` or `+`, never a model id. + modelDetect: { + screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, + screenLines: 3, + }, // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the // launcher's own default, `workspace-write`, which already asks. Clamping to // `read-only` instead would break the workspace rather than protect it. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 147188c5..4de6936c 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -462,6 +462,23 @@ export interface CliCapabilities { statusLineTelemetry: boolean; /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; + /** + * Where this CLI draws the model it is running, so a session header can name it + * (`SessionState.displayModel`, src/session-display-model.ts). + * + * `screenLine` is the source of a regex with exactly ONE capture group, the model. It + * runs over the last `screenLines` non-blank rows of the pane capture the idle/working + * probe already takes (rows joined with `\n`, so a pattern may span them), which costs no + * extra tmux call and re-reads the footer at every turn transition, so an in-session + * `/model` switch is followed. + * + * ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor + * on chrome only this CLI draws (the row under its own composer, an effort word in its + * own footer format), never on a shape the agent could print in its transcript. Measured + * on a live pane per CLI; absent means the CLI's screen is never read for a model and + * the session shows its launch model, if any. + */ + modelDetect?: { screenLine: string; screenLines?: number }; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/session-display-model.ts b/src/session-display-model.ts new file mode 100644 index 00000000..9eae26cb --- /dev/null +++ b/src/session-display-model.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Which model a session is running, as far as the server can know it + * (`SessionState.displayModel`, shown in the tile grid's and split pane's headers). + * + * Pure: the session feeds it what it has and publishes the answer through `toState()`. + * + * ## Sources, strongest first + * + * 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered + * by that endpoint's `modelId`, whatever alias the CLI itself prints. + * 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's + * statusLine exporter posts `model.display_name` on every render (it follows an + * in-session `/model`); a CLI whose registry entry declares + * `capabilities.modelDetect` has its footer read off the pane capture the idle/working + * probe already takes. + * 3. **launch**: the model the session was launched with (claude's `--model` or the + * app-wide default it was created with; another CLI's `Config.model`). What was + * asked for, not what was reported, so it only shows when nothing reported. + * + * Nothing known means no field at all: the header shows the harness logo alone, never a + * placeholder or a guess. + * + * ## Untrusted text + * + * A screen-read model is pane text, and a statusline payload is a POST body: both are + * stripped of escape sequences and control characters, whitespace-collapsed and capped + * here, and the browser renders the result with `textContent`. + * + * Tests: `test/session-display-model.test.ts`. + * + * @module session-display-model + */ + +import type { DisplayModel, DisplayModelSource } from './types/session.js'; +import { stripAnsi } from './utils/index.js'; +import { getCli } from './config/cli-registry/index.js'; +import { legacyConfigForMode } from './session-cli-registry-bridge.js'; + +/** Longest model name published (the header truncates long before this). */ +export const MAX_DISPLAY_MODEL_CHARS = 64; + +/** A report from the running CLI itself: the sources a restart may restore. */ +export type ReportedModelSource = Extract; + +export interface ReportedModel { + model: string; + source: ReportedModelSource; +} + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g; + +/** + * A model name fit to publish, or undefined when nothing printable is left. + * + * @param raw anything; only a string can yield a name + */ +export function sanitizeModelName(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined; + const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim(); + if (!clean) return undefined; + return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd(); +} + +/** + * The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern. + * + * Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern + * can anchor on the row above), which keeps the search below the transcript: the + * pattern itself must still anchor on chrome only that CLI draws. + * + * @param paneText a plain `capture-pane -p` frame, or null when it could not be read + * @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model + * @param tailRows how many non-blank rows from the bottom the pattern sees + * @returns the model, or undefined when the frame shows none + */ +export function readScreenModel( + paneText: string | null | undefined, + pattern: RegExp, + tailRows: number = 1 +): string | undefined { + if (!paneText) return undefined; + const rows = stripAnsi(paneText) + .split('\n') + .map((row) => row.trimEnd()) + .filter((row) => row !== ''); + const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n'); + // compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a + // stale lastIndex would make the same frame match every other call. + pattern.lastIndex = 0; + const match = pattern.exec(window); + return match ? sanitizeModelName(match[1]) : undefined; +} + +/** + * The model a session was launched with, read the way its spawn reads it: where the + * model param lives is registry data (`capabilities.model` names the param, the entry's + * `legacyConfigField` the `Config` object holding it, or the option bag itself for + * claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell, + * dsh) has none. + * + * @param mode the session's CLI id + * @param bag the session's launch option bag (`model`, `codexConfig`, ...) + */ +export function launchModelFor(mode: string, bag: Record): string | undefined { + const entry = getCli(mode); + const model = entry?.capabilities.model; + if (!entry || !model || model.source === 'none') return undefined; + const param = model.param ?? 'model'; + const key = entry.launch.legacyConfigAliases?.[param] ?? param; + const value = legacyConfigForMode(mode, bag)?.[key]; + return typeof value === 'string' ? value : undefined; +} + +/** + * The persisted `displayModel` of a previous run, when it was a report from the CLI + * itself: a restart shows it until the next report replaces it. A custom-endpoint or + * launch answer is not restored, since the session derives those again by itself. + */ +export function restoredReportedModel(saved: unknown): ReportedModel | undefined { + if (!saved || typeof saved !== 'object') return undefined; + const { model, source } = saved as { model?: unknown; source?: unknown }; + if (source !== 'statusline' && source !== 'screen') return undefined; + const name = sanitizeModelName(model); + return name ? { model: name, source } : undefined; +} + +/** + * The model a session header shows, and where it came from. + * + * @param input.customModelId the custom endpoint's model, when the session is pointed at one + * @param input.reported the newest report from the CLI itself + * @param input.launchModel the model the session was launched with + */ +export function resolveDisplayModel(input: { + customModelId?: string; + reported?: ReportedModel | null; + launchModel?: string; +}): DisplayModel | undefined { + const custom = sanitizeModelName(input.customModelId); + if (custom) return { model: custom, source: 'custom-endpoint' }; + const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined; + if (reported && input.reported) return { model: reported, source: input.reported.source }; + const launch = sanitizeModelName(input.launchModel); + if (launch) return { model: launch, source: 'launch' }; + return undefined; +} diff --git a/src/session.ts b/src/session.ts index 7e92df81..15811f1d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -137,7 +137,16 @@ import { sanitizeAttachmentHistory, upsertAttachmentHistory as upsertAttachmentHistoryList, } from './session-attachment-history.js'; -import type { SessionAttachmentHistoryItem } from './types/session.js'; +import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; +import { + launchModelFor, + readScreenModel, + resolveDisplayModel, + restoredReportedModel, + sanitizeModelName, + type ReportedModel, + type ReportedModelSource, +} from './session-display-model.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; @@ -550,6 +559,17 @@ export class Session extends EventEmitter { private _watchingWindow = WATCHING_TAIL_LINES; /** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */ private _awaitingLineRe: RegExp | null | undefined = undefined; + /** + * The newest model the running CLI reported for itself (its statusline, or its own + * footer read off the probe's capture), or null when none has. Feeds `displayModel` + * (src/session-display-model.ts). Persisted through `toState()` and restored after a + * restart, so an idle session keeps naming its model until the next report. + */ + private _reportedModel: ReportedModel | null = null; + /** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */ + private _modelLineRe: RegExp | null | undefined = undefined; + /** Resolved with the pattern above: how many rows at the foot of the screen it sees. */ + private _modelLineRows = 1; private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read @@ -807,6 +827,8 @@ export class Session extends EventEmitter { claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ paneExit?: PaneExit; + /** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */ + displayModel?: DisplayModel; /** This session was rebuilt from the tmux socket, so its metadata is a guess. */ discoveredMuxSession?: boolean; /** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */ @@ -974,6 +996,7 @@ export class Session extends EventEmitter { // replaces it with a first-hand reading. NOT the stats collector, which a // browser panel arms and disarms — see `startPaneExitWatcher`. this.setPaneExit(config.paneExit); + this._reportedModel = restoredReportedModel(config.displayModel) ?? null; // Never self-parent: a session pointing at itself would draw a zero-length // lineage arc under its own tab. Only reachable via the recovery path, where // both the id and the saved parent come from disk. @@ -1858,6 +1881,7 @@ export class Session extends EventEmitter { model: cliTakesSessionModel(this.mode) ? this._model : undefined, advisorModel: this._advisorModel, customModel: this.customModel, + displayModel: this.displayModel, // COD-118: runtime-only — surfaced so the frontend can require explicit user // intent before restarting a crash-looped session. Deliberately NOT restored // by the constructor: a Codeman restart starts with a fresh breaker so boot @@ -3134,9 +3158,85 @@ export class Session extends EventEmitter { this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text); this._readWatching(text); + this._readScreenModel(text); return this._lastPaneProbeWorking; } + /** + * Read the model the CLI's own footer names off the same capture, for a CLI whose + * registry entry declares `capabilities.modelDetect`. + * + * Unlike `_readWatching`, a capture that could not be read, or a footer the pattern + * does not find (a popup covering it, a footer turned off), KEEPS the last model. The + * two are not symmetric: background work ends and its badge must go, while a model does + * not stop running because something was drawn over the row that names it. + */ + private _readScreenModel(paneText: string | null): void { + if (paneText === null) return; + const pattern = this._modelLinePattern(); + if (!pattern) return; + const model = readScreenModel(paneText, pattern, this._modelLineRows); + if (model) this.noteReportedModel('screen', model); + } + + /** + * The regex reading this CLI's model off its footer, or null for a CLI that declares + * none. Compiled once per session through `compileVersionRegex()` (null, never a throw, + * for a pattern it refuses), like the working- and watching-line patterns. + */ + private _modelLinePattern(): RegExp | null { + if (this._modelLineRe === undefined) { + const detect = getCli(this.mode)?.capabilities.modelDetect; + this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null; + this._modelLineRows = detect?.screenLines ?? 1; + } + return this._modelLineRe; + } + + /** + * Record a model the running CLI reported for itself: its statusline (claude's + * exporter, via `POST /api/status-telemetry`) or its own footer. The newest report + * wins whatever its source. An empty or unprintable report changes nothing. + * + * @returns true when the reported model changed (and `displayModelChanged` was emitted) + */ + noteReportedModel(source: ReportedModelSource, raw: unknown): boolean { + const model = sanitizeModelName(raw); + if (!model) return false; + if (this._reportedModel?.model === model && this._reportedModel.source === source) return false; + this._reportedModel = { model, source }; + // The status does not change with it, so it needs a broadcast (and a persist) of its own. + this.emit('displayModelChanged'); + return true; + } + + /** + * The model this session runs as far as the server knows, and where that came from: + * the custom endpoint's model, else the newest report from the CLI, else the launch + * model (src/session-display-model.ts). Undefined when none is known. + */ + get displayModel(): DisplayModel | undefined { + return resolveDisplayModel({ + customModelId: this._customModel?.modelId, + reported: this._reportedModel, + // The same option bag the spawn reads its launch params from: `model` at the top + // for claude (the `--model` or app-wide default it was created with; inert for + // every other CLI, which is why it is not handed over for them), each other CLI's + // own `Config`. Where the model param lives is registry data. + launchModel: launchModelFor(this.mode, { + model: cliTakesSessionModel(this.mode) ? this._model : undefined, + openCodeConfig: this._openCodeConfig, + codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, + piConfig: this._piConfig, + grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, + }), + }); + } + /** * Read the background-work chip off the same capture the working probe just took. * diff --git a/src/types/session.ts b/src/types/session.ts index 08dbf4be..2e7d4415 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -641,6 +641,22 @@ export interface CustomModelSelection { label?: string; } +/** + * Where a session's {@link DisplayModel} came from (src/session-display-model.ts): + * - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins. + * - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch. + * - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch. + * - `launch`: what the session was launched with; nothing has reported since. + */ +export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch'; + +/** The model a session runs as far as the server knows, for a session header. */ +export interface DisplayModel { + /** Display text: sanitized (no control characters) and at most 64 characters. */ + model: string; + source: DisplayModelSource; +} + /** * The full custom-model selection a session keeps: the public selection plus the * bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what @@ -847,6 +863,15 @@ export interface SessionState { * written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`. */ customModel?: CustomModelSelection; + /** + * The model this session runs, as far as the server knows it, and where that came from + * (src/session-display-model.ts): the custom endpoint's model, else the newest report + * from the CLI itself (statusline or its own footer), else the launch model. Absent when + * none is known; a session header then shows the harness alone. Untrusted display text + * (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen` + * value is restored after a restart until the next report replaces it. + */ + displayModel?: DisplayModel; /** Sanitized per-session attachment history. */ attachmentHistory?: SessionAttachmentHistoryItem[]; /** diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 86aa8437..b7074c6c 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -7,6 +7,9 @@ * the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event` * (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth). * + * Also records the session's model (`model.display_name`) as its reported model, which + * `session:updated` publishes as `displayModel` for the session headers. + * * Returns a compact plain-text status string for the exporter to print as the * in-terminal footer (print-through) when it has no statusline of the user's * own to wrap. An unknown session gets an EMPTY body: the old brand-word @@ -26,6 +29,7 @@ import { import { SessionStatusTelemetry } from '../sse-events.js'; import { setLatestPlanUsage } from '../plan-usage-latest.js'; import type { SessionPort, EventPort } from '../ports/index.js'; +import { getCli } from '../../config/cli-registry/index.js'; export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { // Last broadcast telemetry signature per session — the statusline fires on @@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session const payload = data as RawStatuslinePayload | undefined; + // The model the CLI says it is running, for the session headers (displayModel). It + // rides every render, so it follows an in-session `/model`; the session dedupes and + // broadcasts only a change. Only a CLI that has a statusline exporter at all may + // report one here. + const session = ctx.sessions.get(sessionId); + if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) { + session.noteReportedModel('statusline', payload?.model?.display_name); + } + // Plan-usage limits (account-wide) → broadcast to the header chip, when // present and changed (the statusline fires on every assistant message). const telemetry = parseStatusTelemetry(payload); diff --git a/src/web/server.ts b/src/web/server.ts index 4442e544..8d7fe698 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter { // pane-exit watcher's own tick replaces it with a first-hand // reading (not the stats collector — see `startPaneExitWatcher`). paneExit: savedState?.paneExit, + // The model the CLI last reported (statusline or its own footer), so a + // tile header keeps naming it until the next report; an idle claude + // session re-renders its statusline only on its next turn. + displayModel: savedState?.displayModel, // A record rebuilt from the socket has no provenance, so its // apparent locality is a guess (see `MuxSession.discovered`). discoveredMuxSession: muxSession.discovered, diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 55bb0152..6f773698 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -44,6 +44,7 @@ export interface SessionListenerRefs { working: () => void; idle: () => void; watchingChanged: () => void; + displayModelChanged: () => void; taskCreated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void; taskCompleted: (task: BackgroundTask) => void; @@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe deps.broadcastSessionStateDebounced(session.id); }, + /** + * Pushes the session state when the model the CLI reports changes (its statusline, + * or its own footer), which the status does not reflect. Persisted too: a restart + * restores the last reported model until the next report. + */ + displayModelChanged: () => { + deps.broadcastSessionStateDebounced(session.id); + deps.persistSessionState(session); + }, + // ─── Background Task Events ────────────────────────────── /** Broadcasts `task:created` — new background task discovered */ @@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe session.on('working', refs.working); session.on('idle', refs.idle); session.on('watchingChanged', refs.watchingChanged); + session.on('displayModelChanged', refs.displayModelChanged); session.on('taskCreated', refs.taskCreated); session.on('taskUpdated', refs.taskUpdated); session.on('taskCompleted', refs.taskCompleted); @@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe session.off('working', refs.working); session.off('idle', refs.idle); session.off('watchingChanged', refs.watchingChanged); + session.off('displayModelChanged', refs.displayModelChanged); session.off('taskCreated', refs.taskCreated); session.off('taskUpdated', refs.taskUpdated); session.off('taskCompleted', refs.taskCompleted); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 5f04d97e..17c838ae 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -383,6 +383,14 @@ export class MockSession extends EventEmitter { ); restartCli = vi.fn(async () => true); getCustomModelForPersist = vi.fn(() => this._mockCustomModel); + /** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */ + reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null; + noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => { + if (typeof raw !== 'string' || !raw.trim()) return false; + if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false; + this.reportedModel = { model: raw, source }; + return true; + }); /** Stub for sendInput */ sendInput = vi.fn(); diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index a126f431..f85b8c69 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -6,12 +6,14 @@ * skip, per-session change-detection (dedup), rebroadcast on a displayed change, * NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented * fields (the .nullish() schema — the project's recurring .optional()/null trap), - * and 400 on a malformed body. + * and 400 on a malformed body. Also the session's model (`displayModel`) the route + * records off the same payload. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js'; import { SessionStatusTelemetry } from '../../src/web/sse-events.js'; +import { Session } from '../../src/session.js'; const SID = 'test-session-1'; // default id created by createMockRouteContext @@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => { expect(res.statusCode).toBe(400); }); }); + +describe('POST /api/status-telemetry: the session model (displayModel)', () => { + let h: RouteTestHarness; + + beforeEach(async () => { + h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID }); + }); + + const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body }); + + /** A real claude session in the route's map, so displayModel is the shipped resolver's. */ + function realSession(mode: 'claude' | 'codex', id: string): Session { + const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters[0]); + (h.ctx.sessions as unknown as Map).set(id, session); + return session; + } + + it('records the model the statusline names, from the first render (no rate limits yet)', async () => { + const session = realSession('claude', 'claude-1'); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } }); + expect(res.statusCode).toBe(200); + expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' }); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('follows a model switch, and says nothing for an unchanged one', async () => { + const session = realSession('claude', 'claude-2'); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + await post({ sessionId: 'claude-2', data: REAL }); + await post({ sessionId: 'claude-2', data: REAL }); + expect(changed).toHaveBeenCalledTimes(1); + await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } }); + expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' }); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('keeps a model name with markup as text, and a null name changes nothing', async () => { + const session = realSession('claude', 'claude-3'); + await post({ sessionId: 'claude-3', data: { model: { display_name: 'Opus' } } }); + expect(session.toState().displayModel?.model).toBe('Opus'); + await post({ sessionId: 'claude-3', data: { model: { display_name: null } } }); + expect(session.toState().displayModel?.model).toBe('Opus'); + }); + + it('takes no model from a CLI that has no statusline exporter', async () => { + const session = realSession('codex', 'codex-1'); + await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } }); + expect(session.toState().displayModel).toBeUndefined(); + }); +}); diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts new file mode 100644 index 00000000..160e5ec8 --- /dev/null +++ b/test/session-display-model.test.ts @@ -0,0 +1,328 @@ +/** + * Which model a session runs (`SessionState.displayModel`), for the tile grid's and the + * split pane's headers: the pure resolver, the screen read with each CLI's registry + * pattern, and the session that feeds them. + * + * The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from + * live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex + * 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Session } from '../src/session.js'; +import { getCli } from '../src/config/cli-registry/index.js'; +import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { + MAX_DISPLAY_MODEL_CHARS, + launchModelFor, + readScreenModel, + resolveDisplayModel, + restoredReportedModel, + sanitizeModelName, +} from '../src/session-display-model.js'; +import { IDLE_SILENCE_MS } from '../src/session-activity.js'; + +const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!; +const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!; +const DSH_ROWS = detectOf('deepseek').screenLines; +const CODEX = compileVersionRegex(detectOf('codex').screenLine)!; +const CODEX_ROWS = detectOf('codex').screenLines; + +const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮'; +const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯'; +const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶'; +/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */ +function dshPane(statusLine: string | null, transcript: string[] = []): string { + return [ + ' Tip: Footer compact on = one merged line; off = metric…', + ' Explore the uncharted!', + ' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …', + ...transcript, + DSH_BORDER_TOP, + DSH_COMPOSER, + DSH_BORDER_BOTTOM, + ...(statusLine === null ? [] : [statusLine]), + '', + ].join('\n'); +} +const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch'); + +/** The foot of a codex pane: transcript, composer, then the status line on the last row. */ +function codexPane(statusLine: string | null, transcript: string[] = []): string { + return [ + '│ directory: ~/codeman-cases/th-scratch │', + '│ permissions: YOLO mode │', + '╰───────────────────────────────────────────────╯', + ' Tip: New For a limited time, Codex is included in your', + ' plan for free – let’s build together.', + ...transcript, + '› Explain this codebase', + ...(statusLine === null ? [] : [statusLine]), + '', + ].join('\n'); +} +const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch'); +const CODEX_154 = codexPane( + ' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left' +); + +describe('the registry patterns', () => { + it('compile through compileVersionRegex() with exactly one capture group', () => { + for (const mode of ['deepseek', 'codex']) { + const { screenLine } = detectOf(mode); + expect(compileVersionRegex(screenLine), mode).not.toBeNull(); + expect(countCaptureGroups(screenLine), mode).toBe(1); + } + }); + + it('are declared only where a footer was measured (claude reports through its statusline)', () => { + expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined(); + expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined(); + }); + + it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => { + const codex = getCli('codex')!; + const withDetect = (modelDetect: unknown) => + CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success; + expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true); + expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false); + expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false); + expect(withDetect({ screenLine: '(a+)+$' })).toBe(false); + expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false); + }); + + it('countCaptureGroups counts named groups and ignores non-capturing ones', () => { + expect(countCaptureGroups('a(?:b)(?c)')).toBe(1); + expect(countCaptureGroups('(a)(b)')).toBe(2); + expect(countCaptureGroups('abc')).toBe(0); + expect(countCaptureGroups('(')).toBe(-1); + }); +}); + +describe('readScreenModel', () => { + it("reads dsh's model off the row under its composer", () => { + expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b'); + // A footer with the model alone, and one with a row below it (the activity line). + expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash'); + expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe( + 'qwen3.8-27b' + ); + }); + + it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => { + expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra'); + expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol'); + }); + + it('never takes a transcript line shaped like the footer', () => { + // The agent printed a line exactly like each CLI's footer, and the real footer is + // hidden (a dsh status bar switched off; a codex popup over its last row). The + // transcript sits above the composer, so neither may be read as the model. + const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']); + expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined(); + const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']); + // A codex screen whose last row is the composer: nothing to read. + expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined(); + // With the real footer back, the real model wins over the forged line above it. + const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [ + DSH_BORDER_BOTTOM, + ' evil-model · medium · th-scratch', + ]); + expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b'); + const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [ + ' evil-model high · ~/codeman-cases/th-scratch', + ]); + expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra'); + }); + + it('does not read a popup under the composer as a model', () => { + const slash = dshPane(' /model Switch the model route'); + expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined(); + const codexSlash = codexPane(' /model choose what model and reasoning effort to use'); + expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined(); + // A last row without codex's ` ·` shape names no model, even one + // whose first word could pass for a model id. + const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch'); + expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined(); + }); + + it('says nothing about an empty or unreadable frame', () => { + expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined(); + expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined(); + }); +}); + +describe('sanitizeModelName', () => { + it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => { + expect(sanitizeModelName('')).toBe(''); + }); + + it('drops escape sequences and control characters, and collapses whitespace', () => { + expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8‮\n')).toBe('Opus 4.8'); + expect(sanitizeModelName('\x00\x07')).toBeUndefined(); + expect(sanitizeModelName(' ')).toBeUndefined(); + expect(sanitizeModelName(42)).toBeUndefined(); + }); + + it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => { + expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS); + }); +}); + +describe('resolveDisplayModel', () => { + const reported = { model: 'Sonnet 4.6', source: 'statusline' as const }; + + it('the custom endpoint wins, then the newest report, then the launch model', () => { + expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({ + model: 'qwen3.8-27b', + source: 'custom-endpoint', + }); + expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported); + expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' }); + }); + + it('knows nothing when nothing is known: no placeholder', () => { + expect(resolveDisplayModel({})).toBeUndefined(); + expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined(); + }); +}); + +describe('restoredReportedModel', () => { + it('restores what the CLI reported, never a derived answer', () => { + expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({ + model: 'qwen3.8-27b', + source: 'screen', + }); + expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline'); + expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined(); + expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined(); + expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined(); + expect(restoredReportedModel('screen')).toBeUndefined(); + expect(restoredReportedModel(undefined)).toBeUndefined(); + }); +}); + +describe('launchModelFor', () => { + it("reads the model param where each CLI's registry entry keeps it", () => { + expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku'); + expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5'); + expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast'); + // Another CLI's config, or a top-level model, is not this CLI's launch model. + expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined(); + }); + + it('has none for a CLI whose model is not a launch param', () => { + expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined(); + expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined(); + expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined(); + }); +}); + +describe('a session', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + type Internals = { + _handleTerminalOutput(data: string): void; + _detectInteractiveActivity(data: string): void; + _customModel?: { endpointId: string; modelId: string; envKeys: string[] }; + }; + + function withFakePane(mode: string, screen: () => string | null, extra: Record = {}): Session { + const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable< + ConstructorParameters[0] + >['mux']; + return new Session({ + workingDir: '/tmp', + mode, + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() }, + ...extra, + } as ConstructorParameters[0]); + } + + /** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */ + function settle(session: Session, glyph: string): void { + const internals = session as unknown as Internals; + for (let i = 0; i < 3; i++) { + const frame = `\x1b[31;1H${glyph}\xa0`; + internals._handleTerminalOutput(frame); + internals._detectInteractiveActivity(frame); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000); + } + + it('publishes the model its footer names, and follows the footer when it changes', () => { + vi.useFakeTimers(); + let screen = DSH_LIVE; + const session = withFakePane('deepseek', () => screen); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + expect(session.toState().displayModel).toBeUndefined(); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' }); + expect(changed).toHaveBeenCalledTimes(1); + // The same footer again: nothing new to say. + settle(session, '❯'); + expect(changed).toHaveBeenCalledTimes(1); + // An in-session switch redraws the footer. + screen = dshPane(' deepseek-v4-flash · high · th-scratch'); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('keeps the last model when the footer cannot be read', () => { + vi.useFakeTimers(); + let screen: string | null = CODEX_LIVE; + const session = withFakePane('codex', () => screen); + settle(session, '›'); + expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra'); + screen = codexPane(' /model choose what model and reasoning effort to use'); + settle(session, '›'); + screen = null; + settle(session, '›'); + expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' }); + }); + + it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => { + vi.useFakeTimers(); + const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n'); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + }); + + it('shows the launch model until the CLI reports, and a custom endpoint over both', () => { + const session = withFakePane('claude', () => null, { model: 'haiku' }); + expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' }); + expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true); + expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' }); + expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false); + expect(session.noteReportedModel('statusline', '')).toBe(false); + (session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] }; + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' }); + (session as unknown as Internals)._customModel = undefined; + expect(session.toState().displayModel?.model).toBe('Haiku 4.5'); + }); + + it("an external CLI's launch model is its own config's, never the inert top-level one", () => { + const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } }); + expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' }); + const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } }); + expect(dsh.toState().displayModel).toBeUndefined(); + }); + + it('restores a reported model after a restart, until the next report replaces it', () => { + const session = withFakePane('deepseek', () => null, { + displayModel: { model: 'qwen3.8-27b', source: 'screen' }, + }); + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' }); + session.noteReportedModel('screen', 'deepseek-v4-flash'); + expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash'); + // A launch answer from the previous run is derived again, not restored. + const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } }); + expect(claude.toState().displayModel).toBeUndefined(); + }); +}); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index 3345c25d..cc54ff00 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; -import { createSessionListeners } from '../src/web/session-listener-wiring.js'; +import { + attachSessionListeners, + createSessionListeners, + detachSessionListeners, +} from '../src/web/session-listener-wiring.js'; import { SseEvent } from '../src/web/sse-events.js'; describe('session listener wiring', () => { @@ -37,6 +41,27 @@ describe('session listener wiring', () => { expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test'); }); + it('pushes and persists the session state when the reported model changes', () => { + // A model switch changes nothing the status reflects, so without its own broadcast + // every header would keep naming the old model; persisting it is what lets a restart + // restore it. + const session = new Session({ id: 'wiring-model-test', workingDir: '/tmp', mode: 'claude' }); + const broadcastSessionStateDebounced = vi.fn(); + const persistSessionState = vi.fn(); + const deps = { broadcastSessionStateDebounced, persistSessionState } as unknown as Parameters< + typeof createSessionListeners + >[1]; + + const refs = createSessionListeners(session, deps); + attachSessionListeners(session, refs); + session.noteReportedModel('statusline', 'Sonnet 4.6'); + + expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-model-test'); + expect(persistSessionState).toHaveBeenCalledWith(session); + detachSessionListeners(session, refs); + expect(session.listenerCount('displayModelChanged')).toBe(0); + }); + /** The listener reads the setting asynchronously; let its promise chain settle. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); From 969f273fecb6204d4feb79d8758de5423dda2a60 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 12:39:41 +0200 Subject: [PATCH 66/90] feat(tiles): each tile header names its harness and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request): - The logo is PR #532's `run-mode-dot ` slot, so the logos, the skins and the plain dot of an id without a logo stay single-sourced in styles.css. The id is data (a class and a catalog lookup), never a branch; the frontend id-branching guard now also scans constants.js, terminal-split.js and tile-grid.js (the one existing shell branch in tile-grid.js, the attach route, is allowlisted with its reason). - The model is the session's displayModel, as text in a data-i18n-skip span inside a box whose tooltip may translate. Unknown means the logo alone. - The logo's tooltip and accessible name say " · ", plus where a model the CLI did not report came from ("set at launch", "custom endpoint"; zh-CN patterns for both, the names pass through). The model's box is aria-hidden so a screen reader hears the model once. - One painter, _paintSessionHarness (terminal-split.js, shared with the split panes next), diffs against what it last wrote, never the DOM: an unchanged session writes nothing on a tab render. - On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box, since any shrink factor takes a subpixel from a name that fits and ellipsizes it. The chrome and zoom tests found header parts by child position; they now look them up by class, with every assertion kept (the rename tests had been passing against the new logo node by position). The i18n harvester files the logo's labels as harness and model names that must stay as they are. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 49 ++++++ src/web/public/i18n.js | 5 + src/web/public/styles.css | 41 ++++- src/web/public/terminal-split.js | 33 ++++ src/web/public/tile-grid.js | 40 ++++- test/frontend-cli-no-id-branching.test.ts | 16 +- test/mocks/tile-grid-vm.ts | 2 + test/tile-grid-chrome.test.ts | 187 ++++++++++++++++++++-- test/tile-grid-i18n.test.ts | 52 +++++- test/tile-grid-zoom.test.ts | 4 +- 10 files changed, 401 insertions(+), 28 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index f4ab30ad..cafb1a7a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2134,7 +2134,56 @@ function sessionIdFromFragment(hash) { return id && id.trim() ? id.trim() : null; } +/** Longest model name a session header shows (the server caps it as well). */ +const SESSION_MODEL_MAX_CHARS = 64; +/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */ +const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/; + +/** + * What a session's header says about its harness: the CLI id (the + * `run-mode-dot ` logo class), the registry's label for it, the model the + * session runs when the server knows it (`SessionState.displayModel`), and the + * tooltip naming both. + * + * The id is data: the label comes from the injected CLI catalog and falls back + * to the id, so a CLI added through clis.json still gets a name. The model is + * untrusted text (read off a pane, or a CLI's own report): control characters + * are dropped and the length capped here too, and callers render it with + * textContent. The tooltip says where a model that is not the CLI's own report + * came from, so it never claims more than the server knows: one the session + * was launched with may have been switched since, and a custom endpoint's + * model is the endpoint's, whatever the CLI calls it. + * + * @param {object} session - a session from app.sessions + * @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog + * @returns {{id: string, label: string, model: string, title: string}} + */ +function describeSessionHarness(session, catalog) { + const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : ''; + const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null; + const label = (typeof entry?.label === 'string' && entry.label.trim()) || id; + const raw = session?.displayModel?.model; + const model = + typeof raw === 'string' + ? raw + .replace(/[\u0000-\u001f\u007f-\u009f]/g, '') + .trim() + .slice(0, SESSION_MODEL_MAX_CHARS) + : ''; + const source = session?.displayModel?.source; + const qualifier = !model + ? '' + : source === 'launch' + ? ' (set at launch)' + : source === 'custom-endpoint' + ? ' (custom endpoint)' + : ''; + const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier; + return { id, label, model, title }; +} + if (typeof window !== 'undefined') { + window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS }; window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; window.CodemanTerminalLines = { terminalLogicalLine }; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index b08a557e..80e02d3e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1058,6 +1058,11 @@ ], [/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`], [/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`], + // A session header's harness logo (tile grid, split pane): " · ", + // and where the model came from when the CLI did not report it. The harness + // and model names pass through untranslated. + [/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`], + [/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`], // The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry // CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP"). [/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`], diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 87121d60..a0fe1843 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19477,7 +19477,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle background: transparent !important; } -/* Tile header: `● name ......... ⋯ ⤢ ×`. FIXED height (#464): the body below +/* Tile header: `● [logo] name · model ..... ⋯ ⤢ ×`. FIXED height (#464): the body below holds the xterm, so a header that grew (a wrapped name, the rename input) would shrink the body with nothing refitting the xterm or its PTY. */ .tile-header { @@ -19496,14 +19496,51 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle user-select: none; } -.tile-name { +/* The harness logo: PR #532's run-mode-dot slot (1.15em of this 13px, the + size of the header's text), with the logos, the skins and the plain dot of an + id without one all coming from the run-mode-dot rules. */ +.tile-harness.run-mode-dot { + font-size: 13px; +} + +/* Name and model. On a narrow header the model gives way first, then the name; + 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 + ellipsizes it) and is capped at this box's width instead. */ +.tile-title { + display: flex; + align-items: baseline; + gap: 6px; flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; +} + +.tile-name { + flex: 0 0 auto; + max-width: 100%; + min-width: 0; + overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; } +.tile-model { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--text-muted, #888); + font-size: 11px; +} + +.tile-model::before { + content: '\00B7'; + margin-right: 6px; +} + .tile-actions { display: flex; flex: 0 0 auto; diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index 88b450af..8d6d174b 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -261,6 +261,39 @@ Object.assign(CodemanApp.prototype, { // nothing on the button said which. `.split-open` + aria-pressed give it // the same active-state language as the codebase's other toggle buttons // (keyboard-accessory's Ctrl key, the voice-input mic). + /** + * 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 ` slot (the id is + * data, never a branch), the model is text (describeSessionHarness, + * constants.js). Diffs against the values it last wrote, kept on `parts`, + * never against the DOM, which the translator may have rewritten: an + * unchanged session writes nothing, and this runs on every tab render. + * + * @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the + * header's nodes (the model's box and the name inside it); the memo lives here too + * @param {object} session - the session the header shows + * @param {string} logoClass - the header's own class for its logo + */ + _paintSessionHarness(parts, session, logoClass) { + const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog); + const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`; + if (parts.harnessClass !== cls) { + parts.harnessClass = cls; + parts.harness.className = cls; + } + if (parts.harnessTitle !== harness.title) { + parts.harnessTitle = harness.title; + parts.harness.title = harness.title; + parts.harness.setAttribute('aria-label', harness.title); + parts.model.title = harness.title; + } + if (parts.modelValue !== harness.model) { + parts.modelValue = harness.model; + parts.modelName.textContent = harness.model; + parts.model.hidden = !harness.model; + } + }, + _updateSplitButtonState(open) { const btn = document.querySelector('.btn-split'); if (!btn) return; diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 2cb1673e..95641eb9 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -732,7 +732,10 @@ Object.assign(CodemanApp.prototype, { overlay: null, header: header.el, dot: header.dot, + harness: header.harness, name: header.name, + model: header.model, + modelName: header.modelName, zoomBtn: header.zoomBtn, renaming: false, // The pid this tile last saw, so a pane that starts later is noticed. @@ -1075,9 +1078,10 @@ Object.assign(CodemanApp.prototype, { }, /** - * `● name ......... ⋯ ⤢ ×`: the status dot (the six-state classifier the tab - * rows and both home screens share), the session name (double-click - * renames), the session menu (the tab rail's own), zoom and remove-tile. Its + * `● [logo] name · model ..... ⋯ ⤢ ×`: the status dot (the six-state + * classifier the tab rows and both home screens share), the harness logo, the + * session name (double-click renames), the model it runs when known, the + * session menu (the tab rail's own), zoom and remove-tile. Its * buttons stop pointerdown, so acting on a tile that is not focused does not * also focus it (and spend its idle alert). */ @@ -1087,6 +1091,15 @@ Object.assign(CodemanApp.prototype, { const dot = document.createElement('span'); dot.className = 'tile-dot home-sessions-dot home-sessions-dot--idle'; dot.setAttribute('aria-hidden', 'true'); + // The harness's logo: PR #532's `run-mode-dot ` slot, so the logos, + // the skins and the plain dot for an id without one stay in styles.css. Its + // tooltip and accessible name carry the harness and the model in full + // (_paintSessionHarness). + const harness = document.createElement('span'); + harness.className = 'tile-harness run-mode-dot'; + harness.setAttribute('role', 'img'); + const title = document.createElement('span'); + title.className = 'tile-title'; const name = document.createElement('span'); name.className = 'tile-name'; // A session literally named like a UI string ("Sessions") must not be translated. @@ -1095,6 +1108,18 @@ Object.assign(CodemanApp.prototype, { e.stopPropagation(); this.startTileRename(sessionId); }); + // The model the session runs, when the server knows it: the name itself is + // never translated (data-i18n-skip on the inner span), the tooltip on the + // outer one may be. Hidden from screen readers: the logo's accessible name + // already says it. + const model = document.createElement('span'); + model.className = 'tile-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); const actions = document.createElement('span'); actions.className = 'tile-actions'; const button = (cls, label, glyph, onClick) => { @@ -1122,13 +1147,13 @@ Object.assign(CodemanApp.prototype, { // behind the menu's Close session and its confirm. button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) ); - el.append(dot, name, actions); - return { el, dot, name, zoomBtn }; + el.append(dot, harness, title, actions); + return { el, dot, harness, name, model, modelName, zoomBtn }; }, /** - * Refreshes one tile's header from the session: dot state, name, the hover - * label ("working 3m") and the `needs` border. Diffs on existing nodes only, + * Refreshes one tile's header from the session: dot state, harness logo, + * name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only, * and cheap: it runs on every tab render (every status change). */ _renderTileHeader(sessionId) { @@ -1152,6 +1177,7 @@ Object.assign(CodemanApp.prototype, { const name = this._inlineRenamePending?.get(sessionId) || this.getSessionName?.(session) || session.name || 'Session'; if (entry.name.textContent !== name) entry.name.textContent = name; + this._paintSessionHarness(entry, session, 'tile-harness'); // A permission prompt is visible across the room. entry.el.classList.toggle('tile--needs', state === 'needs'); this._renderTileOverlay(sessionId); diff --git a/test/frontend-cli-no-id-branching.test.ts b/test/frontend-cli-no-id-branching.test.ts index 5c98efde..ba098bc7 100644 --- a/test/frontend-cli-no-id-branching.test.ts +++ b/test/frontend-cli-no-id-branching.test.ts @@ -1,6 +1,9 @@ /** * @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched - * (`session-ui.js`, `mobile-overview.js`), mirroring + * (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session + * header's harness logo and model (`constants.js`, `terminal-split.js`, + * `tile-grid.js`: the logo's `run-mode-dot ` class is the id as DATA), + * mirroring * `test/cli-registry-no-id-branching.test.ts` for the backend registry. * * Deliberately scoped to ONLY these two files, not all of `src/web/public/`. @@ -21,7 +24,7 @@ import { fileURLToPath } from 'node:url'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url)); -const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js']; +const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js']; /** * Every currently-surviving branch, each with the COUNT of physical call @@ -90,6 +93,13 @@ const ALLOWED_BRANCHES: Record = { reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI', }, + // tile-grid.js: a tile's Attach re-attaches through the same route pair the + // primary pane uses, and that pair is split by mode on the server side. + "tile-grid.js::mode === 'shell'": { + count: 1, + reason: 'attach route: a shell session attaches through /shell, an agent through /interactive', + }, + // mobile-overview.js: shell is exempt from the isCliAvailable() gate the // same way the toolbar's #runModeMenu exempts it (shell needs no CLI). "mobile-overview.js::mode !== 'shell'": { @@ -177,7 +187,7 @@ function actualCounts(): Map { return counts; } -describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => { +describe('no NEW CLI-id branching in the scanned frontend files', () => { it('scans both files (sanity)', () => { // If this drops to zero the scanner or the file list drifted and every // assertion below would pass vacuously. diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 82f74712..d82fd062 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -313,6 +313,8 @@ export function resetGridHarness() { rafCallbacks.length = 0; localStore.clear(); windowStub.innerWidth = 2400; + // The CLI catalog the server injects (labels for the harness logos); a test sets its own. + delete windowStub.__codemanCliCatalog; section.children = []; main.className = 'main'; // A split a test left open moved .terminal-wrap into its container. diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 3b5a07da..7a3f2dda 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -1,6 +1,11 @@ /** - * @fileoverview A tile's header: `● name ......... ⋯ ⤢ ×`, and the tab marker. + * @fileoverview A tile's header: `● [logo] name · model ..... ⋯ ⤢ ×`, and the tab marker. * + * - The logo is PR #532's `run-mode-dot ` slot (the id is data); the + * logo's tooltip and accessible name carry the harness and the model, with + * where the model came from when the CLI did not report it. The model is + * text (never markup, never translated), and an unknown model shows nothing. + * An unchanged session writes nothing on a refresh. * - The dot uses the six-state classifier the tab rows and both home screens * share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*` * classes; a `needs` tile gets the pulsing red border; hovering shows the @@ -18,7 +23,15 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeEl, FakeTile, makeGridApp, resetGridHarness, type GridApp, tileEl } from './mocks/tile-grid-vm.js'; +import { + FakeEl, + FakeTile, + makeGridApp, + resetGridHarness, + type GridApp, + tileEl, + windowStub, +} from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -39,8 +52,12 @@ function openGrid(): GridApp { } const headerOf = (id: string) => tileEl(id).children[0]; +/** A header part by its class, wherever the header nests it. */ +const partOf = (id: string, cls: string) => headerOf(id).querySelector(`.${cls}`) as FakeEl; const buttonOf = (id: string, cls: string) => - headerOf(id).children[2].children.find((b) => b.className.includes(cls)) as FakeEl; + partOf(id, 'tile-actions').children.find((b) => b.className.includes(cls)) as FakeEl; +/** Where the name sits: the name itself, or the rename input in its place. */ +const nameSlotOf = (id: string) => partOf(id, 'tile-title').children[0]; beforeEach(() => { resetGridHarness(); @@ -51,7 +68,7 @@ describe('the header', () => { const app = makeGridApp(IDS); app.sessions.get('s-b').name = 'Sessions'; app.openTileGrid(IDS); - const name = headerOf('s-b').children[1]; + const name = partOf('s-b', 'tile-name'); expect(name.textContent).toBe('Sessions'); expect(name.getAttribute('data-i18n-skip')).toBe(''); expect(name.children).toHaveLength(0); @@ -70,9 +87,9 @@ describe('the header', () => { app.pendingHooks.set('s-c', new Set(['permission_prompt'])); app._renderTileChrome(); - expect(headerOf('s-a').children[0].className).toContain('home-sessions-dot--idle'); - expect(headerOf('s-b').children[0].className).toContain('home-sessions-dot--working'); - expect(headerOf('s-c').children[0].className).toContain('home-sessions-dot--needs'); + expect(partOf('s-a', 'tile-dot').className).toContain('home-sessions-dot--idle'); + expect(partOf('s-b', 'tile-dot').className).toContain('home-sessions-dot--working'); + expect(partOf('s-c', 'tile-dot').className).toContain('home-sessions-dot--needs'); expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true); expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false); }); @@ -95,6 +112,148 @@ describe('the header', () => { }); }); +describe('the harness logo and the model', () => { + const CATALOG = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'deepseek', label: 'DeepSeek' }, + { id: 'shell', label: 'Shell' }, + ]; + + /** s-a on dsh's route, s-b claude on its statusline's model, s-c a shell. */ + function harnessGrid(): GridApp { + windowStub.__codemanCliCatalog = CATALOG; + const app = makeGridApp(IDS); + app.sessions.get('s-a').mode = 'deepseek'; + app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'screen' }; + app.sessions.get('s-b').displayModel = { model: 'Haiku 4.5', source: 'statusline' }; + app.sessions.get('s-c').mode = 'shell'; + app.openTileGrid(IDS); + return app; + } + const logoOf = (id: string) => partOf(id, 'tile-harness'); + const modelOf = (id: string) => partOf(id, 'tile-model'); + const modelNameOf = (id: string) => modelOf(id).children[0]; + + it('sits between the dot and the name: `● [logo] name · model ... ⋯ ⤢ ×`', () => { + harnessGrid(); + expect(headerOf('s-a').children.map((c) => c.className.split(' ')[0])).toEqual([ + 'tile-dot', + 'tile-harness', + 'tile-title', + 'tile-actions', + ]); + expect(partOf('s-a', 'tile-title').children.map((c) => c.className)).toEqual(['tile-name', 'tile-model']); + }); + + it("draws the session's CLI as PR #532's logo slot, the id as data", () => { + harnessGrid(); + expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot deepseek'); + expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot claude'); + expect(logoOf('s-c').className).toBe('tile-harness run-mode-dot shell'); + expect(logoOf('s-a').getAttribute('role')).toBe('img'); + }); + + it('names the harness and the model in the tooltip and the accessible name', () => { + harnessGrid(); + expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b'); + expect(logoOf('s-a').getAttribute('aria-label')).toBe('DeepSeek \u00B7 qwen3.8-27b'); + expect(modelOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b'); + expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b'); + expect(modelOf('s-a').hidden).toBe(false); + // Said once to a screen reader: the model's box is hidden from it. + expect(modelOf('s-a').getAttribute('aria-hidden')).toBe('true'); + // The model name is never translated; its tooltip may be. + expect(modelNameOf('s-a').getAttribute('data-i18n-skip')).toBe(''); + expect(modelOf('s-a').getAttribute('data-i18n-skip')).toBeNull(); + }); + + it('an unknown model shows the logo alone: no text, no placeholder', () => { + harnessGrid(); + expect(modelOf('s-c').hidden).toBe(true); + expect(modelNameOf('s-c').textContent).toBe(''); + expect(logoOf('s-c').title).toBe('Shell'); + }); + + it('says in the tooltip where a model came from when the CLI did not report it', () => { + const app = harnessGrid(); + app.sessions.get('s-b').displayModel = { model: 'haiku', source: 'launch' }; + app.sessions.get('s-c').mode = 'claude'; + app.sessions.get('s-c').displayModel = { model: 'qwen3.8-27b', source: 'custom-endpoint' }; + app._renderTileChrome(); + expect(logoOf('s-b').title).toBe('Claude Code \u00B7 haiku (set at launch)'); + expect(logoOf('s-c').title).toBe('Claude Code \u00B7 qwen3.8-27b (custom endpoint)'); + expect(modelNameOf('s-b').textContent).toBe('haiku'); + }); + + it('a model change (session:updated) updates the header', () => { + const app = harnessGrid(); + app.sessions.set('s-b', { + ...app.sessions.get('s-b'), + displayModel: { model: 'Sonnet 4.6', source: 'statusline' }, + }); + app._renderTileChrome(); + expect(modelNameOf('s-b').textContent).toBe('Sonnet 4.6'); + expect(logoOf('s-b').title).toBe('Claude Code \u00B7 Sonnet 4.6'); + // And the model going away takes the text with it. + delete app.sessions.get('s-b').displayModel; + app._renderTileChrome(); + expect(modelOf('s-b').hidden).toBe(true); + expect(modelNameOf('s-b').textContent).toBe(''); + }); + + it('a model with markup stays text', () => { + const app = harnessGrid(); + app.sessions.get('s-b').displayModel = { model: '', source: 'statusline' }; + app._renderTileChrome(); + expect(modelNameOf('s-b').textContent).toBe(''); + expect(modelNameOf('s-b').children).toHaveLength(0); + expect(modelOf('s-b').children).toHaveLength(1); + }); + + it('an unchanged session writes nothing on a refresh', () => { + const app = harnessGrid(); + const writes: string[] = []; + for (const id of IDS) { + for (const [node, props] of [ + [logoOf(id), ['className', 'title']], + [modelOf(id), ['title', 'hidden']], + [modelNameOf(id), ['textContent']], + ] as Array<[FakeEl, string[]]>) { + for (const prop of props) { + let value = (node as unknown as Record)[prop]; + Object.defineProperty(node, prop, { + get: () => value, + set: (v) => { + writes.push(`${id} ${prop}`); + value = v; + }, + }); + } + const setAttribute = node.setAttribute.bind(node); + node.setAttribute = (k: string, v: string) => { + writes.push(`${id} @${k}`); + setAttribute(k, v); + }; + } + } + app._renderTileChrome(); + app._renderTileChrome(); + expect(writes).toEqual([]); + }); + + it('an id that is not a CLI id is not a class name; without a catalog the label is the id', () => { + const app = harnessGrid(); + delete windowStub.__codemanCliCatalog; + app.sessions.get('s-a').mode = 'bad id" onclick'; + app.sessions.get('s-b').mode = 'my-cli'; + app._renderTileChrome(); + expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot'); + expect(logoOf('s-a').title).toBe('qwen3.8-27b'); + expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot my-cli'); + expect(logoOf('s-b').title).toBe('my-cli \u00B7 Haiku 4.5'); + }); +}); + describe('header buttons', () => { it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => { const app = openGrid(); @@ -216,8 +375,8 @@ describe('a translated label survives a refresh (zh-CN)', () => { describe('rename', () => { function startRename(app: GridApp, id: string) { - headerOf(id).children[1].dispatch('dblclick', { stopPropagation: vi.fn() }); - return headerOf(id).children[1]; + nameSlotOf(id).dispatch('dblclick', { stopPropagation: vi.fn() }); + return nameSlotOf(id); } it('double-click puts an input in place of the name; Enter renames through the write queue', () => { @@ -235,8 +394,8 @@ describe('rename', () => { input.value = 'renamed'; input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() }); expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed'); - expect(headerOf('s-b').children[1].className).toBe('tile-name'); - expect(headerOf('s-b').children[1].textContent).toBe('renamed'); + expect(nameSlotOf('s-b').className).toBe('tile-name'); + expect(nameSlotOf('s-b').textContent).toBe('renamed'); }); it('Escape cancels without a write', () => { @@ -246,7 +405,7 @@ describe('rename', () => { input.value = 'nope'; input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() }); expect(app._queueInlineSessionName).not.toHaveBeenCalled(); - expect(headerOf('s-b').children[1].textContent).toBe('s-b'); + expect(nameSlotOf('s-b').textContent).toBe('s-b'); }); it('a header refresh while renaming leaves the input alone', () => { @@ -254,7 +413,7 @@ describe('rename', () => { const input = startRename(app, 's-b'); input.value = 'half-typed'; app._renderTileChrome(); - expect(headerOf('s-b').children[1]).toBe(input); + expect(nameSlotOf('s-b')).toBe(input); expect(input.value).toBe('half-typed'); }); @@ -265,7 +424,7 @@ describe('rename', () => { input.value = 'x'; input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() }); expect(app._queueInlineSessionName).not.toHaveBeenCalled(); - expect(headerOf('s-b').children[1]).toBe(input); + expect(nameSlotOf('s-b')).toBe(input); }); }); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 1dd5976d..3718e7f0 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -72,6 +72,14 @@ const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) const seen = new Map(); /** Text in a data-i18n-skip subtree (user text): must never be translated. */ const userText = new Set(); +/** + * The harness logo's tooltip and accessible name (and the model box's + * tooltip): harness and model NAMES, which stay as they are, plus at most a + * note on where the model came from, which translates. + */ +const harnessLabels = new Map(); +/** The harness labels and models the exercise below gives its sessions. */ +const HARNESS_NAMES = ['Claude Code', 'DeepSeek', 'Codex', 'qwen3.8-27b', 'Haiku 4.5', 'haiku', 'gpt-5.6-terra']; /** * A title or accessible name inside a skipped subtree: the translator skips * the element's attributes along with its text, so a UI label there stays @@ -82,9 +90,11 @@ const labelsInSkip: string[] = []; function harvest(root: FakeEl | null | undefined, where: string) { const walk = (el: FakeEl, inSkip: boolean) => { const skip = inSkip || 'data-i18n-skip' in el.attrs; + const names = /\b(run-mode-dot|tile-model)\b/.test(el.className); const add = (value: unknown, kind: string) => { if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return; - if (skip && kind === 'text') userText.add(value.trim()); + if (names && !skip && kind !== 'text') harnessLabels.set(value.trim(), `${where} (${kind})`); + else if (skip && kind === 'text') userText.add(value.trim()); else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`); else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`); }; @@ -127,6 +137,22 @@ const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`); async function exercise() { setUp(); const app = makeGridApp(EIGHT); + // The harness logos: a CLI-reported model, a launch one, a custom endpoint's, none. + windowStub.__codemanCliCatalog = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'deepseek', label: 'DeepSeek' }, + { id: 'codex', label: 'Codex' }, + ]; + Object.assign(app.sessions.get('s-1'), { + mode: 'deepseek', + displayModel: { model: 'qwen3.8-27b', source: 'screen' }, + }); + app.sessions.get('s-2').displayModel = { model: 'haiku', source: 'launch' }; + Object.assign(app.sessions.get('s-3'), { + mode: 'codex', + displayModel: { model: 'qwen3.8-27b', source: 'custom-endpoint' }, + }); + app.sessions.get('s-6').displayModel = { model: 'Haiku 4.5', source: 'statusline' }; let pill = 'idle'; app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); app._mobileOverviewStampText = () => '3m'; @@ -291,6 +317,30 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( expect(bad).toEqual([]); }); + it('the harness logo keeps harness and model names as they are and translates the rest', () => { + // Not vacuous: every form the logo's tooltip takes was seen. + expect([...harnessLabels.keys()]).toEqual( + expect.arrayContaining([ + 'DeepSeek \u00B7 qwen3.8-27b', + 'Claude Code \u00B7 haiku (set at launch)', + 'Codex \u00B7 qwen3.8-27b (custom endpoint)', + 'Claude Code \u00B7 Haiku 4.5', + 'Claude Code', + ]) + ); + const bad: string[] = []; + for (const [label, where] of harnessLabels) { + const text = zh.api.t(label); + const kept = HARNESS_NAMES.filter((n) => label.includes(n)); + let rest = text; + for (const n of [...kept].sort((a, b) => b.length - a.length)) rest = rest.split(n).join(''); + if (!kept.length || kept.some((n) => !text.includes(n)) || leftover(rest).length || en.api.t(label) !== label) { + bad.push(`${where}: "${label}" -> "${text}"`); + } + } + expect(bad).toEqual([]); + }); + it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => { expect(labelsInSkip).toEqual([]); }); diff --git a/test/tile-grid-zoom.test.ts b/test/tile-grid-zoom.test.ts index 7dce6516..7456e832 100644 --- a/test/tile-grid-zoom.test.ts +++ b/test/tile-grid-zoom.test.ts @@ -28,7 +28,9 @@ import { const IDS = ['s-a', 's-b', 's-c', 's-d']; const zoomButton = (id: string) => - tileEl(id).children[0].children[2].children.find((b) => b.className.includes('tile-zoom')) as FakeEl; + tileEl(id) + .querySelector('.tile-actions')! + .children.find((b) => b.className.includes('tile-zoom')) as FakeEl; function openGrid(ids = IDS, focus = ids[0]): GridApp { const app = makeGridApp(ids); From c6e13e4fcf12ec3b814c751dab15546efb027028 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 12:54:38 +0200 Subject: [PATCH 67/90] 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) --- src/web/public/styles.css | 44 +++--- src/web/public/terminal-split.js | 110 +++++++++++++-- test/mocks/tile-grid-vm.ts | 10 +- test/split-pane-headers.test.ts | 230 +++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 test/split-pane-headers.test.ts diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a0fe1843..9b5560cb 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19376,31 +19376,33 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle 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 { display: flex; align-items: center; - justify-content: space-between; - padding: 4px 8px; + gap: 6px; + flex: 0 0 28px; + height: 28px; + box-sizing: border-box; + padding: 0 2px 0 8px; + overflow: hidden; font-size: 12px; background: var(--bg-secondary, #1a1a1a); border-bottom: 1px solid var(--border-color, #333); - flex: 0 0 auto; + user-select: none; } -.terminal-pane-b-close { - /* A native - -
- `; + // Pane B's header: the harness logo, the name and the model, as on a grid + // tile, and the close button at a tile button's size. + const headerB = this._buildSplitPaneHeader(); + const close = document.createElement('button'); + close.type = 'button'; + 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); + wrap.insertBefore(headerA.el, wrap.firstChild); container.appendChild(wrap); wrap.style.flexBasis = '50%'; container.appendChild(divider); container.appendChild(paneB); 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, fontSettings: this.loadAppSettingsFromStorage?.() || {}, detachedSessions: this.detachedSessions, @@ -209,6 +225,7 @@ Object.assign(CodemanApp.prototype, { initial load — live output still arrives once/if the socket connects. */ }); this._splitSessionId = sessionId; + this._renderSplitChrome(); // 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 @@ -236,6 +253,9 @@ Object.assign(CodemanApp.prototype, { this._splitPane.destroy(); this._splitPane = 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); const container = document.querySelector('.terminal-split-container'); @@ -246,14 +266,16 @@ Object.assign(CodemanApp.prototype, { parent.insertBefore(wrap, container); 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 // 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; - // the promoted session gets its own resize from selectSession(). - if (!options.skipPrimaryResize) { - this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {}); - } + // the promoted session gets its own resize from selectSession(), and the + // terminal is only refitted here. + 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 @@ -261,6 +283,59 @@ Object.assign(CodemanApp.prototype, { // nothing on the button said which. `.split-open` + aria-pressed give it // the same active-state language as the codebase's other toggle buttons // (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 * split panes'. The logo is PR #532's `run-mode-dot ` 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 // still attached to — two live WebSockets to one session, each claiming PTY // 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; CodemanApp.prototype.selectSession = function (sessionId, ...args) { if (this._splitPane && this._splitSessionId === sessionId) { diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d82fd062..7b818648 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -80,6 +80,9 @@ export class FakeEl { else this.children.splice(i, 0, child); return child; } + get firstChild() { + return this.children[0] ?? null; + } get nextSibling() { const siblings = this.parentElement?.children ?? []; return siblings[siblings.indexOf(this) + 1] ?? null; @@ -152,7 +155,8 @@ export class FakeTile { export const main = new FakeEl(); 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'; main.appendChild(wrap); export const section = new FakeEl(); @@ -317,8 +321,10 @@ export function resetGridHarness() { delete windowStub.__codemanCliCatalog; section.children = []; 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 = []; + wrap.children = []; main.appendChild(wrap); main.appendChild(section); bySelector.clear(); diff --git a/test/split-pane-headers.test.ts b/test/split-pane-headers.test.ts new file mode 100644 index 00000000..236d23d7 --- /dev/null +++ b/test/split-pane-headers.test.ts @@ -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 ` 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 = ''; + app.sessions.get('s-b').displayModel = { model: 'm', source: 'statusline' }; + app.syncTerminalGeometry = vi.fn(); + app.openSplitPane('s-b'); + expect(part(headerB(), 'session-name').textContent).toBe(''); + expect(part(headerB(), 'session-name').children).toHaveLength(0); + expect(modelName(headerB()).textContent).toBe('m'); + 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)[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(); + }); +}); From c481bf4f4776fb58e1d209daeb085e8b42b0d1da Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 13:10:29 +0200 Subject: [PATCH 68/90] docs: session headers name the harness and the model (displayModel) - api-reference: the new `displayModel` session field, its sources in order (custom-endpoint, statusline, screen, launch) and that it is untrusted display text, persisted and restored when the CLI reported it. - cli-registry: `capabilities.modelDetect` (one capture group, the last rows of the probe's capture, anchored on chrome only that CLI draws), the two stock patterns (dsh-TUI, codex) and the fifth config regex. - architecture-invariants (tile grid): the header painter, the id as data, the untrusted model text, no writes for an unchanged session, the truncation order, Pane A's strip and its fits through syncTerminalGeometry. - tile-grid-plan "as built", the wiki's Tile Grid page (logo and model rows, Split's strips), and CLAUDE.md's tile grid and CLI registry paragraphs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 ++-- docs/api-reference.md | 24 ++++++++++++++++++++++++ docs/architecture-invariants.md | 2 ++ docs/cli-registry.md | 6 +++++- docs/tile-grid-plan.md | 10 +++++++++- docs/wiki/Tile-Grid.md | 6 +++++- 6 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e4ab84f..eba03b9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/api-reference.md b/docs/api-reference.md index cf9378c1..f1bc6ed5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -484,6 +484,30 @@ also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. +## Session model (`displayModel`) + +Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a +session runs as far as the server knows it, for the web UI's session headers: + +```json +"displayModel": { "model": "qwen3.8-27b", "source": "screen" } +``` + +`source` is where it came from, strongest first: + +| `source` | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | +| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | +| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | +| `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | + +Between `statusline` and `screen` the newest report wins. The field is absent when no +model is known (a shell, a CLI that reports none and was launched without one). `model` +is display text from a pane or a CLI report: control characters are stripped and it is at +most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is +persisted and restored after a server restart until the next report replaces it. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e48f49ae..db5df2eb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -815,6 +815,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. +⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. + ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. ⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 82ec20a0..ac6844ae 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -49,6 +49,8 @@ interface CliEntry { // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? } // — how this CLI's pane shows work, work it started in the background, and a turn // that ended waiting for workers it will resume from + // .modelDetect?: { screenLine, screenLines? } + // (where this CLI's own chrome names the model it runs: SessionState.displayModel) overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store } ``` @@ -57,10 +59,12 @@ interface CliEntry { ### Regexes that come from config -Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. +`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. + `watchingLine` reads a different row of the same screen. A CLI draws it while work the agent itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index f518cd81..9cb3348e 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -48,6 +48,13 @@ or settled a question the spec left open. The invariants as built are in the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable, so going back to nine is that one line. A stored grid with more ids comes back as its first six. Where this spec says nine, read six. +- **Each header names the harness and the model** (owner request): the tile header is + `● [logo] name · model ……… ⋯ ⤢ ×`. The logo is PR #532's `run-mode-dot ` slot + (the id is data), the model the session's `displayModel` (custom endpoint, else what the + CLI itself reports: claude's statusline, a footer read with `capabilities.modelDetect` + for dsh and codex; else the launch model; else nothing, the logo alone). Both split + panes carry the same strip: Pane B's header, and Pane A's while the split is open. Pane + B's close is a tile button (26px). - **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" are gone; tiles are added from the Tiles button (and its right-click picker), Ctrl/Cmd+click on a tab, a dragged tab, "Open group @@ -206,7 +213,8 @@ animation frame, and sends one resize per affected tile at pointer-up. ### Tile header -`● name ……… ⋯ ⤢ + ×` (as built: `● name ……… ⋯ ⤢ ×`, owner decision 9) +`● name ……… ⋯ ⤢ + ×` (as built: `● [logo] name · model ……… ⋯ ⤢ ×`, owner decision 9 and +the harness/model request; see "As built") - **●** status dot from the existing six-state classifier (`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`): diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 5562febf..ade5c66e 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -39,12 +39,14 @@ window is too small for six; the picker says which limit applies. ## A tile -Each tile has a small header: `● name ......... ⋯ ⤢ ×` +Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×` | Part | What it does | | ------ | ------------------------------------------------------------------------------------------------ | | `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. | +| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. | | name | Double-click to rename the session. | +| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model it was started with. Nothing when unknown. | | `⋯` | The session menu: options, open in a new window, close the session. | | `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. | | `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. | @@ -85,6 +87,8 @@ same. Narrowing the window below the desktop width also returns to the single vi The grid is saved on this device and comes back when you reload the page, with its focus, zoom and column widths. A session that was closed in the meantime is simply left out. +Split shows the same logo, name and model above both of its panes. + The grid and Split are never open together: opening the grid turns an open split into two tiles, and Split is unavailable while the grid is open. From 284f86b74010742f31b68415e3d91ba2f8a73eb8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 19:27:45 +0200 Subject: [PATCH 69/90] feat(deepseek): read the model a dsh session's TUI route config pins A DeepSeek session whose screen names no model (dsh-TUI's status bar model field off, or not drawn yet) can still name the model its route config pins (owner request). src/deepseek-route-config.ts resolves it the way dsh and dsh-TUI 0.10.0-beta.1 do, for the session's profile (else the one the launch boots) under the session's dsh home: - dsh composes a profile from patch layers: the bundles, then profiles//cordis.patch.yml, then $DSH_HOME/cordis.patch.yml (which outranks it). A patch's `config` replaces the dsh-tui row's whole config, a `name` mismatch skips it, `disabled` turns the row off. - dsh-TUI takes its route from that config only when it names BOTH provider and model (modelRoute.js); anything less falls back to state the config does not hold, so the answer is nothing. The bundle row pins a provider alone by design and is not read (it resolves outside the dsh home); settings.yaml's agent-default-model is the headless default and is never read. - Any doubt answers nothing: a profile without dsh-TUI, a half-pinned route, an unreadable or oversized layer, a symlink out of the dsh home, a mount that does not answer, a file beyond a narrow strict YAML subset (no dependency added: plain keys, single-line string scalars for the values it needs; tags, anchors, aliases, merge keys, multi-line scalars, flow or block-scalar config, duplicate keys, typed scalars, a second document, or a nested row re-defining dsh-tui all answer null). - Bounded and read-only: every path is probed with probePathKind() first, read async with a 64 KiB cap, and must realpath inside the dsh home. Only the model id leaves the module. The default-profile inventory reuses the resolver's classification through a new pure deepSeekProfileFromManifest(), read with the same bounded rules. Not wired to sessions yet; the next commit does. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/deepseek-route-config.ts | 464 +++++++++++++++++++++++++++++ src/utils/deepseek-cli-resolver.ts | 21 +- test/deepseek-route-config.test.ts | 275 +++++++++++++++++ 3 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 src/deepseek-route-config.ts create mode 100644 test/deepseek-route-config.test.ts diff --git a/src/deepseek-route-config.ts b/src/deepseek-route-config.ts new file mode 100644 index 00000000..ad30211c --- /dev/null +++ b/src/deepseek-route-config.ts @@ -0,0 +1,464 @@ +/** + * @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to + * use, read from its route config, for a session header whose screen names no model + * yet (the status bar's model field switched off, or not drawn yet). See + * `SessionState.displayModel` (src/session-display-model.ts): the screen still wins + * whenever it names a model, since it is what the running TUI actually uses. + * + * ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1) + * + * A profile is a stack of loader patch layers over an empty root, in this order + * (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own + * `$DSH_HOME/profiles//cordis.patch.yml`, the home-level + * `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch` + * overlays (Codeman passes none). A patch targets a row by `id`; one whose `name` + * does not match the row's is skipped; every other key REPLACES the row's field + * whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui` + * row defines all of it. + * + * dsh-TUI then takes its model route from that config only when it names BOTH + * `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is + * dropped whole and the TUI falls back to the persisted `/model` choice, then to its + * own default: neither is in the config, so neither is answered here. The bundle's + * own row pins `provider: deepseek-official` alone, by design, so only the two user + * layers can pin a route; the bundle layers are not read (they resolve through the dsh + * installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the + * HEADLESS default, not the TUI's, and is never read. + * + * ## Answer nothing rather than a guess + * + * Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned + * route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too + * big, a mount that does not answer), and a file this reader does not fully + * understand. The YAML reader below is deliberately narrow (the repo carries no YAML + * dependency): a top-level block sequence of patch items, plain keys, single-line + * plain or quoted string scalars for the values it needs, and null for anything else + * that could change the answer (an anchor, alias or tag such as `!!js` on such a value, + * a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a + * scalar YAML would type as a number, boolean or null, a second document, a nested + * row redefining dsh-TUI). + * + * ## Never block, never write, never leak + * + * Every path is probed with the bounded `probePathKind()` before it is touched, read + * asynchronously with a size cap, and must resolve (realpath) inside the dsh home. + * Nothing is written. Only the model id leaves this module: never the provider, a + * base URL, a key or any other config value. + * + * Tests: `test/deepseek-route-config.test.ts`. + * + * @module deepseek-route-config + */ + +import fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve, sep } from 'node:path'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import { + deepSeekProfileFromManifest, + isProfileDirName, + resolveDefaultDeepSeekProfile, + type DeepSeekProfile, +} from './utils/deepseek-cli-resolver.js'; + +/** The dsh-TUI bundle a profile must compose for its route to be read here. */ +export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui'; +/** The loader row dsh-TUI's own config lives on. */ +export const DSH_TUI_ROW_ID = 'dsh-tui'; +/** Largest file read: a patch layer is a few dozen lines. */ +export const MAX_ROUTE_FILE_BYTES = 64 * 1024; +/** A profile name as the launch accepts it (the `path-segment` token pattern). */ +const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; +/** How many profile directories the default-profile inventory looks at. */ +const MAX_PROFILES = 64; + +/** What one patch item does to the dsh-TUI row. */ +export interface DshTuiRowPatch { + /** `name` on the patch; a mismatch makes dsh skip it. */ + name?: string; + /** `disabled` on the patch, when present. */ + disabled?: boolean; + /** + * `config` on the patch, when present: the provider and model it names (absent when + * it does not name one), or `{}` for a config that is empty or not a mapping. + */ + config?: { provider?: string; model?: string }; +} + +/** Thrown inside the parser for anything it does not fully understand. */ +class Ambiguous extends Error {} + +/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */ +const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/; + +const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/; + +/** + * Strip a line's comment (`#` at line start or after whitespace, outside quotes) and + * trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is + * beyond this reader. + */ +function stripComment(line: string): string { + let quote: '"' | "'" | null = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote === "'") { + if (c === "'") { + if (line[i + 1] === "'") i++; + else quote = null; + } + } else if (quote === '"') { + if (c === '\\') i++; + else if (c === '"') quote = null; + } else if (c === "'" || c === '"') { + // A quote opens a scalar only at its start; inside a plain scalar it is a character. + const prev = line.slice(0, i).trimEnd(); + if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c; + } else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) { + return line.slice(0, i).trimEnd(); + } + } + if (quote) throw new Ambiguous('unterminated quote'); + return line.trimEnd(); +} + +/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */ +function indentOf(line: string): number { + const m = /^[ \t]*/.exec(line)![0]; + if (m.includes('\t')) throw new Ambiguous('tab indentation'); + return m.length; +} + +/** + * A single-line scalar as a string: plain, or single/double-quoted. Throws on anything + * that is not plainly a string (a tag, an anchor, an alias, a flow collection, a + * block scalar, or a plain scalar YAML would type as null, a boolean or a number). + */ +function stringScalar(raw: string): string { + const v = raw.trim(); + if (v.startsWith("'")) { + const m = /^'((?:[^']|'')*)'$/.exec(v); + if (!m) throw new Ambiguous('quoted scalar'); + return m[1].replace(/''/g, "'"); + } + if (v.startsWith('"')) { + const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v); + if (!m) throw new Ambiguous('quoted scalar'); + return m[1].replace(/\\(["\\/])/g, '$1'); + } + if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string'); + if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar'); + if ( + /^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test( + v + ) + ) { + throw new Ambiguous('numeric scalar'); + } + if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator'); + return v; +} + +/** `true`/`false` as YAML spells them, or a throw. */ +function boolScalar(raw: string): boolean { + const v = raw.trim(); + if (/^(?:true|True|TRUE)$/.test(v)) return true; + if (/^(?:false|False|FALSE)$/.test(v)) return false; + throw new Ambiguous('not a boolean'); +} + +interface Line { + indent: number; + text: string; +} + +/** + * The direct keys of a block mapping whose lines all sit at `indent` or deeper, each + * with its inline value and the lines nested under it. Throws on a line that is not a + * key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh + * would not boot). + */ +function mappingKeys(lines: Line[], indent: number): Map { + const keys = new Map(); + let current: { inline: string | undefined; nested: Line[] } | null = null; + for (const line of lines) { + if (line.indent > indent) { + if (!current) throw new Ambiguous('nested line with no key'); + current.nested.push(line); + continue; + } + if (line.indent < indent) throw new Ambiguous('dedent inside a mapping'); + const m = KEY_LINE.exec(line.text); + if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`); + if (keys.has(m[1])) throw new Ambiguous('duplicate key'); + current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] }; + keys.set(m[1], current); + } + return keys; +} + +/** + * Whether an item this reader cannot follow is certainly about another row: a plain + * `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no + * mention of the dsh-TUI row anywhere in it. + */ +function isUnrelatedItem(item: Line[]): boolean { + const indent = item[0].indent; + const top = item.filter((l) => l.indent === indent); + if (top.some((l) => /^insert\s*:/.test(l.text))) return false; + const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id'); + if (ids.length !== 1 || ids[0]![2] === undefined) return false; + try { + return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID; + } catch { + return false; + } +} + +/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */ +function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } { + if (entry.inline !== undefined) { + if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines'); + const v = entry.inline.trim(); + // An empty flow mapping or a null names no route; anything else inline (a tag, a + // non-empty flow mapping, a block scalar) is beyond this reader. + if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {}; + throw new Ambiguous('inline config'); + } + if (!entry.nested.length) return {}; + const keys = mappingKeys(entry.nested, entry.nested[0].indent); + const out: { provider?: string; model?: string } = {}; + for (const field of ['provider', 'model'] as const) { + const value = keys.get(field); + if (!value) continue; + if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`); + out[field] = stringScalar(value.inline); + } + return out; +} + +/** + * What a cordis patch-list file (a top-level YAML array of loader patches) does to the + * dsh-TUI row, in order. An empty list when the file does not touch it. Null when the + * file is beyond this reader's subset, or when it could re-insert the row. + * + * @param text the file's content + */ +export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null { + try { + const lines: Line[] = []; + let sawContent = false; + for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) { + const stripped = stripComment(rawLine); + if (stripped.trim() === '') continue; + const indent = indentOf(stripped); + const body = stripped.slice(indent); + if (indent === 0 && body === '---') { + if (sawContent) throw new Ambiguous('a second document'); + continue; + } + sawContent = true; + lines.push({ indent, text: body }); + } + if (lines.length === 0) return []; + if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return []; + + // Split the top-level block sequence into items. + const items: Line[][] = []; + for (const line of lines) { + if (line.indent === 0) { + const m = /^-(?:(\s+)(.*))?$/.exec(line.text); + if (!m) throw new Ambiguous('not a top-level sequence'); + const item: Line[] = []; + // `- key: value`: the key sits at its real column, which its siblings below share. + if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] }); + items.push(item); + continue; + } + if (items.length === 0) throw new Ambiguous('indented content before the first item'); + items[items.length - 1].push(line); + } + + const patches: DshTuiRowPatch[] = []; + for (const item of items) { + if (item.length === 0) throw new Ambiguous('empty item'); + // The first key's column is the item's indent; every key shares it. + let keys: ReturnType; + try { + keys = mappingKeys(item, item[0].indent); + } catch (err) { + // An item this reader cannot follow is harmless only when it provably is about + // another row: its own id names one, and no line in it names the dsh-TUI row. + if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err; + if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row'); + continue; + } + const id = keys.get('id'); + if (keys.has('insert')) { + // An insert that could bring a second dsh-TUI row is beyond this reader. + const body = item.map((l) => l.text).join('\n'); + if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row'); + continue; + } + if (!id) continue; // dsh warns and skips a non-insert patch without an id + if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar'); + if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) { + // Another row; but a group row's config is a list of rows, and one of them could + // be a second dsh-TUI row (dsh indexes nested group entries by id too). + if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row'); + continue; + } + const patch: DshTuiRowPatch = {}; + const name = keys.get('name'); + if (name) { + if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar'); + patch.name = stringScalar(name.inline); + } + const disabled = keys.get('disabled'); + if (disabled) { + if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar'); + patch.disabled = boolScalar(disabled.inline); + } + const config = keys.get('config'); + if (config) patch.config = configOf(config); + patches.push(patch); + } + return patches; + } catch (err) { + if (err instanceof Ambiguous) return null; + throw err; + } +} + +/** + * The model dsh-TUI's route config pins, given what the user layers do to its row in + * application order (profile layer first, then the home layer). Null unless the last + * `config` that applies names both a provider and a model, and the row is not + * disabled. Pure. + * + * @param layers each layer's patches for the row, or null for a layer that could not be read + */ +export function resolveDshTuiRouteModel(layers: Array): string | null { + let config: { provider?: string; model?: string } | undefined; + let disabled = false; + for (const layer of layers) { + if (layer === null) return null; + for (const patch of layer) { + if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue; + if (patch.disabled !== undefined) disabled = patch.disabled; + if (patch.config !== undefined) config = patch.config; + } + } + if (disabled || !config?.provider || !config.model) return null; + return config.model; +} + +/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */ +type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' }; + +/** + * Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount + * that does not answer is refused, never waited on), its real path checked against + * the dsh home (a symlink out of it is refused), size-capped. + */ +async function readHomeFile(path: string, realHome: string): Promise { + const kind = await probePathKind(path); + if (kind === 'absent') return { state: 'absent' }; + if (kind !== 'file') return { state: 'refused' }; + try { + const real = await fs.realpath(path); + if (!real.startsWith(realHome + sep)) return { state: 'refused' }; + const stat = await fs.stat(real); + if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' }; + return { state: 'read', text: await fs.readFile(real, 'utf8') }; + } catch { + return { state: 'refused' }; + } +} + +/** + * The dsh home a session runs against: its own `DSH_HOME` (already clamped for a + * non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's + * `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place. + */ +export function effectiveDshHome(env: (key: string) => string | undefined): string | null { + const value = env('DSH_HOME')?.trim(); + if (!value) return join(homedir(), '.dsh'); + return isAbsolute(value) ? resolve(value) : null; +} + +/** + * The profiles under a dsh home, read with the same bounded rules as the route files. + * Used to name the profile a session boots when it named none, the way the launch's + * `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile). + */ +async function listProfilesBounded(home: string): Promise { + const profilesDir = join(home, 'profiles'); + if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null; + let realHome: string; + let names: string[]; + try { + realHome = await fs.realpath(home); + names = (await fs.readdir(profilesDir, { withFileTypes: true })) + .filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name)) + .map((e) => e.name) + .sort((a, b) => a.localeCompare(b)) + .slice(0, MAX_PROFILES); + } catch { + return null; + } + const profiles: DeepSeekProfile[] = []; + for (const name of names) { + const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome); + if (manifest.state !== 'read') continue; + const profile = deepSeekProfileFromManifest(name, manifest.text); + if (profile) profiles.push(profile); + } + return profiles; +} + +/** What the reader needs to know about one session. */ +export interface DeepSeekRouteContext { + /** The session's `deepSeekConfig.profile`, if any. */ + profile?: unknown; + /** The session's dsh home (see {@link effectiveDshHome}). */ + home: string | null; + /** The server's own dsh home, which names the default profile (as the launch does). */ + serverHome: string | null; +} + +/** + * The model the session's dsh-TUI route config pins, or null when it pins none or the + * answer is in any doubt. Read-only and bounded; see the module comment. + */ +export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise { + const { home } = ctx; + if (!home) return null; + // An invalid name reads as unset at launch, so the default applies there too. + let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null; + if (!profile) { + if (!ctx.serverHome) return null; + const listed = await listProfilesBounded(ctx.serverHome); + profile = listed ? resolveDefaultDeepSeekProfile(listed) : null; + if (!profile) return null; + } + if ((await probePathKind(home)) !== 'directory') return null; + let realHome: string; + try { + realHome = await fs.realpath(home); + } catch { + return null; + } + const profileDir = join(home, 'profiles', profile); + const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome); + if (manifest.state !== 'read') return null; + if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null; + + const layers: Array = []; + for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) { + const read = await readHomeFile(file, realHome); + if (read.state === 'refused') return null; + layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text)); + } + return resolveDshTuiRouteModel(layers); +} diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index 8b6694a8..b815a761 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map([ /** Profile directory names that are not profiles. */ const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']); +/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */ +export function isProfileDirName(name: string): boolean { + return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.'); +} + function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind { const haystack = [name, ...bundles].join(' '); // Order matters: a profile that composes BOTH a web app and a tui bundle is a @@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind { */ function readProfile(profilesDir: string, name: string): DeepSeekProfile | null { try { - const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'); + return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8')); + } catch { + return null; + } +} + +/** + * A profile from its directory name and the text of its `package.json`, or null when + * that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the + * same classification as the inventory below. + */ +export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null { + try { const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } }; const rawBundles = parsed?.dsh?.profile?.bundles; const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : []; @@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] { let entries: string[]; try { entries = readdirSync(profilesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.')) + .filter((e) => e.isDirectory() && isProfileDirName(e.name)) .map((e) => e.name); } catch { return []; diff --git a/test/deepseek-route-config.test.ts b/test/deepseek-route-config.test.ts new file mode 100644 index 00000000..d6ab76db --- /dev/null +++ b/test/deepseek-route-config.test.ts @@ -0,0 +1,275 @@ +/** + * The model a dsh session's TUI route config pins (src/deepseek-route-config.ts), + * shown in a session header when the TUI's screen names none. + * + * Fixture dsh homes are built in a temp directory. The two user layers below are the + * owner's real files on 2026-10-07, verbatim apart from the comment text: the + * profile layer pins the dsh-tui row's route (provider AND model), the home layer + * pins `agent-default-model`, which is the headless default and NOT the TUI's. + * + * Port: N/A. + */ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + DSH_TUI_PACKAGE, + MAX_ROUTE_FILE_BYTES, + effectiveDshHome, + parseDshTuiPatches, + readDeepSeekRouteModel, + resolveDshTuiRouteModel, +} from '../src/deepseek-route-config.js'; + +const PROFILE_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer: +# a top-level YAML array of loader patch entries (id-targeted config +# overrides, disables, and insert lists; \`!!js\` expressions allowed). +# +# Route the TUI at the 5090 box's live serve. A config route counts only +# when it names BOTH provider and model. +- id: dsh-tui + config: + provider: qwen5090 + model: qwen3.8-27b + effort: medium + preset: qwen5090 +`; +const HOME_LAYER = `# Home-level patch layer. The default model is a composition entry, +# not a settings key. +- id: agent-default-model + config: + provider: qwen5090 + model: qwen3.8-27b +`; +const SETTINGS = `agent-default-model: + provider: qwen5090 + model: qwen3.8-27b +`; +const TUI_BUNDLES = ['@deepseek-ai/dsh-base', DSH_TUI_PACKAGE]; + +const homes: string[] = []; +afterEach(() => { + for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** A fixture dsh home: profiles (bundles + optional patch layer), a home layer, settings.yaml. */ +function makeHome(spec: { + profiles?: Record; + homePatch?: string; + settings?: string; +}): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-route-')); + homes.push(root); + const home = join(root, 'dsh'); + mkdirSync(join(home, 'profiles'), { recursive: true }); + for (const [name, p] of Object.entries(spec.profiles ?? {})) { + mkdirSync(join(home, 'profiles', name), { recursive: true }); + writeFileSync( + join(home, 'profiles', name, 'package.json'), + JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles: p.bundles ?? TUI_BUNDLES } } }) + ); + if (p.patch !== undefined) writeFileSync(join(home, 'profiles', name, 'cordis.patch.yml'), p.patch); + } + if (spec.homePatch !== undefined) writeFileSync(join(home, 'cordis.patch.yml'), spec.homePatch); + if (spec.settings !== undefined) writeFileSync(join(home, 'settings.yaml'), spec.settings); + return home; +} + +const read = (home: string, profile: string | undefined = 'dsh-tui', serverHome: string | null = home) => + readDeepSeekRouteModel({ profile, home, serverHome }); + +describe('readDeepSeekRouteModel', () => { + it('a route pinned with both provider and model: the model', async () => { + const home = makeHome({ + profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, + homePatch: HOME_LAYER, + settings: SETTINGS, + }); + expect(await read(home)).toBe('qwen3.8-27b'); + }); + + it('a half-pinned route (provider alone): nothing, the TUI drops it whole', async () => { + const half = '- id: dsh-tui\n config:\n provider: qwen5090\n effort: medium\n'; + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: half } } }))).toBeNull(); + const modelOnly = '- id: dsh-tui\n config:\n model: qwen3.8-27b\n'; + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: modelOnly } } }))).toBeNull(); + }); + + it('no profile layer: nothing (the bundle pins a provider alone, by design)', async () => { + expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER, settings: SETTINGS }))).toBeNull(); + }); + + it("settings.yaml and the home layer's agent-default-model are the headless default, never the TUI's", async () => { + expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, settings: SETTINGS }))).toBeNull(); + expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER }))).toBeNull(); + }); + + it('a layer that does not parse: nothing', async () => { + const broken = [ + '- id: dsh-tui\n config:\n\tprovider: qwen5090\n\tmodel: x\n', // tab indentation + '- id: dsh-tui\n config:\n \tprovider: qwen5090\n \tmodel: x\n', // a tab inside the indentation + "- id: dsh-tui\n config:\n provider: 'qwen5090\n model: x\n", // unterminated quote + '- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n model: other\n', // duplicate key + 'dsh-tui:\n provider: qwen5090\n', // not a sequence + ]; + for (const patch of broken) { + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch } } })), patch).toBeNull(); + } + // The home layer is read too: a broken one blanks a good profile layer. + const home = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: '- id: [\n' }); + expect(await read(home)).toBeNull(); + }); + + it('a layer that is a symlink out of the dsh home: nothing; one inside it is followed', async () => { + const home = makeHome({ profiles: { 'dsh-tui': {} } }); + const outside = join(home, '..', 'outside.yml'); + writeFileSync(outside, PROFILE_LAYER); + symlinkSync(outside, join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml')); + expect(await read(home)).toBeNull(); + + const inside = makeHome({ profiles: { 'dsh-tui': {} } }); + writeFileSync(join(inside, 'shared-route.yml'), PROFILE_LAYER); + symlinkSync(join(inside, 'shared-route.yml'), join(inside, 'profiles', 'dsh-tui', 'cordis.patch.yml')); + expect(await read(inside)).toBe('qwen3.8-27b'); + }); + + it("a session's own DSH_HOME is the one read", async () => { + const server = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }); + const own = makeHome({ + profiles: { + 'dsh-tui': { patch: '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n' }, + }, + }); + expect(await read(own, 'dsh-tui', server)).toBe('deepseek-v4-pro'); + expect(await read(server, 'dsh-tui', server)).toBe('qwen3.8-27b'); + // The status bar can be switched off in that home without touching the route. + expect(effectiveDshHome((k) => (k === 'DSH_HOME' ? own : undefined))).toBe(own); + }); + + it('the home layer outranks the profile layer, and replaces its whole config', async () => { + const pin = '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n'; + const homeWins = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: pin }); + expect(await read(homeWins)).toBe('deepseek-v4-flash'); + const half = '- id: dsh-tui\n config:\n effort: high\n'; + const halved = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: half }); + expect(await read(halved)).toBeNull(); + }); + + it('no profile named: the one the launch would boot (the first terminal profile)', async () => { + const home = makeHome({ + profiles: { + web: { bundles: ['@deepseek-ai/dsh-web-app'] }, + 'dsh-tui': { patch: PROFILE_LAYER }, + }, + }); + expect(await read(home, undefined)).toBe('qwen3.8-27b'); + // An invalid name reads as unset at launch, so the default applies here too. + expect(await read(home, '../web')).toBe('qwen3.8-27b'); + }); + + it('a profile that does not compose dsh-TUI: nothing (its route is not this reader’s to know)', async () => { + const home = makeHome({ profiles: { other: { bundles: ['@someone/other-tui'], patch: PROFILE_LAYER } } }); + expect(await read(home, 'other')).toBeNull(); + }); + + it('a disabled row, a name mismatch, a missing home or profile: nothing', async () => { + const disabled = `${PROFILE_LAYER}- id: dsh-tui\n disabled: true\n`; + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: disabled } } }))).toBeNull(); + const mismatch = '- id: dsh-tui\n name: "@someone/else"\n config:\n provider: a\n model: b\n'; + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: mismatch } } }))).toBeNull(); + expect(await read(join(tmpdir(), 'no-such-dsh-home-xyz'))).toBeNull(); + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }), 'missing')).toBeNull(); + }); + + it('an oversized layer: nothing', async () => { + const big = PROFILE_LAYER + `# ${'x'.repeat(MAX_ROUTE_FILE_BYTES)}\n`; + expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: big } } }))).toBeNull(); + }); +}); + +describe('effectiveDshHome', () => { + it("the session's, else ~/.dsh; a relative value names no place", () => { + expect(effectiveDshHome(() => '/srv/dsh')).toBe('/srv/dsh'); + expect(effectiveDshHome(() => ' ')).toMatch(/\.dsh$/); + expect(effectiveDshHome(() => undefined)).toMatch(/\.dsh$/); + expect(effectiveDshHome(() => 'dsh-copy')).toBeNull(); + expect(effectiveDshHome(() => '~/dsh')).toBeNull(); + }); +}); + +describe('parseDshTuiPatches (the narrow YAML subset)', () => { + it("reads the owner's two layers", () => { + expect(parseDshTuiPatches(PROFILE_LAYER)).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]); + expect(parseDshTuiPatches(HOME_LAYER)).toEqual([]); + }); + + it('an empty list, comments only, and a leading document marker', () => { + expect(parseDshTuiPatches('[]\n')).toEqual([]); + expect(parseDshTuiPatches('# nothing yet\n')).toEqual([]); + expect(parseDshTuiPatches(`---\n${PROFILE_LAYER}`)).toHaveLength(1); + }); + + it('quoted scalars, a key on its own line under `-`, a wider item indent', () => { + expect( + parseDshTuiPatches('- id: "dsh-tui"\n config:\n provider: "qwen5090"\n model: \'qwen3.8-27b\'\n') + ).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]); + expect(parseDshTuiPatches('-\n id: dsh-tui\n config:\n provider: p\n model: m # trailing\n')).toEqual([ + { config: { provider: 'p', model: 'm' } }, + ]); + expect(parseDshTuiPatches('- id: dsh-tui\n config:\n provider: p\n model: m\n')).toEqual([ + { config: { provider: 'p', model: 'm' } }, + ]); + }); + + it('anything that could change the answer and is beyond the subset: null', () => { + const cases = [ + '- id: dsh-tui\n config:\n provider: !!js process.env.P\n model: m\n', // a tag + '- id: dsh-tui\n config: &route\n provider: p\n model: m\n', // an anchor + '- id: other\n config: &r { provider: p, model: m }\n- id: dsh-tui\n config: *r\n', // an alias + '- id: dsh-tui\n config:\n <<: { provider: p, model: m }\n', // a merge key + '- id: dsh-tui\n config: { provider: p, model: m }\n', // a flow config + '- id: dsh-tui\n config:\n provider: p\n model: 12\n', // a number, not a string + '- id: dsh-tui\n config:\n provider: p\n model: |\n m\n', // a block scalar + '- insert:\n - id: dsh-tui\n name: x\n', // re-inserting the row + `${PROFILE_LAYER}---\n- id: dsh-tui\n`, // a second document + '%YAML 1.2\n---\n- id: dsh-tui\n', // a directive + // A quoted scalar spanning lines swallows the next "key": YAML reads note as + // "start model: m" and the route as half-pinned. + '- id: dsh-tui\n config:\n note: "start\n model: m"\n provider: p\n', + // A plain scalar continued on the next line is one value ("m continued"). + '- id: dsh-tui\n config:\n provider: p\n model: m\n continued\n', + // A group row whose config re-defines a dsh-tui row inside it. + '- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n', + '- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n', + ]; + for (const text of cases) expect(parseDshTuiPatches(text), text).toBeNull(); + }); + + it('an unrelated item it cannot follow is skipped, one about dsh-tui is not', () => { + // `rules`' sequence at its key's own indent is valid YAML but beyond the subset. + const odd = '- id: approval\n rules:\n - a\n config:\n policy: |\n line one\n'; + expect(parseDshTuiPatches(`${odd}${PROFILE_LAYER}`)).toEqual([ + { config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }, + ]); + expect(parseDshTuiPatches(`- id: dsh-tui\n config:\n rules:\n - a\n`)).toBeNull(); + }); + + it('a disabled flag, a name, and an empty config', () => { + expect(parseDshTuiPatches('- id: dsh-tui\n disabled: true\n name: x\n config: {}\n')).toEqual([ + { disabled: true, name: 'x', config: {} }, + ]); + }); +}); + +describe('resolveDshTuiRouteModel', () => { + it('the last config wins whole; a layer that could not be read blanks the answer', () => { + const full = { config: { provider: 'p', model: 'm' } }; + expect(resolveDshTuiRouteModel([[full], []])).toBe('m'); + expect(resolveDshTuiRouteModel([[full], [{ config: { provider: 'p' } }]])).toBeNull(); + expect(resolveDshTuiRouteModel([[full], null])).toBeNull(); + expect(resolveDshTuiRouteModel([[], []])).toBeNull(); + expect(resolveDshTuiRouteModel([[full, { disabled: true }]])).toBeNull(); + expect(resolveDshTuiRouteModel([[full, { name: '@someone/else', config: {} }]])).toBe('m'); + }); +}); From 661fe3dc139622e73091b26abc6bdcd8be636784 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 19:41:56 +0200 Subject: [PATCH 70/90] feat(sessions): a dsh session shows its route config's model while its screen names none displayModel gains a `config` source, ranked below any report from the running CLI and above the launch model: custom endpoint, then statusline or screen, then config, then launch, then nothing. The screen still wins whenever it names a model, since that is what the running TUI uses. - Registry data: capabilities.modelDetect gains `configResolver`, a NAMED reader (src/model-config-resolvers.ts), like a launcher profile; dsh names 'deepseek-route' (the reader from the previous commit). `screenLine` becomes optional; the schema refuses a modelDetect naming nothing, an unknown reader, or screenLines without a screenLine. - Session: the reader runs from _withPaneLifecycle's finally, so at every pane start, attach and relaunch, with the session's own launch config (legacyConfigForMode) and env (its clamped overrides, then the server's), so a per-session DSH_HOME is the home read. Async; a read that lands after a newer one or after the session stopped is dropped; a remote or docker session reads nothing locally. A change emits displayModelChanged (broadcast and persist). Not restored after a restart: the next attach reads it again, and a restored screen value outranks it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config/cli-registry/schema.ts | 16 ++- src/config/cli-registry/stock.ts | 3 + src/config/cli-registry/types.ts | 12 +- src/model-config-resolvers.ts | 49 ++++++++ src/session-display-model.ts | 10 +- src/session.ts | 84 +++++++++++--- src/types/session.ts | 7 +- test/session-config-model.test.ts | 180 +++++++++++++++++++++++++++++ test/session-display-model.test.ts | 63 ++++++++++ 9 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 src/model-config-resolvers.ts create mode 100644 test/session-config-model.test.ts diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c58247f3..c8f11b17 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; -import type { McpConfigFormat } from './types.js'; +import type { McpConfigFormat, ModelConfigResolverName } from './types.js'; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -382,12 +382,24 @@ const capabilitiesSchema = z .refine( (src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1, 'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group' - ), + ) + .optional(), // Bounded hard, like watchingLines: every row it adds is one more row the agent // itself may be able to write. screenLines: z.number().int().min(1).max(4).optional(), + // A NAMED reader (src/model-config-resolvers.ts), never code in config. + configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(), }) .strict() + // Typos rather than configurations, refused at LOAD time like watchingLines. + .refine( + (v) => v.screenLine !== undefined || v.configResolver !== undefined, + 'modelDetect declares nothing to read' + ) + .refine( + (v) => v.screenLines === undefined || v.screenLine !== undefined, + 'screenLines has nothing to bound without a screenLine' + ) .optional(), privilegedParams: z .array( diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 30915aae..caa73a03 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1243,6 +1243,9 @@ const DEEPSEEK: CliEntry = { modelDetect: { screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, screenLines: 3, + // With the status bar's model field off (or before it paints), the route the + // session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts. + configResolver: 'deepseek-route', }, // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the // launcher's own default, `workspace-write`, which already asks. Clamping to diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 4de6936c..634dda72 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -93,6 +93,9 @@ export interface CliVariant { /** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */ export type NewlineSequence = 'line-feed' | 'esc-enter'; +/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */ +export type ModelConfigResolverName = 'deepseek-route'; + /** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; @@ -477,8 +480,15 @@ export interface CliCapabilities { * own footer format), never on a shape the agent could print in its transcript. Measured * on a live pane per CLI; absent means the CLI's screen is never read for a model and * the session shows its launch model, if any. + * + * `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the + * model the CLI's own config pins, the way that CLI resolves it for the session, for + * while the screen names none (its status line switched off, or not drawn yet). Read + * once per pane start, attach or relaunch, bounded and read-only; the screen still + * wins whenever it names a model. A NAMED reader, like a launcher profile, so the + * per-CLI behaviour stays data here and code in one module. */ - modelDetect?: { screenLine: string; screenLines?: number }; + modelDetect?: { screenLine?: string; screenLines?: number; configResolver?: ModelConfigResolverName }; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/model-config-resolvers.ts b/src/model-config-resolvers.ts new file mode 100644 index 00000000..9bdfe53b --- /dev/null +++ b/src/model-config-resolvers.ts @@ -0,0 +1,49 @@ +/** + * @fileoverview The config readers a CLI's registry entry may name for the model its + * session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives + * here, keyed by name, so no code branches on a CLI id (like the launcher profiles in + * config/cli-registry/profiles.ts). + * + * A reader answers the model the CLI's own config pins for one session, or null when + * it pins none or the answer is in any doubt. It must be read-only, bounded (no + * synchronous filesystem call, nothing that can wait on a dead mount) and must return + * the model id alone, never another config value. + * + * @module model-config-resolvers + */ + +import type { ModelConfigResolverName } from './config/cli-registry/types.js'; +import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js'; + +/** What a reader gets to know about the session. */ +export interface ModelConfigContext { + /** The session's own launch config for its CLI (its `Config`), if any. */ + config: Record | undefined; + /** The environment the session's CLI runs with (its own overrides, then the server's). */ + env: (key: string) => string | undefined; +} + +const RESOLVERS: Record Promise> = { + // dsh-TUI's route: the session's profile (else the one the launch boots, which the + // launch names from the server's own dsh home) read under the session's dsh home. + 'deepseek-route': (ctx) => + readDeepSeekRouteModel({ + profile: ctx.config?.profile, + home: effectiveDshHome(ctx.env), + serverHome: effectiveDshHome((key) => process.env[key]), + }), +}; + +/** + * The model the named reader resolves for a session, or null. + * + * @param name a `configResolver` from the registry (schema-checked at load) + * @param ctx what the reader may know about the session + */ +export async function resolveConfigModel( + name: ModelConfigResolverName, + ctx: ModelConfigContext +): Promise { + const resolver = RESOLVERS[name]; + return resolver ? resolver(ctx) : null; +} diff --git a/src/session-display-model.ts b/src/session-display-model.ts index 9eae26cb..c9e59d6c 100644 --- a/src/session-display-model.ts +++ b/src/session-display-model.ts @@ -13,7 +13,11 @@ * in-session `/model`); a CLI whose registry entry declares * `capabilities.modelDetect` has its footer read off the pane capture the idle/working * probe already takes. - * 3. **launch**: the model the session was launched with (claude's `--model` or the + * 3. **config**: the model the CLI's own config pins for this session, read by the + * reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the + * dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not + * a report from the running CLI, so any report outranks it. + * 4. **launch**: the model the session was launched with (claude's `--model` or the * app-wide default it was created with; another CLI's `Config.model`). What was * asked for, not what was reported, so it only shows when nothing reported. * @@ -130,17 +134,21 @@ export function restoredReportedModel(saved: unknown): ReportedModel | undefined * * @param input.customModelId the custom endpoint's model, when the session is pointed at one * @param input.reported the newest report from the CLI itself + * @param input.configModel the model the CLI's config pins for the session * @param input.launchModel the model the session was launched with */ export function resolveDisplayModel(input: { customModelId?: string; reported?: ReportedModel | null; + configModel?: string | null; launchModel?: string; }): DisplayModel | undefined { const custom = sanitizeModelName(input.customModelId); if (custom) return { model: custom, source: 'custom-endpoint' }; const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined; if (reported && input.reported) return { model: reported, source: input.reported.source }; + const config = sanitizeModelName(input.configModel); + if (config) return { model: config, source: 'config' }; const launch = sanitizeModelName(input.launchModel); if (launch) return { model: launch, source: 'launch' }; return undefined; diff --git a/src/session.ts b/src/session.ts index 15811f1d..f4a93fe1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -138,6 +138,8 @@ import { upsertAttachmentHistory as upsertAttachmentHistoryList, } from './session-attachment-history.js'; import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; +import { resolveConfigModel } from './model-config-resolvers.js'; +import { legacyConfigForMode } from './session-cli-registry-bridge.js'; import { launchModelFor, readScreenModel, @@ -566,6 +568,14 @@ export class Session extends EventEmitter { * restart, so an idle session keeps naming its model until the next report. */ private _reportedModel: ReportedModel | null = null; + /** + * The model the CLI's own config pins for this session (`modelDetect.configResolver`), + * read at each pane start, attach or relaunch; null when it pins none. Below any + * report from the running CLI in `displayModel`. Not persisted: the next start reads it. + */ + private _configModel: string | null = null; + /** Bumped per config read, so a read that lands after a newer one is dropped. */ + private _configModelGen = 0; /** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */ private _modelLineRe: RegExp | null | undefined = undefined; /** Resolved with the pattern above: how many rows at the foot of the screen it sees. */ @@ -1290,6 +1300,9 @@ export class Session extends EventEmitter { } finally { this._paneLifecycleOps--; this._paneStartedAt = Date.now(); + // A start, attach or relaunch is when the CLI read its config, so it is when + // the model that config pins is read here too. + this._refreshConfigModel(); } } @@ -3187,7 +3200,7 @@ export class Session extends EventEmitter { private _modelLinePattern(): RegExp | null { if (this._modelLineRe === undefined) { const detect = getCli(this.mode)?.capabilities.modelDetect; - this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null; + this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null; this._modelLineRows = detect?.screenLines ?? 1; } return this._modelLineRe; @@ -3219,24 +3232,63 @@ export class Session extends EventEmitter { return resolveDisplayModel({ customModelId: this._customModel?.modelId, reported: this._reportedModel, - // The same option bag the spawn reads its launch params from: `model` at the top - // for claude (the `--model` or app-wide default it was created with; inert for - // every other CLI, which is why it is not handed over for them), each other CLI's - // own `Config`. Where the model param lives is registry data. - launchModel: launchModelFor(this.mode, { - model: cliTakesSessionModel(this.mode) ? this._model : undefined, - openCodeConfig: this._openCodeConfig, - codexConfig: this._codexConfig, - geminiConfig: this._geminiConfig, - antigravityConfig: this._antigravityConfig, - piConfig: this._piConfig, - grokConfig: this._grokConfig, - deepSeekConfig: this._deepSeekConfig, - ompConfig: this._ompConfig, - }), + configModel: this._configModel, + launchModel: launchModelFor(this.mode, this._launchOptionBag()), }); } + /** + * The same option bag the spawn reads its launch params from: `model` at the top for + * claude (the `--model` or app-wide default it was created with; inert for every other + * CLI, which is why it is not handed over for them), each other CLI's own + * `Config`. Where a param lives is registry data (`legacyConfigForMode`). + */ + private _launchOptionBag(): Record { + return { + model: cliTakesSessionModel(this.mode) ? this._model : undefined, + openCodeConfig: this._openCodeConfig, + codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, + piConfig: this._piConfig, + grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, + }; + } + + /** + * Read the model this session's CLI config pins, with the reader its registry entry + * names (`capabilities.modelDetect.configResolver`), and announce a change. Async and + * bounded (the reader probes before it reads); a read that lands after a newer one, + * or after the session stopped, is dropped. A remote or docker session's CLI reads its + * config on another machine or in its container, so nothing local is read for it. + */ + private _refreshConfigModel(): void { + const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver; + if (!name || this._remote || this._docker) return; + const gen = ++this._configModelGen; + const overrides = this._envOverrides; + resolveConfigModel(name, { + config: legacyConfigForMode(this.mode, this._launchOptionBag()), + // The session's own env first (already clamped for a non-granted owner), then the + // server's: what the pane's CLI inherits. + env: (key) => overrides?.[key] ?? process.env[key], + }).then( + (model) => { + if (gen !== this._configModelGen || this._isStopped) return; + // Sanitized where it is published (resolveDisplayModel), like every source. + const next = model || null; + if (next === this._configModel) return; + this._configModel = next; + this.emit('displayModelChanged'); + }, + () => { + /* A reader answers null on doubt and never throws; a throw changes nothing. */ + } + ); + } + /** * Read the background-work chip off the same capture the working probe just took. * diff --git a/src/types/session.ts b/src/types/session.ts index 2e7d4415..b0f4214d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -646,9 +646,11 @@ export interface CustomModelSelection { * - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins. * - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch. * - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch. + * - `config`: what the CLI's own config pins for this session + * (`capabilities.modelDetect.configResolver`), while its screen names none. * - `launch`: what the session was launched with; nothing has reported since. */ -export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch'; +export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch'; /** The model a session runs as far as the server knows, for a session header. */ export interface DisplayModel { @@ -866,7 +868,8 @@ export interface SessionState { /** * The model this session runs, as far as the server knows it, and where that came from * (src/session-display-model.ts): the custom endpoint's model, else the newest report - * from the CLI itself (statusline or its own footer), else the launch model. Absent when + * from the CLI itself (statusline or its own footer), else the model its config pins, + * else the launch model. Absent when * none is known; a session header then shows the harness alone. Untrusted display text * (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen` * value is restored after a restart until the next report replaces it. diff --git a/test/session-config-model.test.ts b/test/session-config-model.test.ts new file mode 100644 index 00000000..78159340 --- /dev/null +++ b/test/session-config-model.test.ts @@ -0,0 +1,180 @@ +/** + * The config source of a session's `displayModel`: the model its CLI's own config pins + * (`capabilities.modelDetect.configResolver`), read at each pane start, attach or + * relaunch, ranked below any report from the running CLI. + * + * The reader is mocked here so the session's own rules are what is tested: when it + * reads, with what, and which answer wins. The reader itself (dsh-TUI's route) is + * `test/deepseek-route-config.test.ts`; one end-to-end read over a fixture dsh home + * is in `test/session-display-model.test.ts`. + * + * Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; + +const resolveConfigModel = vi.fn(); +vi.mock('../src/model-config-resolvers.js', () => ({ + resolveConfigModel: (...a: unknown[]) => resolveConfigModel(...a), +})); + +const { Session } = await import('../src/session.js'); + +type Internals = { + _withPaneLifecycle(op: () => Promise): Promise; + _isStopped: boolean; +}; + +/** A deferred answer, so a test decides when (and in which order) reads land. */ +function deferred() { + let resolve!: (v: string | null) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +function session(extra: Record = {}, mode = 'deepseek') { + return new Session({ workingDir: '/tmp', mode, ...extra } as ConstructorParameters[0]); +} +/** One pane start: what triggers the read. */ +const start = (s: InstanceType) => (s as unknown as Internals)._withPaneLifecycle(async () => {}); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + resolveConfigModel.mockReset(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a session's config model", () => { + it("is read at each pane start, with the session's own config and env", async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + // The server has a dsh home of its own; the session's override is the one its CLI gets. + const before = process.env.DSH_HOME; + process.env.DSH_HOME = '/srv/server-dsh'; + onTestFinished(() => { + if (before === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = before; + }); + const s = session({ deepSeekConfig: { profile: 'dsh-tui' }, envOverrides: { DSH_HOME: '/srv/dsh-copy' } }); + const changed = vi.fn(); + s.on('displayModelChanged', changed); + expect(resolveConfigModel).not.toHaveBeenCalled(); + await start(s); + await flush(); + expect(resolveConfigModel).toHaveBeenCalledTimes(1); + const [name, ctx] = resolveConfigModel.mock.calls[0]; + expect(name).toBe('deepseek-route'); + expect(ctx.config).toEqual({ profile: 'dsh-tui' }); + expect(ctx.env('DSH_HOME')).toBe('/srv/dsh-copy'); + expect(s.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' }); + expect(changed).toHaveBeenCalledTimes(1); + // A relaunch reads again; the same answer announces nothing new. + await start(s); + await flush(); + expect(resolveConfigModel).toHaveBeenCalledTimes(2); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('without a session DSH_HOME the server one is what the CLI inherits', async () => { + resolveConfigModel.mockResolvedValue(null); + const before = process.env.DSH_HOME; + process.env.DSH_HOME = '/srv/server-dsh'; + try { + const s = session(); + await start(s); + expect(resolveConfigModel.mock.calls[0][1].env('DSH_HOME')).toBe('/srv/server-dsh'); + } finally { + if (before === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = before; + } + }); + + it('the screen wins over the config whenever it names a model, in either order', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + const s = session(); + await start(s); + await flush(); + s.noteReportedModel('screen', 'deepseek-v4-flash'); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + await start(s); + await flush(); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + }); + + it('a screen value restored after a restart outranks a fresh config read', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + const s = session({ displayModel: { model: 'deepseek-v4-flash', source: 'screen' } }); + await start(s); + await flush(); + expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + }); + + it('the config outranks the launch model, and a config that pins nothing leaves it', async () => { + resolveConfigModel.mockResolvedValue('qwen-from-config'); + const codex = session({ codexConfig: { model: 'gpt-5.5' } }, 'codex'); + await start(codex); + await flush(); + // codex declares no config reader: nothing is read, the launch model stays. + expect(resolveConfigModel).not.toHaveBeenCalled(); + expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' }); + resolveConfigModel.mockResolvedValue(null); + const s = session(); + await start(s); + await flush(); + expect(s.toState().displayModel).toBeUndefined(); + }); + + it('a read that lands after a newer one is dropped, and so is one after the session stopped', async () => { + const first = deferred(); + const second = deferred(); + resolveConfigModel.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const s = session(); + await start(s); + await start(s); + second.resolve('newer'); + await flush(); + first.resolve('older'); + await flush(); + expect(s.toState().displayModel?.model).toBe('newer'); + + const late = deferred(); + resolveConfigModel.mockReturnValueOnce(late.promise); + const stopped = session(); + await start(stopped); + (stopped as unknown as Internals)._isStopped = true; + late.resolve('too late'); + await flush(); + expect(stopped.toState().displayModel).toBeUndefined(); + }); + + it('a remote or docker session reads nothing locally', async () => { + resolveConfigModel.mockResolvedValue('qwen3.8-27b'); + await start(session({ remote: { hostId: 'h', label: 'h', host: 'h', username: 'u', remotePath: '/w' } })); + await start( + session({ + docker: { + hostId: 'd', + label: 'd', + engine: 'docker', + image: 'i', + containerName: 'c', + hostWorkspacePath: '/w', + containerWorkdir: '/w', + }, + }) + ); + await flush(); + expect(resolveConfigModel).not.toHaveBeenCalled(); + }); + + it('a config model is untrusted text: control characters dropped, length capped', async () => { + resolveConfigModel.mockResolvedValue(`\x1b[31mqwen\x1b[0m${'x'.repeat(200)}`); + const s = session(); + await start(s); + await flush(); + const dm = s.toState().displayModel!; + expect(dm.source).toBe('config'); + expect(dm.model.startsWith('qwenx')).toBe(true); + expect(dm.model.length).toBe(64); + }); +}); diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 160e5ec8..3421c189 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -7,6 +7,9 @@ * live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex * 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; import { getCli } from '../src/config/cli-registry/index.js'; @@ -91,6 +94,21 @@ describe('the registry patterns', () => { expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false); }); + it('dsh also names a config reader for while its screen names no model', () => { + expect(detectOf('deepseek').configResolver).toBe('deepseek-route'); + expect(detectOf('codex').configResolver).toBeUndefined(); + }); + + it('the schema takes a known config reader alone, and refuses an empty or unknown one', () => { + const codex = getCli('codex')!; + const withDetect = (modelDetect: unknown) => + CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success; + expect(withDetect({ configResolver: 'deepseek-route' })).toBe(true); + expect(withDetect({ configResolver: 'read-anything' })).toBe(false); + expect(withDetect({})).toBe(false); + expect(withDetect({ configResolver: 'deepseek-route', screenLines: 2 })).toBe(false); + }); + it('countCaptureGroups counts named groups and ignores non-capturing ones', () => { expect(countCaptureGroups('a(?:b)(?c)')).toBe(1); expect(countCaptureGroups('(a)(b)')).toBe(2); @@ -181,6 +199,16 @@ describe('resolveDisplayModel', () => { expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' }); }); + it("the config ranks below the CLI's own report and above the launch model", () => { + expect(resolveDisplayModel({ reported, configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual(reported); + expect(resolveDisplayModel({ configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual({ + model: 'qwen3.8-27b', + source: 'config', + }); + expect(resolveDisplayModel({ customModelId: 'm', configModel: 'qwen3.8-27b' })?.source).toBe('custom-endpoint'); + expect(resolveDisplayModel({ configModel: null, launchModel: 'opus' })?.source).toBe('launch'); + }); + it('knows nothing when nothing is known: no placeholder', () => { expect(resolveDisplayModel({})).toBeUndefined(); expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined(); @@ -195,6 +223,7 @@ describe('restoredReportedModel', () => { }); expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline'); expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined(); + expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'config' })).toBeUndefined(); expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined(); expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined(); expect(restoredReportedModel('screen')).toBeUndefined(); @@ -326,3 +355,37 @@ describe('a session', () => { expect(claude.toState().displayModel).toBeUndefined(); }); }); + +describe('a dsh session over a fixture dsh home (end to end, no mocks)', () => { + it('names the route its profile pins until the screen names one, then the screen', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-e2e-')); + try { + const home = join(root, 'dsh'); + mkdirSync(join(home, 'profiles', 'dsh-tui'), { recursive: true }); + writeFileSync( + join(home, 'profiles', 'dsh-tui', 'package.json'), + JSON.stringify({ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui'] } } }) + ); + writeFileSync( + join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'), + '- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n' + ); + const session = new Session({ + workingDir: '/tmp', + mode: 'deepseek', + deepSeekConfig: { profile: 'dsh-tui' }, + envOverrides: { DSH_HOME: home }, + } as ConstructorParameters[0]); + await (session as unknown as { _withPaneLifecycle(op: () => Promise): Promise })._withPaneLifecycle( + async () => {} + ); + await vi.waitFor(() => + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' }) + ); + session.noteReportedModel('screen', 'deepseek-v4-flash'); + expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 21ae48a5c8fcb81d2acbb61a48765b2233ff8795 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 19:54:17 +0200 Subject: [PATCH 71/90] fix(deepseek): the dsh footer field after a switched-off model is not the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh-TUI draws the model as its status line's first field only while the status bar's model field is on (the default). Switched off, the first field is the reasoning effort (` medium · th-scratch`), else the mode, else the cwd's basename, and the footer pattern read that as the model, which would also outrank the route config added in the previous commit. The captured field must now carry a digit, as a model id does (a version) and an effort word, a mode name or most folder names do not. A model id without one (`deepseek-chat`) is not read from the screen and the session falls back to its route config: silent, never wrong. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config/cli-registry/stock.ts | 8 +++++++- test/session-display-model.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index caa73a03..6a0a9c3a 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1240,8 +1240,14 @@ const DEEPSEEK: CliEntry = { // and, on the row right under its bottom border, ` qwen3.8-27b · medium · `. // The border anchors it: nothing the agent writes can sit below the composer, and a // suggestion popup there starts with `/` or `+`, never a model id. + // ⚠ The first field is the model only while the status bar's model field is on (the + // default). Switched off, the first field is the next one: the reasoning effort + // (` medium · th-scratch`), else the mode, else the cwd's basename. So the field must + // carry a digit, which a model id does (a version) and an effort word, a mode name or + // most folder names do not. A model id without one (`deepseek-chat`) is not read, + // and the session falls back to its route config: silent, never wrong. modelDetect: { - screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, + screenLine: String.raw`╰─+╯\n ?((?=[\w.:/@+-]*\d)[A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, screenLines: 3, // With the status bar's model field off (or before it paints), the route the // session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts. diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 3421c189..13b4613c 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -127,6 +127,16 @@ describe('readScreenModel', () => { ); }); + it("never reads the field after a switched-off model as the model (dsh's effort, mode, cwd)", () => { + // dsh-TUI with `statusBar.model: false`: the effort word comes first. + expect(readScreenModel(dshPane(' medium · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); + expect(readScreenModel(dshPane(' xhigh · plan · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); + expect(readScreenModel(dshPane(' th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); + // A model id carries a version digit; one that does not is left to the config. + expect(readScreenModel(dshPane(' deepseek-v4-flash · max · th-scratch'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash'); + expect(readScreenModel(dshPane(' deepseek-chat · max'), DSH, DSH_ROWS)).toBeUndefined(); + }); + it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => { expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra'); expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol'); From 3104e9945bfec481b4633f04345eee079a306167 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 20:18:38 +0200 Subject: [PATCH 72/90] feat(tiles): a header names a model read from the CLI's config as such MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session header's tooltip (tile and split) says where a model the CLI did not report came from; the new `config` source reads "DeepSeek · qwen3.8-27b (from config)", with the zh-CN pattern "(来自配置)" (the harness and model names pass through). The header's model text itself is unchanged. Covered in the chrome tooltip test and the i18n harvester's exercise. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 9 ++++++--- src/web/public/i18n.js | 1 + test/tile-grid-chrome.test.ts | 4 ++++ test/tile-grid-i18n.test.ts | 5 +++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index cafb1a7a..42dabac0 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2151,8 +2151,9 @@ const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/; * are dropped and the length capped here too, and callers render it with * textContent. The tooltip says where a model that is not the CLI's own report * came from, so it never claims more than the server knows: one the session - * was launched with may have been switched since, and a custom endpoint's - * model is the endpoint's, whatever the CLI calls it. + * was launched with may have been switched since, one read from the CLI's + * config is what it is configured to run, and a custom endpoint's model is the + * endpoint's, whatever the CLI calls it. * * @param {object} session - a session from app.sessions * @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog @@ -2177,7 +2178,9 @@ function describeSessionHarness(session, catalog) { ? ' (set at launch)' : source === 'custom-endpoint' ? ' (custom endpoint)' - : ''; + : source === 'config' + ? ' (from config)' + : ''; const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier; return { id, label, model, title }; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 80e02d3e..e103575e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1063,6 +1063,7 @@ // and model names pass through untranslated. [/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`], [/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`], + [/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`], // The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry // CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP"). [/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`], diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 7a3f2dda..9f989932 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -182,6 +182,10 @@ describe('the harness logo and the model', () => { app._renderTileChrome(); expect(logoOf('s-b').title).toBe('Claude Code \u00B7 haiku (set at launch)'); expect(logoOf('s-c').title).toBe('Claude Code \u00B7 qwen3.8-27b (custom endpoint)'); + app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'config' }; + app._renderTileChrome(); + expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b (from config)'); + expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b'); expect(modelNameOf('s-b').textContent).toBe('haiku'); }); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 3718e7f0..edd024c0 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -153,6 +153,10 @@ async function exercise() { displayModel: { model: 'qwen3.8-27b', source: 'custom-endpoint' }, }); app.sessions.get('s-6').displayModel = { model: 'Haiku 4.5', source: 'statusline' }; + Object.assign(app.sessions.get('s-4'), { + mode: 'deepseek', + displayModel: { model: 'qwen3.8-27b', source: 'config' }, + }); let pill = 'idle'; app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } }); app._mobileOverviewStampText = () => '3m'; @@ -324,6 +328,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'DeepSeek \u00B7 qwen3.8-27b', 'Claude Code \u00B7 haiku (set at launch)', 'Codex \u00B7 qwen3.8-27b (custom endpoint)', + 'DeepSeek \u00B7 qwen3.8-27b (from config)', 'Claude Code \u00B7 Haiku 4.5', 'Claude Code', ]) From 2e25bfa9e04956af7da4ed62a4ee3e4c17abd4ba Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 20:39:30 +0200 Subject: [PATCH 73/90] docs: the dsh route config as a displayModel source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-reference: the `config` source in the displayModel table, read again at every pane start, attach and relaunch rather than restored. - cli-registry: `modelDetect.configResolver` (a named, read-only, bounded reader), the stock `deepseek-route` reader and its rules, and why the dsh footer pattern needs a digit. - deepseek-integration §4: Codeman reads the route for display only, the way dsh-TUI resolves it; the catalog check it cannot see. - architecture-invariants (tile grid), tile-grid-plan "as built" (owner feedback 1), the wiki's model row, CLAUDE.md's source order. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/api-reference.md | 4 +++- docs/architecture-invariants.md | 2 +- docs/cli-registry.md | 4 +++- docs/deepseek-integration.md | 12 ++++++++++++ docs/tile-grid-plan.md | 4 +++- docs/wiki/Tile-Grid.md | 2 +- 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eba03b9d..cd128863 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/api-reference.md b/docs/api-reference.md index f1bc6ed5..4b195b34 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -500,13 +500,15 @@ session runs as far as the server knows it, for the web UI's session headers: | `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | | `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | | `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | +| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. | | `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | Between `statusline` and `screen` the newest report wins. The field is absent when no model is known (a shell, a CLI that reports none and was launched without one). `model` is display text from a pane or a CLI report: control characters are stripped and it is at most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is -persisted and restored after a server restart until the next report replaces it. +persisted and restored after a server restart until the next report replaces it; a +`config` value is read again at every pane start, attach and relaunch instead. ## Approvals Inbox diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index db5df2eb..7eaebb1f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -815,7 +815,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. -⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. +⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index ac6844ae..22e5919a 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -63,7 +63,9 @@ Five capability fields carry a regular expression an override file can set: `dis `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. -`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. +`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the reasoning effort (` medium · `), so the dsh pattern requires a digit in the field (a model id's version), which an effort word, a mode name or most folder names lack. + +`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles//cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset. `watchingLine` reads a different row of the same screen. A CLI draws it while work the agent itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 6b4fd61c..45b75bb7 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -201,6 +201,18 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml` plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the profile. That is also how you point dsh at a local or third-party provider. +Codeman does READ the route, for display only: a session header names the model +the TUI's status line draws, and while it draws none (the status bar's model +field switched off, or not painted yet) the model the session's route config +pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of +`profiles//cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying +`config` for the `dsh-tui` row, and only when it names BOTH `provider` and +`model`; a half-pinned route is dropped whole by the TUI and shows nothing here. +`settings.yaml`'s `agent-default-model` is the headless default and is not read. +The reader never writes, follows no symlink out of the dsh home, and returns the +model id alone. The TUI can still reject a pinned route against its provider's +model catalog at startup; the status line, when on, then shows what it chose. + **Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides` (so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL` all flow through). Provider keys with *other* names are deliberately not: a dsh diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 9cb3348e..9984d103 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -52,7 +52,9 @@ or settled a question the spec left open. The invariants as built are in `● [logo] name · model ……… ⋯ ⤢ ×`. The logo is PR #532's `run-mode-dot ` slot (the id is data), the model the session's `displayModel` (custom endpoint, else what the CLI itself reports: claude's statusline, a footer read with `capabilities.modelDetect` - for dsh and codex; else the launch model; else nothing, the logo alone). Both split + for dsh and codex; else what the CLI's config pins, dsh-TUI's route (owner feedback 1: + a dsh session with its status bar's model field off shows `qwen3.8-27b (from config)`); + else the launch model; else nothing, the logo alone). Both split panes carry the same strip: Pane B's header, and Pane A's while the split is open. Pane B's close is a tile button (26px). - **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index ade5c66e..a8d810c1 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -46,7 +46,7 @@ Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×` | `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. | | logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. | | name | Double-click to rename the session. | -| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model it was started with. Nothing when unknown. | +| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. | | `⋯` | The session menu: options, open in a new window, close the session. | | `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. | | `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. | From 9209922ea4b19f7edd7ab0485db5f42e64191e0d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 21:16:53 +0200 Subject: [PATCH 74/90] fix(deepseek): reject the dsh footer's non-model words instead of requiring a digit The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`, `deepseek-reasoner` carry no digit), so a session on the official route with the model field on showed the logo alone (its bundle row pins a provider alone, so the config had nothing either). It also still misread a folder name with a digit when every field before it was off. Now the captured field is rejected when it is what the field can be when it is NOT the model, and read otherwise: - capabilities.modelDetect.rejectWords (registry data, single tokens, compared ignoring case; the schema bounds them and requires a screenLine). dsh lists every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`, CJK) can never be one captured field. - In the shared screen reader, for every CLI: a field equal to the session's own working-directory basename is the folder, never the model. Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are read; every effort id, `default`, `plan mode`, and the folder name first (with and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`. Known gaps, all off by default, are named in stock.ts: a custom mode id drawn raw, a git branch or a one-word session title first, and the non-compact footer layout (nothing read there; the route config applies). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- docs/cli-registry.md | 2 +- src/config/cli-registry/schema.ts | 6 +++ src/config/cli-registry/stock.ts | 20 ++++++--- src/config/cli-registry/types.ts | 13 +++++- src/session-display-model.ts | 24 ++++++++++- src/session.ts | 11 ++++- test/session-display-model.test.ts | 69 ++++++++++++++++++++++++++---- 8 files changed, 127 insertions(+), 20 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7eaebb1f..9d047b0b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -815,7 +815,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. -⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. +⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 22e5919a..c24fd819 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -63,7 +63,7 @@ Five capability fields carry a regular expression an override file can set: `dis `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. -`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the reasoning effort (` medium · `), so the dsh pattern requires a digit in the field (a model id's version), which an effort word, a mode name or most folder names lack. +`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · `), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read. `modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles//cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c8f11b17..f28a436e 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -387,6 +387,8 @@ const capabilitiesSchema = z // Bounded hard, like watchingLines: every row it adds is one more row the agent // itself may be able to write. screenLines: z.number().int().min(1).max(4).optional(), + // Single tokens, bounded: each is compared against one captured field. + rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(), // A NAMED reader (src/model-config-resolvers.ts), never code in config. configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(), }) @@ -400,6 +402,10 @@ const capabilitiesSchema = z (v) => v.screenLines === undefined || v.screenLine !== undefined, 'screenLines has nothing to bound without a screenLine' ) + .refine( + (v) => v.rejectWords === undefined || v.screenLine !== undefined, + 'rejectWords has nothing to filter without a screenLine' + ) .optional(), privilegedParams: z .array( diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 6a0a9c3a..38bfe155 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1241,14 +1241,22 @@ const DEEPSEEK: CliEntry = { // The border anchors it: nothing the agent writes can sit below the composer, and a // suggestion popup there starts with `/` or `+`, never a model id. // ⚠ The first field is the model only while the status bar's model field is on (the - // default). Switched off, the first field is the next one: the reasoning effort - // (` medium · th-scratch`), else the mode, else the cwd's basename. So the field must - // carry a digit, which a model id does (a version) and an effort word, a mode name or - // most folder names do not. A model id without one (`deepseek-chat`) is not read, - // and the session falls back to its route config: silent, never wrong. + // default). Switched off, the first field is the next one (StatusLine.js): tokens per + // second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match, + // then the reasoning effort (` medium · th-config`, measured live), then the session + // mode, then the cwd's basename. So `rejectWords` lists what those can be, from the + // dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's + // THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode + // ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token, + // and a field equal to the session's folder name is refused by the shared reader. + // Known gaps, all off by default: a custom mode id drawn raw, a git branch or a + // one-word session title as the first field; and the non-compact layout, whose + // left/right justification never ends a field with ` · `, so nothing is read there + // and the session shows its route config. modelDetect: { - screenLine: String.raw`╰─+╯\n ?((?=[\w.:/@+-]*\d)[A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, + screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, screenLines: 3, + rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'], // With the status bar's model field off (or before it paints), the route the // session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts. configResolver: 'deepseek-route', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 634dda72..d17fb919 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -488,7 +488,18 @@ export interface CliCapabilities { * wins whenever it names a model. A NAMED reader, like a launcher profile, so the * per-CLI behaviour stays data here and code in one module. */ - modelDetect?: { screenLine?: string; screenLines?: number; configResolver?: ModelConfigResolverName }; + modelDetect?: { + screenLine?: string; + screenLines?: number; + /** + * Words the `screenLine` field can show when it is NOT the model (a footer whose model + * field is switched off shows the next field there), compared lower-cased. A field + * equal to the session's own working-directory basename is never the model either, + * for every CLI; that rule is the shared reader's, not data. + */ + rejectWords?: string[]; + configResolver?: ModelConfigResolverName; + }; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/session-display-model.ts b/src/session-display-model.ts index c9e59d6c..c79d8439 100644 --- a/src/session-display-model.ts +++ b/src/session-display-model.ts @@ -66,6 +66,14 @@ export function sanitizeModelName(raw: unknown): string | undefined { return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd(); } +/** What a footer field can show that is never the model. */ +export interface ScreenModelRejects { + /** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */ + rejectWords?: readonly string[]; + /** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */ + cwdBasename?: string; +} + /** * The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern. * @@ -73,15 +81,23 @@ export function sanitizeModelName(raw: unknown): string | undefined { * can anchor on the row above), which keeps the search below the transcript: the * pattern itself must still anchor on chrome only that CLI draws. * + * A footer whose model field is switched off shows its NEXT field where the model + * was, so the captured field is not taken when it is one of the CLI's declared + * non-model words (an effort level, a mode) or the session's own folder name, which a + * footer field equal to is the folder, never the model, whatever the CLI. Anything + * else the pattern captures is read as the model. + * * @param paneText a plain `capture-pane -p` frame, or null when it could not be read * @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model * @param tailRows how many non-blank rows from the bottom the pattern sees + * @param rejects fields that are never the model (see {@link ScreenModelRejects}) * @returns the model, or undefined when the frame shows none */ export function readScreenModel( paneText: string | null | undefined, pattern: RegExp, - tailRows: number = 1 + tailRows: number = 1, + rejects: ScreenModelRejects = {} ): string | undefined { if (!paneText) return undefined; const rows = stripAnsi(paneText) @@ -93,7 +109,11 @@ export function readScreenModel( // stale lastIndex would make the same frame match every other call. pattern.lastIndex = 0; const match = pattern.exec(window); - return match ? sanitizeModelName(match[1]) : undefined; + if (!match) return undefined; + const field = match[1] ?? ''; + if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined; + if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined; + return sanitizeModelName(field); } /** diff --git a/src/session.ts b/src/session.ts index f4a93fe1..49d42903 100644 --- a/src/session.ts +++ b/src/session.ts @@ -29,6 +29,7 @@ */ import { EventEmitter } from 'node:events'; +import { basename } from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; @@ -580,6 +581,8 @@ export class Session extends EventEmitter { private _modelLineRe: RegExp | null | undefined = undefined; /** Resolved with the pattern above: how many rows at the foot of the screen it sees. */ private _modelLineRows = 1; + /** Resolved with the pattern above: the fields it shows that are never the model. */ + private _modelRejectWords: readonly string[] = []; private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read @@ -3188,7 +3191,12 @@ export class Session extends EventEmitter { if (paneText === null) return; const pattern = this._modelLinePattern(); if (!pattern) return; - const model = readScreenModel(paneText, pattern, this._modelLineRows); + const model = readScreenModel(paneText, pattern, this._modelLineRows, { + rejectWords: this._modelRejectWords, + // A footer field equal to the folder this session runs in is the folder, never the + // model: the generic half of the rule, for every CLI. + cwdBasename: basename(this.workingDir), + }); if (model) this.noteReportedModel('screen', model); } @@ -3202,6 +3210,7 @@ export class Session extends EventEmitter { const detect = getCli(this.mode)?.capabilities.modelDetect; this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null; this._modelLineRows = detect?.screenLines ?? 1; + this._modelRejectWords = detect?.rejectWords ?? []; } return this._modelLineRe; } diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 13b4613c..d40a8b09 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -28,6 +28,7 @@ import { IDLE_SILENCE_MS } from '../src/session-activity.js'; const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!; const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!; const DSH_ROWS = detectOf('deepseek').screenLines; +const DSH_REJECT = detectOf('deepseek').rejectWords; const CODEX = compileVersionRegex(detectOf('codex').screenLine)!; const CODEX_ROWS = detectOf('codex').screenLines; @@ -94,6 +95,25 @@ describe('the registry patterns', () => { expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false); }); + it("dsh declares what its footer's first field can be when it is not the model", () => { + // Every effort id dsh's adapters offer, and the shipped mode ids. + expect(DSH_REJECT).toEqual( + expect.arrayContaining(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full']) + ); + expect(detectOf('codex').rejectWords).toBeUndefined(); + }); + + it('the schema takes single-token reject words with a screenLine only, and bounds them', () => { + const codex = getCli('codex')!; + const withDetect = (modelDetect: unknown) => + CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success; + const screenLine = '^ {2}([a-z]+) · '; + expect(withDetect({ screenLine, rejectWords: ['medium'] })).toBe(true); + expect(withDetect({ configResolver: 'deepseek-route', rejectWords: ['medium'] })).toBe(false); + expect(withDetect({ screenLine, rejectWords: ['two words'] })).toBe(false); + expect(withDetect({ screenLine, rejectWords: Array.from({ length: 33 }, (_, i) => `w${i}`) })).toBe(false); + }); + it('dsh also names a config reader for while its screen names no model', () => { expect(detectOf('deepseek').configResolver).toBe('deepseek-route'); expect(detectOf('codex').configResolver).toBeUndefined(); @@ -127,14 +147,33 @@ describe('readScreenModel', () => { ); }); - it("never reads the field after a switched-off model as the model (dsh's effort, mode, cwd)", () => { - // dsh-TUI with `statusBar.model: false`: the effort word comes first. - expect(readScreenModel(dshPane(' medium · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - expect(readScreenModel(dshPane(' xhigh · plan · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - expect(readScreenModel(dshPane(' th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - // A model id carries a version digit; one that does not is left to the config. - expect(readScreenModel(dshPane(' deepseek-v4-flash · max · th-scratch'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash'); - expect(readScreenModel(dshPane(' deepseek-chat · max'), DSH, DSH_ROWS)).toBeUndefined(); + it("never reads the field after a switched-off model as the model (dsh's effort, mode, folder)", () => { + // dsh-TUI with `statusBar.model: false` (live capture: ` medium · th-config`): the + // effort id comes first, then the mode, then the folder name. + const at = (cwdBasename: string) => ({ rejectWords: DSH_REJECT, cwdBasename }); + for (const effort of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) { + expect(readScreenModel(dshPane(` ${effort} · x`), DSH, DSH_ROWS, at('x')), effort).toBeUndefined(); + } + expect(readScreenModel(dshPane(' default · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // The words compare ignoring case (the banner capitalizes effort; the footer does not). + expect(readScreenModel(dshPane(' Medium · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // A mode's drawn label is two words: never one field the pattern takes. + expect(readScreenModel(dshPane(' plan mode · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // The session's own folder name first, with and without a digit. + expect(readScreenModel(dshPane(' th-config'), DSH, DSH_ROWS, at('th-config'))).toBeUndefined(); + expect(readScreenModel(dshPane(' project2 · main'), DSH, DSH_ROWS, at('project2'))).toBeUndefined(); + // A field that is none of those IS read: the same `project2` in another folder. + expect(readScreenModel(dshPane(' project2'), DSH, DSH_ROWS, at('elsewhere'))).toBe('project2'); + }); + + it('reads the official DeepSeek ids, which carry no digit, with the model field on', () => { + const at = { rejectWords: DSH_REJECT, cwdBasename: 'th-config' }; + expect(readScreenModel(dshPane(' deepseek-chat · max · th-config'), DSH, DSH_ROWS, at)).toBe('deepseek-chat'); + expect(readScreenModel(dshPane(' deepseek-reasoner · high · th-config'), DSH, DSH_ROWS, at)).toBe( + 'deepseek-reasoner' + ); + // The live qwen footer still reads. + expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch'), DSH, DSH_ROWS, at)).toBe('qwen3.8-27b'); }); it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => { @@ -313,6 +352,20 @@ describe('a session', () => { expect(changed).toHaveBeenCalledTimes(2); }); + it("a footer field equal to the session's folder is not its model; the official ids are", () => { + vi.useFakeTimers(); + let screen = dshPane(' th-config'); + const session = withFakePane('deepseek', () => screen, { workingDir: '/w/th-config' }); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + screen = dshPane(' medium · th-config'); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + screen = dshPane(' deepseek-chat · max · th-config'); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'deepseek-chat', source: 'screen' }); + }); + it('keeps the last model when the footer cannot be read', () => { vi.useFakeTimers(); let screen: string | null = CODEX_LIVE; From 2b20288ca0c669b3eaf797d3eb4d41d04fc5ede5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 23:33:16 +0200 Subject: [PATCH 75/90] feat(tiles): drag a tile by its header to move it Owner request: "give me the option to move the tiles around". A tile's header (its free area, not the buttons or the rename input) is now a native drag handle: dropped on another tile the two trade places, dropped on an empty slot it moves there (the last cell; the tiles after it close up). Escape or a drop anywhere else is the browser's own cancel and moves nothing. Every move goes through one path, _reorderTiles: the header drag, a tab of a tiled session dropped on a tile or a slot, and (next commit) the Move Tile chords. Nothing is remounted, reconnected or reloaded. Divider sizes belong to the cells, so a moved tile takes its new cell's size: each tile whose cell size changed fits once (one PTY resize, #464) and every other tile is left alone, in place of the debounced refit of every tile the swap used to schedule. The drag reuses the tab drop targets (capture phase, stopped before xterm), carries a type of its own and never text, and is not draggedTabId, so neither a text field nor the tab strip takes it. Moving is off while a tile is zoomed (draggable off, and a tab drag of a tiled session onto the zoomed tile is refused too) and with a single tile. The handle shows a grab cursor and says it drags in its tooltip (zh-CN included); the dragged tile is dimmed and the target highlighted. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/i18n.js | 21 +- src/web/public/styles.css | 20 +- src/web/public/tile-grid.js | 197 +++++++++++++--- test/tile-grid-chrome.test.ts | 7 +- test/tile-grid-i18n.test.ts | 15 +- test/tile-grid-move.test.ts | 407 ++++++++++++++++++++++++++++++++++ 6 files changed, 628 insertions(+), 39 deletions(-) create mode 100644 test/tile-grid-move.test.ts diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e103575e..3b794c9e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -133,6 +133,8 @@ 'Restore the grid': '恢复平铺网格', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', 'Drop a tab here': '将标签页拖放到此处', + // A tile header's tooltip while tiles can move (with the state above it: a pattern below). + 'Drag to move the tile': '拖动可移动窗格', 'Resize tile columns': '调整窗格列宽', 'Resize tile rows': '调整窗格行高', Attach: '附加', @@ -1012,6 +1014,17 @@ return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? '')); } + // The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle). + const TILE_STATE_ZH = { + 'needs you': '需要你', + error: '错误', + waiting: '等待中', + working: '工作中', + idle: '空闲', + done: '已完成', + exited: '已退出', + }; + function translateDynamic(source) { const patterns = [ [/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`], @@ -1087,8 +1100,14 @@ // state strings on other surfaces (see mobile-overview.js). [ /^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/, + (_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`, + ], + // The same while tiles can move, with the drag hint on a second line. + // Anchored on the hint, so a bare state word is safe here. + [ + /^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/, (_m, state, duration) => - `${{ 'needs you': '需要你', error: '错误', waiting: '等待中', working: '工作中', idle: '空闲', done: '已完成', exited: '已退出' }[state]} ${duration}`, + `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`, ], ]; for (const [pattern, replacement] of patterns) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 9b5560cb..f54b3343 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19671,7 +19671,8 @@ body.tile-grid-resizing--row * { opacity: 0.6; } -/* A tab dragged over a tile (replace it) or an empty slot (add it). */ +/* A tab dragged over a tile (replace it) or an empty slot (add it), or a tile + dragged there by its header (swap, move). */ .tile.tile--drop-target, .tile-slot.tile--drop-target { border-color: var(--accent, #4a9eff); @@ -19679,6 +19680,23 @@ body.tile-grid-resizing--row * { opacity: 1; } +/* The header's free area moves its tile (tile-grid.js _installTileMoveDrag). + `draggable` is off while moving is (a zoomed grid, a single tile, a rename), + and the buttons keep their own pointer. */ +.tile-header[draggable='true'] { + cursor: grab; +} + +.tile-header[draggable='true']:active { + cursor: grabbing; +} + +/* The tile being dragged by its header. Opacity only: nothing that moves + layout, which would resize its PTY. */ +.tile.tile--dragging { + opacity: 0.45; +} + /* Zoom (tmux-style): one tile fills the grid; the others stay connected but hidden, so they measure nothing and send no resize. */ .tile-grid.tile-grid--zoomed .tile:not(.tile--zoomed) { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 95641eb9..6772cb94 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -253,8 +253,9 @@ Object.assign(CodemanApp.prototype, { // Remembered (closed) for one-click return, or forgotten. if (keepStored) this._persistTileGrid({ open: false }); else this._forgetStoredTileGrid(); - // A divider drag in progress ends with the grid. + // A divider drag or a header drag in progress ends with the grid. this._tileDividerDragTeardown?.(); + this._endTileMoveDrag(); // Closed BEFORE the tiles go: each destroy() updates the header's connection // state, which must read the main terminal again, not half-destroyed tiles. grid.open = false; @@ -681,8 +682,10 @@ Object.assign(CodemanApp.prototype, { } const wasFocused = grid.focusedId === sessionId; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); - // A divider drag in progress was measured against this tile. + // A divider drag in progress was measured against this tile; a header + // drag of this tile has nothing left to drop. this._tileDividerDragTeardown?.(); + if (this._draggedTileId === sessionId) this._endTileMoveDrag(); // The zoomed tile leaving restores the grid (an automatic zoom moves to // the neighbour with focus, below). if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null; @@ -748,20 +751,26 @@ Object.assign(CodemanApp.prototype, { /** * Makes `el` a drop target for a session tab dragged from the strip (the - * strip's own drag sets `draggedTabId`). Capture phase, with the event - * stopped: the drag carries the session id as text, and xterm's helper - * textarea would otherwise accept that drop and type the id into a PTY. Any - * other drag (a file) is left alone. + * strip's own drag sets `draggedTabId`) and for a tile dragged by its header + * (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event + * stopped: a tab drag carries the session id as text, and xterm's helper + * textarea would otherwise accept that drop and type the id into a PTY. Over + * the dragged session's own tile the drag is held there too, but refused + * (`dropEffect: 'none'`, so no drop follows). Any other drag (a file) is + * left alone. */ _acceptTabDrops(el, onDrop) { + const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); el.addEventListener( 'dragover', (e) => { - if (!this.draggedTabId || !this._tileGrid?.open) return; + const id = dragged(); + if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; - el.classList.add('tile--drop-target'); + const own = id === el.dataset.sessionId; + if (e.dataTransfer) e.dataTransfer.dropEffect = own ? 'none' : 'move'; + if (!own) el.classList.add('tile--drop-target'); }, true ); @@ -772,31 +781,76 @@ Object.assign(CodemanApp.prototype, { 'drop', (e) => { el.classList.remove('tile--drop-target'); - if (!this.draggedTabId || !this._tileGrid?.open) return; + const id = dragged(); + if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - onDrop(this.draggedTabId); + onDrop(id); }, true ); }, + /** Whether tiles can be moved now: more than one, and none zoomed (moving is off while one is). */ + _tilesMovable() { + const grid = this._tileGrid; + return !!grid?.open && !grid.zoomedId && grid.ids.length > 1; + }, + /** - * A tab dropped on a tile: a session not yet tiled REPLACES that tile (same - * place; the replaced session keeps running); one already tiled swaps places - * with it. Either way the dropped session takes focus (a human selection). + * Puts the tiles in a new reading order (the same sessions): the one path + * every move takes, a header drag, a tab dragged onto a tile or a slot, and + * the Move Tile chords. Nothing is remounted, reconnected or reloaded, and + * no session joins or leaves. Divider sizes belong to the cells, so a moved + * tile takes its new cell's size: each tile whose cell size changed fits + * once (its xterm and one PTY resize together, #464), every other tile is + * left alone. Refused while a tile is zoomed (moving is off then). + * + * @param {string[]} ids - the new order + * @returns {boolean} false when refused, true otherwise (also when nothing moved) + */ + _reorderTiles(ids) { + const grid = this._tileGrid; + if (!grid?.open || grid.zoomedId) return false; + if (ids.length !== grid.ids.length || ids.some((id) => !grid.tiles.has(id))) return false; + if (ids.every((id, k) => grid.ids[k] === id)) return true; + // A divider drag in progress measured the tiles at their old places. + this._tileDividerDragTeardown?.(); + const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`; + const before = new Map(grid.ids.map((id, k) => [id, cell(k)])); + grid.ids = ids.slice(); + this._applyTileLayout(); + // Synchronous: the fit's measurement forces the new placement's layout. + grid.ids.forEach((id, k) => { + if (before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit(); + }); + return true; + }, + + /** Two tiles trade places (_reorderTiles). */ + _swapTiles(a, b) { + const ids = this._tileGrid.ids.slice(); + const i = ids.indexOf(a); + const j = ids.indexOf(b); + if (i === -1 || j === -1) return false; + ids[i] = b; + ids[j] = a; + return this._reorderTiles(ids); + }, + + /** + * A tab dropped on a tile, or a tile dragged there by its header: a session + * not yet tiled REPLACES that tile (same place; the replaced session keeps + * running); one already tiled swaps places with it (_swapTiles, refused while + * a tile is zoomed). Either way the dropped session takes focus (a human + * selection). */ dropSessionOnTile(draggedId, targetId) { const grid = this._tileGrid; if (!grid?.open || draggedId === targetId || !grid.tiles.has(targetId)) return; if (!this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; if (grid.tiles.has(draggedId)) { - const a = grid.ids.indexOf(draggedId); - const b = grid.ids.indexOf(targetId); - grid.ids[a] = targetId; - grid.ids[b] = draggedId; - this._applyTileLayout(); - this._scheduleTileGridRefit(); + if (!this._swapTiles(draggedId, targetId)) return; } else { this._tileDividerDragTeardown?.(); const index = grid.ids.indexOf(targetId); @@ -818,16 +872,17 @@ Object.assign(CodemanApp.prototype, { this.selectSession(draggedId); }, - /** A tab dropped on an empty slot joins the grid there (an already tiled one moves there). */ + /** + * A tab dropped on an empty slot joins the grid there; a tiled session (its + * tab, or the tile dragged by its header) moves there (_reorderTiles). + */ dropSessionOnSlot(draggedId) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; if (grid.tiles.has(draggedId)) { - // Empty slots are always the last cells in reading order. - grid.ids.splice(grid.ids.indexOf(draggedId), 1); - grid.ids.push(draggedId); - this._applyTileLayout(); - this._scheduleTileGridRefit(); + // Empty slots are always the last cells in reading order: the tile goes + // last, the tiles after it close up. + if (!this._reorderTiles([...grid.ids.filter((id) => id !== draggedId), draggedId])) return; } else if (!this.addTile(draggedId)) { return; } @@ -1083,7 +1138,8 @@ Object.assign(CodemanApp.prototype, { * session name (double-click renames), the model it runs when known, the * session menu (the tab rail's own), zoom and remove-tile. Its * buttons stop pointerdown, so acting on a tile that is not focused does not - * also focus it (and spend its idle alert). + * also focus it (and spend its idle alert). The rest of it is the handle that + * moves the tile (_installTileMoveDrag). */ _buildTileHeader(sessionId) { const el = document.createElement('div'); @@ -1148,9 +1204,84 @@ Object.assign(CodemanApp.prototype, { button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) ); el.append(dot, harness, title, actions); + this._installTileMoveDrag(sessionId, el, actions); return { el, dot, harness, name, model, modelName, zoomBtn }; }, + /** + * The header moves its tile: dragged onto another tile the two trade places, + * onto an empty slot it moves there (dropSessionOnTile / dropSessionOnSlot, + * the path a dragged tab takes, through the same capture-phase drop targets, + * _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the + * browser's own cancel: nothing moves, and dragend clears what the drag + * painted. The drag carries a type of its own and never text, so no text + * field or terminal, in this page or another application, can take it as + * typing; and it is not `draggedTabId`, so the tab strip ignores it. + * + * The handle is the header's free area: a press on a button or the rename + * input starts no drag. dragstart's target is the header whatever was + * pressed, so where the press landed is noted in the capture phase, ahead of + * the buttons' own stopPropagation. `draggable` is off while moving is + * (_paintTileHandle: a zoomed grid, a single tile, a rename in progress). + */ + _installTileMoveDrag(sessionId, header, actions) { + let pressedOnControl = false; + header.addEventListener( + 'pointerdown', + (e) => { + const target = e.target; + pressedOnControl = !!actions.contains?.(target) || /^(INPUT|BUTTON)$/i.test(target?.tagName || ''); + }, + true + ); + header.addEventListener('dragstart', (e) => { + const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null; + if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) { + e.preventDefault?.(); + return; + } + this._draggedTileId = sessionId; + entry.el.classList.add('tile--dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Firefox starts no drag without data. + e.dataTransfer.setData('application/x-codeman-tile', sessionId); + } + }); + header.addEventListener('dragend', () => this._endTileMoveDrag()); + }, + + /** + * A header drag is over (dropped, cancelled with Escape, dropped outside), + * or its tile or the grid went mid-drag: forget it and clear what it + * painted. The drop targets' highlight too: a cancelled drag does not + * reliably send dragleave. + */ + _endTileMoveDrag() { + this._draggedTileId = null; + const grid = this._tileGrid; + for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target'); + for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target'); + }, + + /** + * The header as a handle: `draggable` while the tile can move (not while a + * tile is zoomed, alone, or being renamed), and its tooltip, the state and + * how long ("working 3m") plus, while it can move, that it drags. Diffs on + * the last English text, never the DOM (translated in zh-CN; see + * _renderTileOverlay). + */ + _paintTileHandle(entry) { + const movable = this._tilesMovable(); + const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n'); + if (entry.headerLabel !== title) { + entry.headerLabel = title; + entry.header.title = title; + } + const draggable = movable && !entry.renaming ? 'true' : 'false'; + if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable); + }, + /** * Refreshes one tile's header from the session: dot state, harness logo, * name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only, @@ -1165,12 +1296,8 @@ Object.assign(CodemanApp.prototype, { const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`; if (entry.dot.className !== dotClass) entry.dot.className = dotClass; const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : ''; - const label = row ? [row.pill, since].filter(Boolean).join(' ') : ''; - // Against the last English label, never the DOM (translated in zh-CN; see _renderTileOverlay). - if (entry.headerLabel !== label) { - entry.headerLabel = label; - entry.header.title = label; - } + entry.stateLabel = row ? [row.pill, since].filter(Boolean).join(' ') : ''; + this._paintTileHandle(entry); // The input of a rename in progress has taken the name's place in the // header, so updating the detached name never touches what is being typed. // A rename still in flight shows as already done, as on the tab. @@ -1212,6 +1339,8 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions.get(sessionId); if (!entry || !session || entry.renaming) return; entry.renaming = true; + // No drag from the header while its input is in it (_paintTileHandle). + this._paintTileHandle(entry); const input = document.createElement('input'); input.type = 'text'; input.className = 'tile-rename-input'; @@ -1301,6 +1430,8 @@ Object.assign(CodemanApp.prototype, { const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null; section.classList.toggle('tile-grid--zoomed', !!zoomed); this._syncTileZoom(zoomed); + // Moving is off while a tile is zoomed or alone: the headers say so. + for (const entry of grid.tiles.values()) this._paintTileHandle(entry); // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so // they measure nothing and send no resize. Otherwise every tile is placed // explicitly in reading order, with a divider track between columns and diff --git a/test/tile-grid-chrome.test.ts b/test/tile-grid-chrome.test.ts index 9f989932..45ffc451 100644 --- a/test/tile-grid-chrome.test.ts +++ b/test/tile-grid-chrome.test.ts @@ -99,7 +99,8 @@ describe('the header', () => { app.sessions.get('s-b').status = 'busy'; app.sessions.get('s-b').lastActivityAt = Date.now() - 180_000; app._renderTileChrome(); - expect(headerOf('s-b').title).toBe('working 3m'); + // While tiles can move, a second line says the header drags. + expect(headerOf('s-b').title).toBe('working 3m\nDrag to move the tile'); }); it('every tab render refreshes the headers', () => { @@ -353,10 +354,12 @@ describe('a translated label survives a refresh (zh-CN)', () => { app.openTileGrid(['s-a', 's-b']); const a = app._tileGrid.tiles.get('s-a'); const b = app._tileGrid.tiles.get('s-b'); - expect(a.header.title).toBe('idle 3m'); + expect(a.header.title).toBe('idle 3m\nDrag to move the tile'); expect(b.overlayText.textContent).toBe('Not attached'); app.zoomTile('s-a'); expect(a.zoomBtn.title).toBe('Restore the grid'); + // Zoomed, nothing moves: the drag hint goes. + expect(a.header.title).toBe('idle 3m'); // What the translator does to them. a.header.title = '空闲 3m'; diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index edd024c0..27b830ce 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -285,8 +285,9 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'Session actions', 'Remove tile (the session keeps running)', 'idle 3m', - 'needs you 3m', - 'exited 3m', + 'idle 3m\nDrag to move the tile', + 'needs you 3m\nDrag to move the tile', + 'exited 3m\nDrag to move the tile', 'Not attached', 'Attach', 'Attaching…', @@ -346,6 +347,16 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( expect(bad).toEqual([]); }); + it('the header tooltip with the drag hint: every state, with and without a duration', () => { + // The harvest sees the hint under a state with a duration; a session the + // overview gives no time yet shows the bare state above it. + expect(zh.api.t('working 3m\nDrag to move the tile')).toBe('工作中 3m\n拖动可移动窗格'); + expect(zh.api.t('needs you <1m\nDrag to move the tile')).toBe('需要你 <1m\n拖动可移动窗格'); + expect(zh.api.t('idle\nDrag to move the tile')).toBe('空闲\n拖动可移动窗格'); + expect(zh.api.t('Drag to move the tile')).toBe('拖动可移动窗格'); + expect(en.api.t('idle\nDrag to move the tile')).toBe('idle\nDrag to move the tile'); + }); + it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => { expect(labelsInSkip).toEqual([]); }); diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts new file mode 100644 index 00000000..141322ea --- /dev/null +++ b/test/tile-grid-move.test.ts @@ -0,0 +1,407 @@ +/** + * @fileoverview Moving tiles (owner request): a tile dragged by its header + * onto another tile trades places with it, onto an empty slot it moves there. + * + * - One path for every move (`_reorderTiles`): the header drag, a tab dragged + * onto a tile or a slot, the Move Tile chords. Nothing is remounted, + * reconnected or reloaded (no new TerminalTile, no `connect()`, no fetch), + * no session joins or leaves, the grid stays open, the order is persisted. + * - Divider sizes belong to the cells: a tile whose cell size changed fits + * once (one PTY resize, #464), every other tile is left alone. + * - The header drag is native: Escape or a drop anywhere else ends in a + * `dragend` with no drop, which moves nothing and clears what the drag + * painted. It carries a type of its own (never text) and is not + * `draggedTabId`, so neither a text field nor the tab strip takes it. The + * drop targets hold it in the capture phase, before xterm. + * - The handle is the header's free area: a press on a button or the rename + * input starts no drag, and a double-click on the name still renames. + * - Moving is off while a tile is zoomed (the header drag, a tab drag of a + * tiled session, the chords) and with a single tile. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + fetchSpy, + localStore, + makeGridApp, + resetGridHarness, + section, + type GridApp, + tileEl, +} from './mocks/tile-grid-vm.js'; + +const FOUR = ['s-a', 's-b', 's-c', 's-d']; +const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; +const TILE_TYPE = 'application/x-codeman-tile'; + +const headerOf = (id: string) => tileEl(id).children[0]; +const actionsOf = (id: string) => headerOf(id).children[3]; +const nameOf = (id: string) => headerOf(id).children[2].children[0]; +const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile; +const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot')); +const stored = () => JSON.parse(localStore.get('codeman:tile-grid') ?? 'null'); + +const dragEvent = () => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { effectAllowed: '', dropEffect: '', setData: vi.fn() }, +}); + +/** A press on `target` inside the header (the capture-phase listener notes where), then the native dragstart. */ +function startDrag(id: string, target: FakeEl = headerOf(id)) { + headerOf(id).dispatch('pointerdown', { target, button: 0 }); + const start = dragEvent(); + headerOf(id).dispatch('dragstart', start); + return start; +} +function over(el: FakeEl) { + const e = dragEvent(); + el.dispatch('dragover', e); + return e; +} +function drop(el: FakeEl) { + const e = dragEvent(); + el.dispatch('drop', e); + return e; +} +const end = (id: string) => headerOf(id).dispatch('dragend', dragEvent()); + +/** A full header drag: press, start, over and drop on `target`, end. */ +function dragTileOnto(id: string, target: FakeEl) { + const start = startDrag(id); + const o = over(target); + const d = drop(target); + end(id); + return { start, over: o, drop: d }; +} + +function openGrid(ids: string[], focusedId = ids[0]): GridApp { + const app = makeGridApp(ids); + app.openTileGrid(ids, { focusedId }); + return app; +} + +/** What a move must never cost: a new tile, a connect, a destroy, a fetch. */ +function snapshotCost() { + const tiles = FakeTile.all.length; + const connects = FakeTile.all.map((t) => t.connect.mock.calls.length); + fetchSpy.mockClear(); + return () => { + expect(FakeTile.all.length).toBe(tiles); + expect(FakeTile.all.map((t) => t.connect.mock.calls.length)).toEqual(connects); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }; +} +const clearFits = () => { + for (const t of FakeTile.all) t.fit.mockClear(); +}; +const fitCounts = (ids: string[]) => Object.fromEntries(ids.map((id) => [id, tile(id).fit.mock.calls.length])); + +beforeEach(() => { + resetGridHarness(); +}); + +describe('dragging a tile by its header', () => { + it('onto another tile: the two trade places, the dragged one takes focus, nothing remounts or reloads', () => { + const app = openGrid(FOUR, 's-b'); + app.markIdleAlertSeen.mockClear(); + const costsNothing = snapshotCost(); + + const start = startDrag('s-a'); + expect(start.preventDefault).not.toHaveBeenCalled(); + expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(true); + const o = over(tileEl('s-d')); + expect(o.preventDefault).toHaveBeenCalled(); + expect(o.dataTransfer.dropEffect).toBe('move'); + expect(tileEl('s-d').classList.contains('tile--drop-target')).toBe(true); + drop(tileEl('s-d')); + end('s-a'); + + expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']); + expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['3', '3']); + expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['1', '1']); + costsNothing(); + expect(app._tilesOwnTerminal()).toBe(true); + expect([...app._tileGrid.tiles.keys()].sort()).toEqual(FOUR); + // A drag is a human action: the dropped tile takes focus, as a tab drop does. + expect(app.activeSessionId).toBe('s-a'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-a'); + // What the drag painted is gone. + for (const id of FOUR) { + expect(tileEl(id).classList.contains('tile--dragging')).toBe(false); + expect(tileEl(id).classList.contains('tile--drop-target')).toBe(false); + } + expect(app._draggedTileId).toBeNull(); + }); + + it('the new order is stored (ids only) and comes back on a reload', () => { + const app = openGrid(FOUR); + dragTileOnto('s-a', tileEl('s-c')); + expect(stored().ids).toEqual(['s-c', 's-b', 's-a', 's-d']); + expect(stored().focused).toBe('s-a'); + expect(stored().open).toBe(true); + + // A reload: a fresh page on the same device restores the stored grid. + app._tileGrid.open = false; + section.children = []; + const reloaded = makeGridApp(FOUR); + expect(reloaded._restoreTileGrid()).toBe(true); + expect(reloaded._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d']); + expect(reloaded.activeSessionId).toBe('s-a'); + }); + + describe('onto an empty slot', () => { + // Under 1800px wide three tiles take a 2x2 with one empty slot, five a 3x2. + beforeEach(() => { + section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); + }); + afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; + }); + + it('it moves there, the last cell (the tiles after it close up); the slot count stays', () => { + const app = openGrid(['s-a', 's-b', 's-c']); + const costsNothing = snapshotCost(); + expect(slots()).toHaveLength(1); + dragTileOnto('s-a', slots()[0]); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-a']); + expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['1', '3']); + expect(slots()).toHaveLength(1); + expect(slots()[0].classList.contains('tile--drop-target')).toBe(false); + expect(app.activeSessionId).toBe('s-a'); + costsNothing(); + expect(stored().ids).toEqual(['s-b', 's-c', 's-a']); + }); + }); + + it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => { + const app = openGrid(FOUR); + localStore.delete('codeman:tile-grid'); + const costsNothing = snapshotCost(); + clearFits(); + startDrag('s-a'); + over(tileEl('s-c')); + // The browser cancels (Escape) or the pointer is released outside every + // target: no drop, and not always a dragleave either. + end('s-a'); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(tileEl('s-c').classList.contains('tile--drop-target')).toBe(false); + expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false); + expect(app._draggedTileId).toBeNull(); + expect(localStore.has('codeman:tile-grid')).toBe(false); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + costsNothing(); + // The drag is over: a later drag that is neither a tab nor a tile (a file) is left alone. + expect(over(tileEl('s-c')).preventDefault).not.toHaveBeenCalled(); + }); + + it('carries a type of its own, never text, and is not a tab drag (the strip ignores it)', () => { + const app = openGrid(FOUR); + const start = startDrag('s-b'); + expect(start.dataTransfer.setData).toHaveBeenCalledWith(TILE_TYPE, 's-b'); + expect(start.dataTransfer.setData.mock.calls.every(([type]) => type === TILE_TYPE)).toBe(true); + expect(start.dataTransfer.effectAllowed).toBe('move'); + expect(app.draggedTabId).toBeFalsy(); + end('s-b'); + }); + + it('never reaches xterm: tiles and slots hold the drag in the capture phase and stop it', () => { + openGrid(FOUR); + for (const id of FOUR) { + expect(tileEl(id).captureFlags.dragover).toEqual([true]); + expect(tileEl(id).captureFlags.drop).toEqual([true]); + } + startDrag('s-a'); + const o = over(tileEl('s-b')); + const d = drop(tileEl('s-b')); + for (const e of [o, d]) { + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.stopPropagation).toHaveBeenCalled(); + } + end('s-a'); + }); + + it('over its own tile the drag is held there too (never reaching its xterm) but refused', () => { + const app = openGrid(FOUR); + startDrag('s-a'); + const o = over(tileEl('s-a')); + expect(o.preventDefault).toHaveBeenCalled(); + expect(o.stopPropagation).toHaveBeenCalled(); + expect(o.dataTransfer.dropEffect).toBe('none'); + expect(tileEl('s-a').classList.contains('tile--drop-target')).toBe(false); + drop(tileEl('s-a')); + end('s-a'); + expect(app._tileGrid.ids).toEqual(FOUR); + }); + + it('a tile removed mid-drag, or the grid closed, ends the drag', () => { + const app = openGrid(FOUR); + startDrag('s-a'); + app.removeTile('s-a'); + expect(app._draggedTileId).toBeNull(); + expect(over(tileEl('s-b')).preventDefault).not.toHaveBeenCalled(); + + startDrag('s-b'); + over(tileEl('s-c')); + app.selectSession = vi.fn(); + app.closeTileGrid(); + expect(app._draggedTileId).toBeNull(); + }); +}); + +describe('the handle', () => { + it('is the header: draggable, with a tooltip that says so under the state', () => { + const app = openGrid(FOUR); + app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } }); + app._mobileOverviewStampText = () => '3m'; + app._renderTileChrome(); + expect(headerOf('s-a').getAttribute('draggable')).toBe('true'); + expect(headerOf('s-a').title).toBe('idle 3m\nDrag to move the tile'); + }); + + it('a press on a header button starts no drag (and a press on the free area after it does)', () => { + const app = openGrid(FOUR); + for (const button of actionsOf('s-a').children) { + const start = startDrag('s-a', button); + expect(start.preventDefault).toHaveBeenCalled(); + expect(app._draggedTileId).toBeFalsy(); + expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false); + } + const start = startDrag('s-a', nameOf('s-a')); + expect(start.preventDefault).not.toHaveBeenCalled(); + expect(app._draggedTileId).toBe('s-a'); + end('s-a'); + }); + + it('a double-click on the name still renames, and no drag starts while the input is there', () => { + const app = openGrid(FOUR); + app._queueInlineSessionName = vi.fn(); + nameOf('s-a').dispatch('dblclick', { stopPropagation: vi.fn() }); + const entry = app._tileGrid.tiles.get('s-a'); + expect(entry.renaming).toBe(true); + expect(headerOf('s-a').getAttribute('draggable')).toBe('false'); + const input = headerOf('s-a').children[2].children[0]; + expect(input.className).toBe('tile-rename-input'); + // A press in the input, then a would-be drag. + expect(startDrag('s-a', input).preventDefault).toHaveBeenCalled(); + expect(app._draggedTileId).toBeFalsy(); + input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() }); + expect(entry.renaming).toBe(false); + expect(headerOf('s-a').getAttribute('draggable')).toBe('true'); + }); + + it('a single tile has nowhere to go: not draggable, no drag hint', () => { + const app = openGrid(['s-a']); + app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } }); + app._mobileOverviewStampText = () => '3m'; + app._renderTileChrome(); + expect(headerOf('s-a').getAttribute('draggable')).toBe('false'); + expect(headerOf('s-a').title).toBe('idle 3m'); + expect(startDrag('s-a').preventDefault).toHaveBeenCalled(); + }); +}); + +describe('while a tile is zoomed, moving is off', () => { + it('no header drags (draggable off, no hint), and a drag that starts anyway is refused', () => { + const app = openGrid(FOUR); + app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } }); + app._mobileOverviewStampText = () => '3m'; + app._renderTileChrome(); + app.zoomTile('s-a'); + for (const id of FOUR) { + expect(headerOf(id).getAttribute('draggable')).toBe('false'); + expect(headerOf(id).title).toBe('idle 3m'); + } + expect(startDrag('s-a').preventDefault).toHaveBeenCalled(); + expect(app._draggedTileId).toBeFalsy(); + + app.zoomTile('s-a'); + expect(headerOf('s-a').getAttribute('draggable')).toBe('true'); + }); + + it('a tab of a tiled session dropped on the zoomed tile moves nothing either', () => { + const app = openGrid(FOUR); + app.zoomTile('s-a'); + app.draggedTabId = 's-c'; + over(tileEl('s-a')); + drop(tileEl('s-a')); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(app._tileGrid.zoomedId).toBe('s-a'); + }); + + it('an automatic zoom (the window too small for the tiles) counts too', () => { + const app = openGrid(FOUR); + section.getBoundingClientRect = () => ({ width: 700, height: 400, top: 0, left: 0, right: 700, bottom: 400 }); + app._applyTileLayout(); + delete (section as unknown as Record).getBoundingClientRect; + expect(app._tileGrid.autoZoom).toBe(true); + expect(headerOf('s-b').getAttribute('draggable')).toBe('false'); + expect(app._swapTiles('s-a', 's-b')).toBe(false); + expect(app._tileGrid.ids).toEqual(FOUR); + }); +}); + +describe('sizes belong to the cells: only a tile whose size changed fits, once', () => { + it('equal cells: a swap fits nothing (no PTY resize anywhere)', () => { + const app = openGrid(SIX); + clearFits(); + dragTileOnto('s-a', tileEl('s-f')); + expect(app._tileGrid.ids).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + }); + + it('a wider first column: the two swapped tiles fit once each, the other four not at all', () => { + const app = openGrid(SIX); + app._tileGrid.colFr = [2, 1, 1]; + app._applyTileLayout(); + clearFits(); + dragTileOnto('s-a', tileEl('s-b')); + expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); + // The fractions stay with the columns. + expect(app._tileGrid.colFr).toEqual([2, 1, 1]); + }); + + it('a swap between two cells of the same size fits nothing, whatever the other sizes', () => { + const app = openGrid(SIX); + app._tileGrid.colFr = [2, 1, 1]; + app._tileGrid.rowFr = [1, 3]; + app._applyTileLayout(); + clearFits(); + // s-c (column 2, row 0) and s-b (column 1, row 0): both 1 x 1. + dragTileOnto('s-c', tileEl('s-b')); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + }); + + it('a move to the slot: every tile whose cell size changed fits once, the others not', () => { + section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); + try { + const five = SIX.slice(0, 5); + const app = openGrid(five); + app._tileGrid.colFr = [2, 1, 1]; + app._applyTileLayout(); + clearFits(); + dragTileOnto('s-a', slots()[0]); + // [a b c / d e _] -> [b c d / e a _]: b (2 -> 1 wide), d (row 1 col 0 -> row 0 col 2: 2 -> 1), + // e (1 -> 2) and a (2 -> 1) changed size; c (1 -> 1) did not. + expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']); + expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 1, 's-e': 1 }); + } finally { + delete (section as unknown as Record).getBoundingClientRect; + } + }); + + it('no debounced refit of every tile follows a move', async () => { + const app = openGrid(SIX); + app._tileGrid.colFr = [2, 1, 1]; + app._applyTileLayout(); + clearFits(); + dragTileOnto('s-a', tileEl('s-b')); + // Past TILE_GRID_REFIT_MS (150ms): still only the two moved tiles. + await new Promise((r) => setTimeout(r, 200)); + expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); + }); +}); From 20c2561a459d180b4b5bdf2d850b37cd4d163466 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 23:49:11 +0200 Subject: [PATCH 76/90] feat(tiles): Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows) Four rebindable registry chords in the Tiles group move the focused tile: it trades places with the neighbour the Alt+Shift+Arrow focus chords pick (tileInDirection), through the same _reorderTiles path as the header drag, and keeps the focus. Nothing at an edge. They go through tileShortcutFor()/runTileShortcut() like the other tile chords, so every xterm key handler swallows them while they apply: only while the grid is open, a zoomed grid included (a no-op there, so the keys never reach the CLI), and never in a text field other than xterm's own textarea, where Ctrl+Shift+Arrows select by word. Ctrl+Shift+Arrows because every other two-modifier arrow chord is taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows graphics drivers), Ctrl+Alt+Shift moves a window to another workspace (GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the browser's back and forward, Alt+Shift focuses tiles. No browser, GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it costs a terminal editor's word selection inside a tile while the grid is open. The Help modal lists the chords and the header drag; the shortcut overlay lists the registry. zh-CN entries for every new string. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 39 ++++++ src/web/public/i18n.js | 8 ++ src/web/public/index.html | 2 + src/web/public/tile-grid.js | 39 +++++- test/tile-grid-i18n.test.ts | 6 +- test/tile-grid-move.test.ts | 217 +++++++++++++++++++++++++++++++ test/tile-grid-shortcuts.test.ts | 23 +++- 7 files changed, 328 insertions(+), 6 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index ecf64b61..ad8b4f3a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -579,6 +579,45 @@ const DEFAULT_SHORTCUTS = [ bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }], action: 'focusTileDown', }, + // Move the focused tile: it trades places with the neighbour the focus + // chords would pick. Ctrl+Shift+Arrows because every other two-modifier + // arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and + // rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows + // move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords + // belong to the desktop, Alt+Arrows are the browser's back and forward, and + // Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and + // no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ } + // for moving tabs); what it costs is word selection, so the chords skip a + // text field (tileShortcutFor) and give up only a terminal editor's + // word selection (nano, micro, emacs) inside a tile while the grid is open. + { + id: 'move-tile-left', + group: 'Tiles', + label: 'Move Tile Left', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }], + action: 'moveTileLeft', + }, + { + id: 'move-tile-right', + group: 'Tiles', + label: 'Move Tile Right', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }], + action: 'moveTileRight', + }, + { + id: 'move-tile-up', + group: 'Tiles', + label: 'Move Tile Up', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }], + action: 'moveTileUp', + }, + { + id: 'move-tile-down', + group: 'Tiles', + label: 'Move Tile Down', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }], + action: 'moveTileDown', + }, { id: 'zoom-tile', group: 'Tiles', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 3b794c9e..3e9e03fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -114,6 +114,14 @@ 'Focus Tile Up': '聚焦上方窗格', 'Focus Tile Down': '聚焦下方窗格', 'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格', + 'Move Tile Left': '向左移动窗格', + 'Move Tile Right': '向右移动窗格', + 'Move Tile Up': '向上移动窗格', + 'Move Tile Down': '向下移动窗格', + 'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格', + Drag: '拖动', + "a tile's header": '窗格的标题栏', + 'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)', 'Zoom Focused Tile': '放大聚焦的窗格', 'Remove Focused Tile': '移除聚焦的窗格', 'Add the Session to the Tile Grid': '将该会话加入平铺网格', diff --git a/src/web/public/index.html b/src/web/public/index.html index 53484f3e..3fbe4530 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -838,6 +838,8 @@
Ctrl+Shift+G
Toggle Tile Grid
Alt/Option+Shift+Arrows
Focus Tile Left / Right / Up / Down
+
Ctrl+Shift+Arrows
Move Tile Left / Right / Up / Down
+
Drag a tile's header
Move the Tile (onto Another: Swap)
Alt/Option+Shift+Enter
Zoom Focused Tile
Ctrl/Cmd+Click a tab
Add the Session to the Tile Grid
Right-click the Tiles button
Choose Which Sessions to Tile
diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 6772cb94..88ba58cf 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -40,17 +40,33 @@ function tileGridTracks(fr) { return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); } // 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. +// needs the grid open. The toggle applies wherever a grid could open. The move +// chords leave a text field its keys (`move`: see tileShortcutFor). 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' }, + 'move-tile-left': { needsOpen: true, move: 'left' }, + 'move-tile-right': { needsOpen: true, move: 'right' }, + 'move-tile-up': { needsOpen: true, move: 'up' }, + 'move-tile-down': { needsOpen: true, move: 'down' }, 'remove-tile': { needsOpen: true }, 'zoom-tile': { needsOpen: true }, }; +/** + * A text field other than a terminal's own input (xterm's helper textarea): + * the rename input, the file editor, a settings field. Ctrl+Shift+Arrows + * selects by word there, so the move chords leave it alone. + */ +function isTextFieldTarget(target) { + if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false; + const tag = String(target.tagName || '').toUpperCase(); + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true; +} + /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { constructor() { @@ -528,9 +544,11 @@ Object.assign(CodemanApp.prototype, { * toggle applies while the grid is open, or where one could open AND the * per-device `showTileGridButton` setting is on: with it off (the default) * the chord is inert and reaches the terminal like any unbound key (owner - * decision 6 in docs/tile-grid-plan.md). The focus, + * decision 6 in docs/tile-grid-plan.md). The focus, move, * zoom and remove chords apply only while the grid is open, however it was - * opened. Registry-aware (rebinds and disables in App Settings, Shortcuts). + * opened (a move chord also while a tile is zoomed, as a no-op, so its keys + * never reach the CLI; and never in a text field, whose keys they are). + * 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. * @@ -543,6 +561,7 @@ Object.assign(CodemanApp.prototype, { for (const shortcut of this.getShortcutRegistry()) { const spec = TILE_SHORTCUTS[shortcut.id]; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; + if (spec.move && isTextFieldTarget(e.target)) continue; if (spec.needsOpen) return open ? shortcut.id : null; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; @@ -558,6 +577,7 @@ Object.assign(CodemanApp.prototype, { else if (id === 'remove-tile') this.removeFocusedTile(); else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId); else if (spec.direction) this.focusTileInDirection(spec.direction); + else if (spec.move) this.moveTileInDirection(spec.move); }, /** @@ -838,6 +858,19 @@ Object.assign(CodemanApp.prototype, { return this._reorderTiles(ids); }, + /** + * Move Tile Left/Right/Up/Down: the focused tile trades places with its + * neighbour in that direction, the neighbour the Alt+Shift+Arrow focus + * chords pick (tileInDirection). Focus stays on the moved tile. Nothing while + * a tile is zoomed, or at an edge. + */ + moveTileInDirection(direction) { + const grid = this._tileGrid; + if (!grid?.open || grid.zoomedId || !grid.focusedId) return; + const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols); + if (neighbor) this._swapTiles(grid.focusedId, neighbor); + }, + /** * A tab dropped on a tile, or a tile dragged there by its header: a session * not yet tiled REPLACES that tile (same place; the replaced session keeps diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 27b830ce..4ee26d44 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -369,7 +369,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => { const app = read('app.js'); const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]); - expect(labels).toHaveLength(7); + expect(labels).toHaveLength(11); expect(app).toContain("label: 'Open group as tiles'"); expect(app).toContain('not bound'); const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => { @@ -417,6 +417,10 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { expect(leftover(tiles!.textContent!)).toEqual([]); expect(tiles!.textContent).toContain('切换平铺网格'); expect(tiles!.textContent).toContain('右键单击'); + // Moving tiles: the chords and the header drag. + expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格'); + expect(tiles!.textContent).toContain('拖动'); + expect(tiles!.textContent).toContain('窗格的标题栏'); }); it('user text stays as typed: a session name and a group name that are also UI words', () => { diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index 141322ea..09b8f9ab 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -17,6 +17,12 @@ * input starts no drag, and a double-click on the name still renames. * - Moving is off while a tile is zoomed (the header drag, a tab drag of a * tiled session, the chords) and with a single tile. + * - Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows): the focused tile trades + * places with the neighbour the Alt+Shift+Arrow focus chords pick, checked + * against hand-written tables for every cell and direction of 2x1, 2x2, 3x2 + * and the partial 2x2 and 3x2; focus stays on the moved tile. The chords + * apply (and are swallowed) only while the grid is open, zoomed included as a + * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ @@ -24,6 +30,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, FakeTile, + documentAddEventListener, fetchSpy, localStore, makeGridApp, @@ -405,3 +412,213 @@ describe('sizes belong to the cells: only a tile whose size changed fits, once', expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); }); }); + +describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => { + const ARROW = { left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' } as const; + type Dir = keyof typeof ARROW; + const chord = (dir: Dir, overrides: Record = {}) => ({ + type: 'keydown', + key: ARROW[dir], + code: ARROW[dir], + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + preventDefault: vi.fn(), + target: { closest: () => null, classList: { contains: (c: string) => c === 'xterm-helper-textarea' } }, + ...overrides, + }); + + 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]; + } + + // The neighbour in each direction, by cell, written out by hand (null: an + // edge). A partial last row: down from a cell above an empty one goes to the + // last tile, the rule the focus chords follow. + const TABLES: Record< + string, + { ids: string[]; cols: number; width?: number; next: Array> } + > = { + '2x1': { + cols: 2, + ids: ['s-a', 's-b'], + next: [ + { left: null, right: 1, up: null, down: null }, + { left: 0, right: null, up: null, down: null }, + ], + }, + '2x2': { + cols: 2, + ids: FOUR, + next: [ + { left: null, right: 1, up: null, down: 2 }, + { left: 0, right: null, up: null, down: 3 }, + { left: null, right: 3, up: 0, down: null }, + { left: 2, right: null, up: 1, down: null }, + ], + }, + '3x2': { + cols: 3, + ids: SIX, + next: [ + { left: null, right: 1, up: null, down: 3 }, + { left: 0, right: 2, up: null, down: 4 }, + { left: 1, right: null, up: null, down: 5 }, + { left: null, right: 4, up: 0, down: null }, + { left: 3, right: 5, up: 1, down: null }, + { left: 4, right: null, up: 2, down: null }, + ], + }, + '2x2 with 3 tiles': { + cols: 2, + ids: ['s-a', 's-b', 's-c'], + width: 1700, + next: [ + { left: null, right: 1, up: null, down: 2 }, + { left: 0, right: null, up: null, down: 2 }, + { left: null, right: null, up: 0, down: null }, + ], + }, + '3x2 with 5 tiles': { + cols: 3, + ids: SIX.slice(0, 5), + width: 1700, + next: [ + { left: null, right: 1, up: null, down: 3 }, + { left: 0, right: 2, up: null, down: 4 }, + { left: 1, right: null, up: null, down: 4 }, + { left: null, right: 4, up: 0, down: null }, + { left: 3, right: null, up: 1, down: null }, + ], + }, + }; + + afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; + }); + + for (const [layout, table] of Object.entries(TABLES)) { + it(`${layout}: every cell, every direction, swaps with the right neighbour (or nothing at an edge)`, () => { + const bad: string[] = []; + table.next.forEach((next, i) => { + for (const dir of Object.keys(ARROW) as Dir[]) { + resetGridHarness(); + if (table.width) { + const w = table.width; + section.getBoundingClientRect = () => ({ width: w, height: 1000, top: 0, left: 0, right: w, bottom: 1000 }); + } + const app = openGrid(table.ids, table.ids[i]); + expect(app._tileGrid.cols).toBe(table.cols); + const costsNothing = snapshotCost(); + app.markIdleAlertSeen.mockClear(); + const onKeydown = handlerFor(app); + const e = chord(dir); + onKeydown(e); + const expected = table.ids.slice(); + const j = next[dir]; + if (j !== null) [expected[i], expected[j]] = [expected[j], expected[i]]; + const moved = table.ids[i]; + const ok = + e.preventDefault.mock.calls.length === 1 && + JSON.stringify(app._tileGrid.ids) === JSON.stringify(expected) && + app.activeSessionId === moved && + app._tileGrid.focusedId === moved && + tileEl(moved).classList.contains('focused') && + JSON.stringify(stored().ids) === JSON.stringify(expected) && + stored().focused === moved; + if (!ok) bad.push(`cell ${i} ${dir}: got ${app._tileGrid.ids.join(',')} focus ${app.activeSessionId}`); + costsNothing(); + // Equal cells: nothing changed size, so nothing fits. + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + } + }); + expect(bad).toEqual([]); + }); + } + + it('focus stays on the moved tile through several moves, and the order is stored', () => { + const app = openGrid(SIX, 's-a'); + const onKeydown = handlerFor(app); + for (const dir of ['right', 'right', 'down', 'left'] as Dir[]) onKeydown(chord(dir)); + // a: 0 -> 1 -> 2 -> 5 -> 4 + expect(app._tileGrid.ids.indexOf('s-a')).toBe(4); + expect(app.activeSessionId).toBe('s-a'); + expect(stored().ids).toEqual(app._tileGrid.ids); + expect(stored().focused).toBe('s-a'); + }); + + it('only a tile whose cell size changed fits, once', () => { + const app = openGrid(SIX, 's-a'); + app._tileGrid.colFr = [2, 1, 1]; + app._applyTileLayout(); + clearFits(); + handlerFor(app)(chord('right')); + expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); + }); + + describe('swallowed only while they apply', () => { + it('grid closed: not a tile chord, and the capture handler leaves it alone (it reaches the terminal)', () => { + const app = makeGridApp(FOUR); + // Even with the Tiles setting on (where the toggle chord would apply). + app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true }); + expect(app.tileShortcutFor(chord('right'))).toBeNull(); + const e = chord('right'); + handlerFor(app)(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('grid open, from a terminal: applies and is swallowed', () => { + const app = openGrid(FOUR); + expect(app.tileShortcutFor(chord('right'))).toBe('move-tile-right'); + expect(app.tileShortcutFor(chord('down'))).toBe('move-tile-down'); + }); + + it('in a text field (the rename input, an editor) it is left to the field: word selection', () => { + const app = openGrid(FOUR); + const onKeydown = handlerFor(app); + for (const tagName of ['INPUT', 'TEXTAREA']) { + const e = chord('right', { target: { tagName, closest: () => null, classList: { contains: () => false } } }); + expect(app.tileShortcutFor(e)).toBeNull(); + onKeydown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + } + const editable = chord('left', { target: { isContentEditable: true, closest: () => null } }); + expect(app.tileShortcutFor(editable)).toBeNull(); + expect(app._tileGrid.ids).toEqual(FOUR); + }); + + it('while a tile is zoomed it still applies (swallowed, never typed into the CLI) and moves nothing', () => { + const app = openGrid(FOUR); + app.zoomTile('s-a'); + const e = chord('right'); + expect(app.tileShortcutFor(e)).toBe('move-tile-right'); + handlerFor(app)(e); + expect(e.preventDefault).toHaveBeenCalled(); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(app._tileGrid.zoomedId).toBe('s-a'); + }); + + it('honours a disable and a rebind from App Settings', () => { + const app = openGrid(FOUR); + app.loadAppSettingsFromStorage = () => ({ + shortcutOverrides: { + 'move-tile-right': { disabled: true }, + 'move-tile-left': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'h', code: 'KeyH' }] }, + }, + }); + expect(app.tileShortcutFor(chord('right'))).toBeNull(); + expect(app.tileShortcutFor(chord('left'))).toBeNull(); + expect(app.tileShortcutFor(chord('left', { key: 'h', code: 'KeyH', shiftKey: false, altKey: true }))).toBe( + 'move-tile-left' + ); + }); + }); +}); diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 339a1f51..5cbab3f3 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -3,8 +3,8 @@ * 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 + * the grid, Alt+Shift+Arrows move focus between tiles, Ctrl+Shift+Arrows move + * the focused tile (tile-grid-move.test.ts), 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 @@ -69,6 +69,11 @@ describe('registry', () => { expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([ { modifiers: ['alt', 'shift'], key: `Arrow${dir}` }, ]); + // Moving the focused tile (tile-grid-move.test.ts). + expect(byId[`move-tile-${dir.toLowerCase()}`].bindings).toEqual([ + { modifiers: ['ctrl', 'shift'], key: `Arrow${dir}` }, + ]); + expect(byId[`move-tile-${dir.toLowerCase()}`].group).toBe('Tiles'); } expect(byId['remove-tile'].bindings).toEqual([]); expect(byId['toggle-tile-grid'].group).toBe('Tiles'); @@ -351,6 +356,20 @@ describe('xterm key handlers swallow a chord that applies', () => { expect(handler(chord(RIGHT))).toBe(true); }); + const MOVE_RIGHT = { key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true, shiftKey: true }; + it.each(['keydown', 'keypress', 'keyup'])( + 'returns false for Ctrl+Shift+Right, Move Tile Right (%s), while the grid is open', + async (type) => { + const handler = await tileHandler(true); + expect(handler({ ...chord(MOVE_RIGHT), type })).toBe(false); + } + ); + + it('lets Ctrl+Shift+Right through to the PTY when no grid is open', async () => { + const handler = await tileHandler(false); + expect(handler(chord(MOVE_RIGHT))).toBe(true); + }); + it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => { const handler = await tileHandler(true); fetchMock.mockClear(); From adeb22d7b2f7f69dc47cb4093ea6c9a55b8f766d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 00:13:32 +0200 Subject: [PATCH 77/90] docs(tiles): moving tiles, by the header and by Ctrl+Shift+Arrows The wiki's Tile Grid page gets a "Moving tiles" section and the move chords in its keys table (with what they leave to a text field and take from a terminal editor inside a tile); Keyboard Shortcuts lists the chords and the header drag. The spec records moving as an owner request in its as-built list, with the reasoning behind the default keys. The invariants and CLAUDE.md say every move goes through _reorderTiles (no remount, reconnect or reload; only a tile whose cell size changed fits) and that the header drag carries its own type and is not draggedTabId. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 ++-- docs/tile-grid-plan.md | 18 ++++++++++++++++++ docs/wiki/Keyboard-Shortcuts.md | 2 ++ docs/wiki/Tile-Grid.md | 22 ++++++++++++++++++++-- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd128863..29b6b20d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ A tile moves (its header dragged, a tiled tab dropped on a tile or slot, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9d047b0b..35209c53 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -807,13 +807,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. -⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. +⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI) and never in a text field other than xterm's own textarea (`isTextFieldTarget`), where Ctrl+Shift+Arrows select by word. ⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. -⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. +⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. ⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 9984d103..fe18e725 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -67,6 +67,24 @@ or settled a question the spec left open. The invariants as built are in "Parking the main terminal" below says; the server's filter gates only terminal batches, so lifecycle and hook events are unaffected, and leaving the grid re-subscribes the shown session. +- **Tiles move** (owner request: "give me the option to move the tiles around"; not a + numbered decision). A tile's header, its free area (not the buttons, not the rename input), + drags it: onto another tile the two trade places, onto an empty slot it moves there (the + last cell: empty slots are always last, so the tiles after it close up). It is a native drag + through the tab drop targets (capture phase, stopped before xterm), carrying a type of its + own and never text, and it is not `draggedTabId`, so neither a text field nor the tab strip + takes it; Escape or a drop anywhere else cancels with nothing changed. `Ctrl+Shift+Arrows` + (Move Tile Left/Right/Up/Down, registry, rebindable) swap the focused tile with the + neighbour the focus chords pick, and focus stays on it. Every move, a tiled tab's drop + included, goes through `_reorderTiles`: no remount, reconnect or reload; divider sizes + belong to the cells, so only a tile whose cell size changed fits (one PTY resize, #464). + Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so their + keys never reach the CLI) and with a single tile, and the chords skip a text field, where + Ctrl+Shift+Arrows select by word. Default keys: every other two-modifier arrow chord is + taken (Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace + in GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by + the browsers, GNOME, KDE, macOS and Claude Code, and costs only a terminal editor's word + selection inside a tile while the grid is open. - **A tile that joins before its pane exists resends its size when the pid appears** (`TerminalTile.paneStarted()`): the server drops a resize for a session with no PTY and spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 30fdf0a6..1140095f 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -45,6 +45,8 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | --------------------------- | ------------------------------------------------------------ | | `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). | | `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | +| `Ctrl+Shift+Arrows` | Move the focused tile: it trades places with that neighbour. | +| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | | Right-click the Tiles button | Choose which sessions to show as tiles. | diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index a8d810c1..0b13683e 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -62,20 +62,38 @@ one on its own until the window is big enough again. A tile whose session is not running shows **Not attached** with an **Attach** button. A tile whose agent exited inside its pane says so instead; close that session from `⋯`. +## Moving tiles + +Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade +places. Drop it on an empty slot and it moves there, to the end of the grid. Press `Escape` +or let go anywhere else and nothing moves. The dropped tile takes the focus. + +With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile left, right, up or down: it +trades places with the tile next to it (the one `Alt+Shift+Arrows` would focus) and keeps the +focus. + +A moved tile takes the size of the place it lands in: column widths and row heights stay +where you dragged the dividers. Tiles do not move while one is zoomed. The new order is +saved with the grid. + ## Keys | Shortcut | Action | | ------------------------ | ---------------------------------------------------------- | | `Ctrl+Shift+G` | Open or close the grid. | | `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | +| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. | | `Ctrl+L` | Clear the focused tile. | | `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). | All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can -also get a key. Outside the grid, `Alt+Shift+Arrows` and `Alt+Shift+Enter` go to the -terminal as usual. With the Tiles setting off, `Ctrl+Shift+G` does nothing. +also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and +`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Ctrl+Shift+Arrows` in a text +field (renaming a tile, the file editor) still selects by word; inside a tile it moves the tile, +so a terminal editor there (nano, micro, emacs) does not get it. With the Tiles setting off, +`Ctrl+Shift+G` does nothing. ## Leaving the grid From aecada8c565fb61bbad8d8070e8740f428980bc1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 01:28:20 +0200 Subject: [PATCH 78/90] fix(tiles): no tile onto an empty slot, header focus on click, arrow chords skip text fields The owner's answers on moving tiles: - "dont move the tile": an empty slot no longer takes a tile. A slot is always the last cell, so a move there shifted every tile after it. Each drop target now says what it accepts (_acceptTabDrops' `accepts`): a tile takes any session but its own, an empty slot only a session not tiled yet. A refused drag is still held (dropEffect none, no highlight), and dropSessionOnSlot refuses a tiled session too, its tab included. A session not tiled yet still joins on a slot. - A cancelled drag changes nothing, focus included (best practice): the header focuses its tile on click, never on press, so a drag that ends with Escape or outside leaves focus and the idle alert alone. The body keeps press-to-focus, so focus still moves before a press reaches xterm. The rename input stops its own clicks. - A tiled tab dropped on the zoomed tile stays refused (confirmed). - The Alt+Shift+Arrow focus chords skip a text field too (best practice), as the move chords already did: shifted arrows select there. Toggle and zoom are not text-editing keys and are unchanged. Docs: the wiki, the spec's as-built bullet (with the owner's answers), the invariants and CLAUDE.md say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/tile-grid-plan.md | 35 +++++---- docs/wiki/Keyboard-Shortcuts.md | 2 +- docs/wiki/Tile-Grid.md | 14 ++-- src/web/public/app.js | 4 +- src/web/public/tile-grid.js | 96 +++++++++++++----------- test/tile-grid-move.test.ts | 122 ++++++++++++++++++++++++++----- test/tile-grid-shortcuts.test.ts | 22 ++++++ 9 files changed, 217 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29b6b20d..c3fcc81c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ A tile moves (its header dragged, a tiled tab dropped on a tile or slot, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed, and an empty slot refuses a tiled session (owner). The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 35209c53..6c0f7347 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -807,13 +807,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. -⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI) and never in a text field other than xterm's own textarea (`isTextFieldTarget`), where Ctrl+Shift+Arrows select by word. +⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. ⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. -⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. +⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own, an empty slot only a session not tiled yet, so a tile is never moved to a slot; a refused drag is still held (`dropEffect: 'none'`, no highlight). Every move (that drag, a tiled tab's drop on a tile, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. ⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index fe18e725..c2b2b563 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -68,21 +68,26 @@ or settled a question the spec left open. The invariants as built are in batches, so lifecycle and hook events are unaffected, and leaving the grid re-subscribes the shown session. - **Tiles move** (owner request: "give me the option to move the tiles around"; not a - numbered decision). A tile's header, its free area (not the buttons, not the rename input), - drags it: onto another tile the two trade places, onto an empty slot it moves there (the - last cell: empty slots are always last, so the tiles after it close up). It is a native drag - through the tab drop targets (capture phase, stopped before xterm), carrying a type of its - own and never text, and it is not `draggedTabId`, so neither a text field nor the tab strip - takes it; Escape or a drop anywhere else cancels with nothing changed. `Ctrl+Shift+Arrows` - (Move Tile Left/Right/Up/Down, registry, rebindable) swap the focused tile with the - neighbour the focus chords pick, and focus stays on it. Every move, a tiled tab's drop - included, goes through `_reorderTiles`: no remount, reconnect or reload; divider sizes - belong to the cells, so only a tile whose cell size changed fits (one PTY resize, #464). - Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so their - keys never reach the CLI) and with a single tile, and the chords skip a text field, where - Ctrl+Shift+Arrows select by word. Default keys: every other two-modifier arrow chord is - taken (Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace - in GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by + numbered decision). A tile's header, its free area (not the buttons, not the rename + input), drags it onto another tile and the two trade places. An empty slot refuses a tile, + its header drag and its tab alike (owner's answer, "dont move the tile": a slot is always + the last cell, so a move there shifted every tile after it); a session not tiled yet still + joins there. It is a native drag through the tab drop targets (capture phase, stopped + before xterm), carrying a type of its own and never text, and it is not `draggedTabId`, so + neither a text field nor the tab strip takes it; Escape or a drop anywhere else cancels + with nothing changed, focus included: the header focuses its tile on click, not on press + (owner's answer: best practice; the body keeps press-to-focus, so focus moves before a + press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile Left/Right/Up/Down, registry, + rebindable) swap the focused tile with the neighbour the focus chords pick, and focus + stays on it. Every move, a tiled tab's drop included, goes through `_reorderTiles`: no + remount, reconnect or reload; divider sizes belong to the cells, so only a tile whose cell + size changed fits (one PTY resize, #464). Moving is off while a tile is zoomed (the chords + still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on + the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow + chord families, focus and move, skip a text field, where shifted arrows select (owner's + answer: best practice). Default keys: every other two-modifier arrow chord is taken + (Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace in + GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by the browsers, GNOME, KDE, macOS and Claude Code, and costs only a terminal editor's word selection inside a tile while the grid is open. - **A tile that joins before its pane exists resends its size when the pid appears** diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 1140095f..f289513a 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -46,7 +46,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). | | `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | | `Ctrl+Shift+Arrows` | Move the focused tile: it trades places with that neighbour. | -| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. | +| Drag a tile's header | Move the tile: dropped on another tile, the two swap. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | | Right-click the Tiles button | Choose which sessions to show as tiles. | diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 0b13683e..c42a79c2 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -65,8 +65,10 @@ whose agent exited inside its pane says so instead; close that session from `⋯ ## Moving tiles Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade -places. Drop it on an empty slot and it moves there, to the end of the grid. Press `Escape` -or let go anywhere else and nothing moves. The dropped tile takes the focus. +places; the dropped tile takes the focus. Empty slots are for adding sessions (drop a tab +there), so a tile cannot be dropped on one. Press `Escape` or let go anywhere else and nothing +changes, not even which tile has the focus: a header focuses its tile when you click it, not +when you press it. With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile left, right, up or down: it trades places with the tile next to it (the one `Alt+Shift+Arrows` would focus) and keeps the @@ -90,10 +92,10 @@ saved with the grid. All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and -`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Ctrl+Shift+Arrows` in a text -field (renaming a tile, the file editor) still selects by word; inside a tile it moves the tile, -so a terminal editor there (nano, micro, emacs) does not get it. With the Tiles setting off, -`Ctrl+Shift+G` does nothing. +`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and +`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there; +inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does +not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing. ## Leaving the grid diff --git a/src/web/public/app.js b/src/web/public/app.js index ad8b4f3a..390f64b4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -543,7 +543,9 @@ const DEFAULT_SHORTCUTS = [ // 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. + // find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. The + // arrow chords (focus, move) never apply in a text field, where shifted + // arrows select (tileShortcutFor). { id: 'toggle-tile-grid', group: 'Tiles', diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 88ba58cf..b7d2c1ea 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -40,8 +40,8 @@ function tileGridTracks(fr) { return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); } // 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. The move -// chords leave a text field its keys (`move`: see tileShortcutFor). +// needs the grid open. The toggle applies wherever a grid could open. The arrow +// chords (`direction`, `move`) leave a text field its keys (tileShortcutFor). const TILE_SHORTCUTS = { 'toggle-tile-grid': { needsOpen: false }, 'focus-tile-left': { needsOpen: true, direction: 'left' }, @@ -58,8 +58,9 @@ const TILE_SHORTCUTS = { /** * A text field other than a terminal's own input (xterm's helper textarea): - * the rename input, the file editor, a settings field. Ctrl+Shift+Arrows - * selects by word there, so the move chords leave it alone. + * the rename input, the file editor, a settings field. Shifted arrows select + * there (Ctrl+Shift by word; Option+Shift by word on macOS), so the arrow + * chords, focus and move, leave it alone (owner: best practice). */ function isTextFieldTarget(target) { if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false; @@ -547,7 +548,8 @@ Object.assign(CodemanApp.prototype, { * decision 6 in docs/tile-grid-plan.md). The focus, move, * zoom and remove chords apply only while the grid is open, however it was * opened (a move chord also while a tile is zoomed, as a no-op, so its keys - * never reach the CLI; and never in a text field, whose keys they are). + * never reach the CLI). The arrow chords never apply in a text field, whose + * keys they are. * 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. @@ -561,7 +563,7 @@ Object.assign(CodemanApp.prototype, { for (const shortcut of this.getShortcutRegistry()) { const spec = TILE_SHORTCUTS[shortcut.id]; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; - if (spec.move && isTextFieldTarget(e.target)) continue; + if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue; if (spec.needsOpen) return open ? shortcut.id : null; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; @@ -738,14 +740,23 @@ Object.assign(CodemanApp.prototype, { el.append(header.el, body); // Pressing a tile is a human selection: it focuses the tile and // acknowledges its idle alert (the already-focused tile hits - // selectSession's early return, which acknowledges too). pointerdown, not - // click, so focus moves before the press reaches xterm, and never - // preventDefault: xterm's own mousedown focuses its textarea and starts - // selections. - el.addEventListener('pointerdown', () => { + // selectSession's early return, which acknowledges too). In the body on + // pointerdown, not click, so focus moves before the press reaches xterm, + // and never preventDefault: xterm's own mousedown focuses its textarea and + // starts selections. In the header on click instead (below): a press there + // may become a drag, and a drag that is cancelled changes nothing, focus + // included (owner: best practice). A drag never ends in a click. + el.addEventListener('pointerdown', (e) => { + if (header.el.contains?.(e?.target)) return; if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); - this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId)); + header.el.addEventListener('click', () => { + if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); + }); + // A tile takes any session but its own (_acceptTabDrops). + this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId), { + accepts: (id) => id !== sessionId, + }); this._tileGridSection().appendChild(el); const tile = this._newTerminalTile(sessionId, body); grid.tiles.set(sessionId, { @@ -774,12 +785,13 @@ Object.assign(CodemanApp.prototype, { * strip's own drag sets `draggedTabId`) and for a tile dragged by its header * (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event * stopped: a tab drag carries the session id as text, and xterm's helper - * textarea would otherwise accept that drop and type the id into a PTY. Over - * the dragged session's own tile the drag is held there too, but refused - * (`dropEffect: 'none'`, so no drop follows). Any other drag (a file) is - * left alone. + * textarea would otherwise accept that drop and type the id into a PTY. + * `accepts(id)` is the target's own rule (a tile: any session but its own; + * an empty slot: only a session not tiled yet). A session it does not accept + * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no + * drop follows). Any other drag (a file) is left alone. */ - _acceptTabDrops(el, onDrop) { + _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); el.addEventListener( 'dragover', @@ -788,9 +800,9 @@ Object.assign(CodemanApp.prototype, { if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - const own = id === el.dataset.sessionId; - if (e.dataTransfer) e.dataTransfer.dropEffect = own ? 'none' : 'move'; - if (!own) el.classList.add('tile--drop-target'); + const ok = accepts(id); + if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none'; + if (ok) el.classList.add('tile--drop-target'); }, true ); @@ -805,7 +817,7 @@ Object.assign(CodemanApp.prototype, { if (!id) return; e.preventDefault?.(); e.stopPropagation?.(); - onDrop(id); + if (accepts(id)) onDrop(id); }, true ); @@ -819,12 +831,13 @@ Object.assign(CodemanApp.prototype, { /** * Puts the tiles in a new reading order (the same sessions): the one path - * every move takes, a header drag, a tab dragged onto a tile or a slot, and - * the Move Tile chords. Nothing is remounted, reconnected or reloaded, and - * no session joins or leaves. Divider sizes belong to the cells, so a moved - * tile takes its new cell's size: each tile whose cell size changed fits - * once (its xterm and one PTY resize together, #464), every other tile is - * left alone. Refused while a tile is zoomed (moving is off then). + * every move takes, a header drag, the tab of a tiled session dropped on a + * tile, and the Move Tile chords. Nothing is remounted, reconnected or + * reloaded, and no session joins or leaves. Divider sizes belong to the + * cells, so a moved tile takes its new cell's size: each tile whose cell + * size changed fits once (its xterm and one PTY resize together, #464), + * every other tile is left alone. Refused while a tile is zoomed (moving is + * off then). * * @param {string[]} ids - the new order * @returns {boolean} false when refused, true otherwise (also when nothing moved) @@ -906,19 +919,13 @@ Object.assign(CodemanApp.prototype, { }, /** - * A tab dropped on an empty slot joins the grid there; a tiled session (its - * tab, or the tile dragged by its header) moves there (_reorderTiles). + * A tab dropped on an empty slot joins the grid there. A session already + * tiled is not moved to a slot (the slot refuses it, see _syncTileSlots). */ dropSessionOnSlot(draggedId) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; - if (grid.tiles.has(draggedId)) { - // Empty slots are always the last cells in reading order: the tile goes - // last, the tiles after it close up. - if (!this._reorderTiles([...grid.ids.filter((id) => id !== draggedId), draggedId])) return; - } else if (!this.addTile(draggedId)) { - return; - } + if (grid.tiles.has(draggedId) || !this.addTile(draggedId)) return; this.selectSession(draggedId); }, @@ -1242,10 +1249,9 @@ Object.assign(CodemanApp.prototype, { }, /** - * The header moves its tile: dragged onto another tile the two trade places, - * onto an empty slot it moves there (dropSessionOnTile / dropSessionOnSlot, - * the path a dragged tab takes, through the same capture-phase drop targets, - * _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the + * The header moves its tile: dragged onto another tile the two trade places + * (dropSessionOnTile, the path a dragged tab takes, through the same + * capture-phase drop targets, _acceptTabDrops); an empty slot refuses it. A native drag, so Escape and a drop anywhere else are the * browser's own cancel: nothing moves, and dragend clears what the drag * painted. The drag carries a type of its own and never text, so no text * field or terminal, in this page or another application, can take it as @@ -1394,6 +1400,9 @@ Object.assign(CodemanApp.prototype, { this._renderTileHeader(sessionId); }; input.addEventListener('pointerdown', (e) => e.stopPropagation()); + // A click in the input is not the header's click, which would focus the + // terminal away from it. + input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', (e) => { // Enter and Escape during an IME composition belong to the IME. if (e.isComposing || e.keyCode === 229) return; @@ -1511,7 +1520,12 @@ Object.assign(CodemanApp.prototype, { const slot = document.createElement('div'); slot.className = 'tile-slot'; slot.textContent = 'Drop a tab here'; - this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId)); + // Only a session not tiled yet: a tile is never moved to a slot (owner: + // "dont move the tile"; a slot is always last, so a move there shifted + // every tile after it). + this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId), { + accepts: (id) => !this._tileGrid?.tiles.has(id), + }); this._tileGridSection().appendChild(slot); grid.slots.push(slot); } diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index 09b8f9ab..ba8dbfb3 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -1,6 +1,6 @@ /** * @fileoverview Moving tiles (owner request): a tile dragged by its header - * onto another tile trades places with it, onto an empty slot it moves there. + * onto another tile trades places with it. * * - One path for every move (`_reorderTiles`): the header drag, a tab dragged * onto a tile or a slot, the Move Tile chords. Nothing is remounted, @@ -8,6 +8,12 @@ * no session joins or leaves, the grid stays open, the order is persisted. * - Divider sizes belong to the cells: a tile whose cell size changed fits * once (one PTY resize, #464), every other tile is left alone. + * - An empty slot refuses a tile (its header drag or its tab): owner, "dont + * move the tile" (a slot is always the last cell, so a move there shifted + * every tile after it). A session not tiled yet still joins there. + * - The header focuses its tile on click, never on a press (the body keeps + * press-to-focus), so a drag that is cancelled changes nothing, focus and + * idle alert included (owner: best practice). * - The header drag is native: Escape or a drop anywhere else ends in a * `dragend` with no drop, which moves nothing and clears what the drag * painted. It carries a type of its own (never text) and is not @@ -22,7 +28,8 @@ * against hand-written tables for every cell and direction of 2x1, 2x2, 3x2 * and the partial 2x2 and 3x2; focus stays on the moved tile. The chords * apply (and are swallowed) only while the grid is open, zoomed included as a - * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word. + * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word + * (the focus chords skip text fields too: tile-grid-shortcuts.test.ts). * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ @@ -170,18 +177,51 @@ describe('dragging a tile by its header', () => { delete (section as unknown as Record).getBoundingClientRect; }); - it('it moves there, the last cell (the tiles after it close up); the slot count stays', () => { - const app = openGrid(['s-a', 's-b', 's-c']); + // Owner: "dont move the tile". A slot is always the last cell, so a move + // there shifted every tile after it; the slot refuses a tile instead. + it('the slot refuses it: held (never reaching anything below), no highlight, nothing moves', () => { + const app = openGrid(['s-a', 's-b', 's-c'], 's-b'); + app.markIdleAlertSeen.mockClear(); + localStore.delete('codeman:tile-grid'); const costsNothing = snapshotCost(); + clearFits(); expect(slots()).toHaveLength(1); - dragTileOnto('s-a', slots()[0]); - expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-a']); - expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['1', '3']); - expect(slots()).toHaveLength(1); + startDrag('s-a'); + const o = over(slots()[0]); + expect(o.preventDefault).toHaveBeenCalled(); + expect(o.stopPropagation).toHaveBeenCalled(); + expect(o.dataTransfer.dropEffect).toBe('none'); expect(slots()[0].classList.contains('tile--drop-target')).toBe(false); - expect(app.activeSessionId).toBe('s-a'); + // A browser sends no drop on a refused target; one that did never reaches the slot's handler. + const toSlot = vi.spyOn(app, 'dropSessionOnSlot'); + drop(slots()[0]); + expect(toSlot).not.toHaveBeenCalled(); + toSlot.mockRestore(); + end('s-a'); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + expect(localStore.has('codeman:tile-grid')).toBe(false); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); costsNothing(); - expect(stored().ids).toEqual(['s-b', 's-c', 's-a']); + }); + + it('so does the tab of a tiled session; a session not tiled yet still joins there', () => { + const app = openGrid(['s-a', 's-b', 's-c']); + app.draggedTabId = 's-a'; + const o = over(slots()[0]); + expect(o.dataTransfer.dropEffect).toBe('none'); + drop(slots()[0]); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); + // dropSessionOnSlot itself refuses a tiled session too. + app.dropSessionOnSlot('s-a'); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); + + app.draggedTabId = 's-other'; + expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move'); + drop(slots()[0]); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c', 's-other']); + expect(app.activeSessionId).toBe('s-other'); }); }); @@ -260,6 +300,55 @@ describe('dragging a tile by its header', () => { }); }); +describe('focus: the header focuses its tile on click, so a cancelled drag changes nothing', () => { + it('a press on the header does not focus the tile; a click does (a human selection)', () => { + const app = openGrid(FOUR, 's-a'); + app.markIdleAlertSeen.mockClear(); + tileEl('s-c').dispatch('pointerdown', { target: nameOf('s-c'), button: 0 }); + tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 }); + expect(app.activeSessionId).toBe('s-a'); + headerOf('s-c').dispatch('click', {}); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); + }); + + it('a press in the body still focuses at once (before the press reaches xterm)', () => { + const app = openGrid(FOUR, 's-a'); + tileEl('s-c').dispatch('pointerdown', { target: tileEl('s-c').children[1], button: 0 }); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a drag of an unfocused tile that is cancelled leaves focus and its alert alone', () => { + const app = openGrid(FOUR, 's-a'); + app.markIdleAlertSeen.mockClear(); + tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 }); + startDrag('s-c'); + over(tileEl('s-b')); + end('s-c'); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(app.activeSessionId).toBe('s-a'); + expect(app._tileGrid.focusedId).toBe('s-a'); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('a double-click on the name leaves the keyboard in the rename input, and its clicks stay in it', () => { + const app = openGrid(FOUR, 's-a'); + app._queueInlineSessionName = vi.fn(); + // The browser's sequence: click, click, dblclick. + headerOf('s-c').dispatch('click', {}); + headerOf('s-c').dispatch('click', {}); + nameOf('s-c').dispatch('dblclick', { stopPropagation: vi.fn() }); + const input = headerOf('s-c').children[2].children[0]; + expect(input.className).toBe('tile-rename-input'); + const terminalFocus = tile('s-c').terminal.focus.mock.invocationCallOrder; + expect(input.focus).toHaveBeenCalled(); + expect(input.focus.mock.invocationCallOrder.at(-1)).toBeGreaterThan(terminalFocus.at(-1) ?? 0); + const click = { stopPropagation: vi.fn() }; + input.dispatch('click', click); + expect(click.stopPropagation).toHaveBeenCalled(); + }); +}); + describe('the handle', () => { it('is the header: draggable, with a tooltip that says so under the state', () => { const app = openGrid(FOUR); @@ -383,19 +472,18 @@ describe('sizes belong to the cells: only a tile whose size changed fits, once', expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); }); - it('a move to the slot: every tile whose cell size changed fits once, the others not', () => { + it('a swap across rows of different heights: the two moved tiles fit, the rest not', () => { section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); try { const five = SIX.slice(0, 5); const app = openGrid(five); - app._tileGrid.colFr = [2, 1, 1]; + app._tileGrid.rowFr = [3, 1]; app._applyTileLayout(); clearFits(); - dragTileOnto('s-a', slots()[0]); - // [a b c / d e _] -> [b c d / e a _]: b (2 -> 1 wide), d (row 1 col 0 -> row 0 col 2: 2 -> 1), - // e (1 -> 2) and a (2 -> 1) changed size; c (1 -> 1) did not. - expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']); - expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 1, 's-e': 1 }); + // [a b c / d e _]: a (row 0) and e (row 1) trade places and heights. + dragTileOnto('s-a', tileEl('s-e')); + expect(app._tileGrid.ids).toEqual(['s-e', 's-b', 's-c', 's-d', 's-a']); + expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 }); } finally { delete (section as unknown as Record).getBoundingClientRect; } diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 5cbab3f3..f260a50f 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -116,6 +116,28 @@ describe('when a chord applies', () => { expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right'); }); + it('the focus chords leave a text field its keys (shifted arrows select there); a terminal still gets them', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + for (const tagName of ['INPUT', 'TEXTAREA']) { + const target = { tagName, closest: () => null, classList: { contains: () => false } }; + expect(app.tileShortcutFor(chord({ ...RIGHT, target }))).toBeNull(); + } + expect( + app.tileShortcutFor(chord({ ...RIGHT, target: { isContentEditable: true, closest: () => null } })) + ).toBeNull(); + // xterm's own input is a textarea too, and the chord is the grid's there. + const xterm = { + tagName: 'TEXTAREA', + closest: () => null, + classList: { contains: (c: string) => c === 'xterm-helper-textarea' }, + }; + expect(app.tileShortcutFor(chord({ ...RIGHT, target: xterm }))).toBe('focus-tile-right'); + // Not an arrow chord: the toggle and zoom still apply from a field. + const input = { tagName: 'INPUT', closest: () => null, classList: { contains: () => false } }; + expect(app.tileShortcutFor(chord({ ...TOGGLE, target: input }))).toBe('toggle-tile-grid'); + }); + it('plain typing and unrelated chords never match', () => { const app = makeGridApp(IDS); app.openTileGrid(IDS); From e702f3c1514432617fc143a5edad2463a1e97d1f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 02:46:23 +0200 Subject: [PATCH 79/90] feat(tiles): pure helpers for a grid whose empty cells can be anywhere Owner: an empty cell need not be the last one ("the empty tab can also be tab nr 4 or 3"). The helpers that let the grid hold cells instead of a packed list, with no behaviour change on their own: - fitTileCells: the cells after a shape change. The same shape keeps every cell, holes included; a new shape keeps each tile at its row and column when all fit (2x2 growing to 3x2: the four tiles stay put), else the tiles pack in reading order. - tileInDirection takes cells: focus never lands on an empty cell. Left and right go along the row past a hole and never leave it; up and down take the nearest row with a tile, the same column else the nearest (lower on a tie). For a packed list this is exactly the old rule, short last row included. - tileCellInDirection: the adjacent cell a Move Tile chord moves into or swaps with. - sanitizeTileGridState reads the stored ids as cells (null for an empty one) and returns them as `cells` beside the packed `ids`; a dropped id becomes a hole, never a shift. The old packed format reads as cells with no hole. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 132 +++++++++++++++++++++++++++------- test/tile-grid-layout.test.ts | 111 +++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 28 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 42dabac0..03d352bd 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1717,11 +1717,17 @@ function tileGridCapacity({ width, height }) { * must be 1 to 3 finite positive numbers. Anything that is not a v1 object * (or its JSON) gives null. * + * The stored `ids` are the grid's CELLS in reading order, `null` for an empty + * one (a hole can be any cell). The old packed list (no nulls) reads as cells + * with no hole. `ids` comes back packed (the tiles in reading order, what + * every list consumer wants) and `cells` keeps the holes: a dropped id (gone, + * detached, a duplicate, past the cap) becomes `null` there, never a shift. + * * @param {unknown} raw - the parsed value, or the stored JSON string * @param {{has(id: string): boolean}|Iterable} liveSessions - ids that exist now * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window - * @returns {{v: 1, open: boolean, ids: string[], focused: string|null, zoomed: string|null, - * colFr: number[]|null, rowFr: number[]|null}|null} + * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null, + * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null} */ function sanitizeTileGridState(raw, liveSessions, detachedIds) { let value = raw; @@ -1731,12 +1737,14 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null; const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []); const ids = []; - for (const id of Array.isArray(value.ids) ? value.ids : []) { - if (typeof id !== 'string' || !id || ids.includes(id)) continue; - if (!live.has(id)) continue; - if (detachedIds?.has?.(id)) continue; - ids.push(id); - if (ids.length === TILE_GRID_MAX) break; + const cells = []; + for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) { + const keep = + typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) && + ids.length < TILE_GRID_MAX; + if (keep) ids.push(id); + // A malformed entry (not a string, not null) is a hole too. + cells.push(keep ? id : null); } const fractions = (fr) => { if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null; @@ -1746,6 +1754,7 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { v: 1, open: value.open === true && ids.length > 0, ids, + cells, focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null), zoomed: ids.includes(value.zoomed) ? value.zoomed : null, colFr: fractions(value.colFr), @@ -1855,31 +1864,102 @@ function tileNeighbor(ids, id) { /** * The tile a directional focus chord moves to, in a row-major grid of `cols` - * columns. Left and right stay within the row; up and down move a whole row, - * and moving down onto a short last row lands on its last tile. Null when - * there is nothing in that direction. + * columns whose empty cells are `null` (a hole can be any cell) or simply + * missing at the end. Focus never lands on a hole. Left and right go along + * the row, past any hole, and never leave it. Up and down take the nearest + * row in that direction that has a tile: the tile in the same column, else + * the one in the nearest column (the lower column on a tie), so moving down + * onto a short or holed last row lands on its nearest tile. Null when there + * is no tile in that direction. * - * @param {string[]} ids - the grid's tiles, in reading order + * @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles) * @param {string} focusedId - the tile the keyboard is in * @param {'left'|'right'|'up'|'down'} direction * @param {number} cols - the layout's column count * @returns {string|null} */ -function tileInDirection(ids, focusedId, direction, cols) { - const n = ids.length; - const i = ids.indexOf(focusedId); - if (i === -1 || n === 0 || !(cols >= 1)) return null; +function tileInDirection(cells, focusedId, direction, cols) { + const i = focusedId ? cells.indexOf(focusedId) : -1; + if (i === -1 || !(cols >= 1)) return null; + const rows = Math.ceil(cells.length / cols); + const row = Math.floor(i / cols); const col = i % cols; - let j = -1; - if (direction === 'left') j = col > 0 ? i - 1 : -1; - else if (direction === 'right') j = col < cols - 1 && i + 1 < n ? i + 1 : -1; - else if (direction === 'up') j = i - cols; - else if (direction === 'down') { - j = i + cols; - const lastRow = Math.floor((n - 1) / cols); - if (j >= n && Math.floor(i / cols) < lastRow) j = n - 1; + const at = (r, c) => cells[r * cols + c] || null; + if (direction === 'left' || direction === 'right') { + const step = direction === 'left' ? -1 : 1; + for (let c = col + step; c >= 0 && c < cols; c += step) { + if (at(row, c)) return at(row, c); + } + return null; } - return j >= 0 && j < n && j !== i ? ids[j] : null; + if (direction !== 'up' && direction !== 'down') return null; + const step = direction === 'up' ? -1 : 1; + for (let r = row + step; r >= 0 && r < rows; r += step) { + let best = null; + let bestDistance = Infinity; + for (let c = 0; c < cols; c++) { + const id = at(r, c); + if (id && Math.abs(c - col) < bestDistance) { + best = id; + bestDistance = Math.abs(c - col); + } + } + if (best) return best; + } + return null; +} + +/** + * The cell next to cell `index` in that direction (Move Tile: a tile moves + * into an empty neighbour cell, or swaps with a tiled one), or -1 at the + * edge. Adjacent only: a move never jumps over a cell. + * + * @param {number} index - the cell, in reading order + * @param {'left'|'right'|'up'|'down'} direction + * @param {number} cols - the layout's column count + * @param {number} cellCount - cols x rows + * @returns {number} + */ +function tileCellInDirection(index, direction, cols, cellCount) { + if (!(cols >= 1) || index < 0 || index >= cellCount) return -1; + const col = index % cols; + let j = -1; + if (direction === 'left') j = col > 0 ? index - 1 : -1; + else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1; + else if (direction === 'up') j = index - cols; + else if (direction === 'down') j = index + cols; + return j >= 0 && j < cellCount ? j : -1; +} + +/** + * The grid's cells after its shape changed (or to fill one for the first + * time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps + * every cell as it is. A new shape keeps each tile at its row and column when + * every tile still fits there (2x2 growing to 3x2: the four tiles stay put), + * and otherwise packs the tiles in reading order from the first cell, holes + * collapsed (positions do not map between shapes). `oldCols` 0 (no layout + * yet) always packs. + * + * @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide + * @param {number} oldCols - the column count they were laid out with + * @param {number} cols + * @param {number} rows + * @returns {(string|null)[]} + */ +function fitTileCells(cells, oldCols, cols, rows) { + const size = Math.max(0, cols * rows); + if (oldCols === cols && cells.length === size) return cells.slice(); + const out = new Array(size).fill(null); + const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null)); + const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols)); + if (keep) { + for (const p of placed) if (p) out[p.row * cols + p.col] = p.id; + return out; + } + cells.filter(Boolean).slice(0, size).forEach((id, k) => { + out[k] = id; + }); + return out; } /** @@ -2204,6 +2284,8 @@ if (typeof window !== 'undefined') { dragTrackFractions, tileNeighbor, tileInDirection, + tileCellInDirection, + fitTileCells, cycleTile, tileGridOpenSet, TILE_GRID_MAX, diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index 4eeb2814..4540b705 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -6,9 +6,17 @@ * cell clears the minimum tile size. * - `tileGridCapacity`: how many tiles a grid area can hold. * - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to - * apply (unknown, deleted, detached and duplicate ids dropped). + * apply (unknown, deleted, detached and duplicate ids dropped); the stored + * `ids` are the grid's cells, `null` for a hole, and come back as `cells` + * (holes kept, a dropped id a hole) beside the packed `ids`. The old packed + * format reads as cells with no hole. + * - `fitTileCells`: the cells after a shape change (owner: an empty cell can be + * any cell). Same shape: unchanged; a new one: each tile keeps its row and + * column when all fit, else the tiles pack in reading order. * - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when - * one leaves, on a directional chord, and on Ctrl+Tab / Alt+[ ]. + * one leaves, on a directional chord (over cells: never onto a hole), and on + * Ctrl+Tab / Alt+[ ]. `tileCellInDirection`: the adjacent cell a Move Tile + * chord moves into or swaps with. * * Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A. */ @@ -23,7 +31,9 @@ type TileGrid = { tileGridCapacity(p: Record): number; sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set): Record | null; tileNeighbor(ids: string[], id: string): string | null; - tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null; + tileInDirection(cells: Array, focused: string, dir: string, cols: number): string | null; + tileCellInDirection(index: number, dir: string, cols: number, cellCount: number): number; + fitTileCells(cells: Array, oldCols: number, cols: number, rows: number): Array; cycleTile(ids: string[], focused: string, delta: number): string | null; TILE_GRID_MAX: number; TILE_LAYOUT_MAX: number; @@ -111,6 +121,7 @@ describe('sanitizeTileGridState', () => { v: 1, open: true, ids: ['a', 'b'], + cells: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], @@ -161,6 +172,33 @@ describe('sanitizeTileGridState', () => { expect(out?.rowFr).toBeNull(); }); + it('stored cells keep their holes; the list beside them is packed', () => { + const raw = { v: 1, open: true, ids: ['a', null, 'b', 'c', 'd', null], focused: 'c' }; + const out = T.sanitizeTileGridState(raw, live); + expect(out?.cells).toEqual(['a', null, 'b', 'c', 'd', null]); + expect(out?.ids).toEqual(['a', 'b', 'c', 'd']); + expect(out?.focused).toBe('c'); + }); + + it('a dropped id (gone, detached, a duplicate, malformed) leaves a hole where it was, never a shift', () => { + const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 7, 'c'] }; + const out = T.sanitizeTileGridState(raw, live, new Set(['b'])); + expect(out?.cells).toEqual(['a', null, null, null, null, 'c']); + expect(out?.ids).toEqual(['a', 'c']); + }); + + it('the old packed format reads unchanged: cells with no hole', () => { + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a', 'b', 'c'] }, live); + expect(out?.cells).toEqual(['a', 'b', 'c']); + expect(out?.ids).toEqual(['a', 'b', 'c']); + }); + + it('past the cap a tile becomes a hole; no more cells than the largest layout', () => { + const many = Array.from({ length: 12 }, (_, i) => `s${i}`); + const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many); + expect(out?.cells).toEqual([...many.slice(0, 6), null, null, null]); + }); + it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => { expect(T.sanitizeTileGridState(raw, live)).toBeNull(); }); @@ -190,6 +228,47 @@ describe('focus helpers', () => { expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull(); }); + it('tileInDirection never lands on a hole: along a row it skips one, never leaving the row', () => { + // 3x2: a _ c + // d e f + const cells = ['a', null, 'c', 'd', 'e', 'f']; + expect(T.tileInDirection(cells, 'a', 'right', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'c', 'left', 3)).toBe('a'); + expect(T.tileInDirection(cells, 'c', 'right', 3)).toBeNull(); + expect(T.tileInDirection(cells, 'd', 'left', 3)).toBeNull(); + // Up from e (a hole above): the nearest tile in that row, the lower column on a tie. + expect(T.tileInDirection(cells, 'e', 'up', 3)).toBe('a'); + expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('d'); + }); + + it('tileInDirection: up and down go to the nearest row that has a tile, even across a column', () => { + // 3x2: a b c + // _ _ f + const cells = ['a', 'b', 'c', null, null, 'f']; + expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('f'); + expect(T.tileInDirection(cells, 'c', 'down', 3)).toBe('f'); + expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c'); + expect(T.tileInDirection(cells, 'f', 'left', 3)).toBeNull(); + // A row with no tile at all is passed over (a 3x3, which the layout table keeps). + const tall = ['a', null, null, null, null, null, null, 'h', null]; + expect(T.tileInDirection(tall, 'a', 'down', 3)).toBe('h'); + expect(T.tileInDirection(tall, 'h', 'up', 3)).toBe('a'); + // A hole is never the focused cell either. + expect(T.tileInDirection(cells, null as unknown as string, 'up', 3)).toBeNull(); + }); + + it('tileCellInDirection: the adjacent cell, or -1 at the edge', () => { + // 3x2 cells 0 1 2 / 3 4 5 + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'left', 3, 6))).toEqual([-1, 0, 1, -1, 3, 4]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'right', 3, 6))).toEqual([1, 2, -1, 4, 5, -1]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'up', 3, 6))).toEqual([-1, -1, -1, 0, 1, 2]); + expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'down', 3, 6))).toEqual([3, 4, 5, -1, -1, -1]); + // 2x2 + expect([0, 1, 2, 3].map((i) => T.tileCellInDirection(i, 'down', 2, 4))).toEqual([2, 3, -1, -1]); + expect(T.tileCellInDirection(7, 'left', 3, 6)).toBe(-1); + }); + it('cycleTile wraps in reading order', () => { expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a'); expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c'); @@ -197,6 +276,32 @@ describe('focus helpers', () => { }); }); +describe('fitTileCells (the cells after a shape change)', () => { + it('the same shape keeps every cell, holes included', () => { + expect(T.fitTileCells(['a', null, 'c', 'd', 'e', 'f'], 3, 3, 2)).toEqual(['a', null, 'c', 'd', 'e', 'f']); + }); + + it('a first layout (no columns yet) packs and pads', () => { + expect(T.fitTileCells(['a', 'b', 'c', 'd', 'e'], 0, 3, 2)).toEqual(['a', 'b', 'c', 'd', 'e', null]); + expect(T.fitTileCells(['a', 'b', 'c'], 0, 2, 2)).toEqual(['a', 'b', 'c', null]); + }); + + it('growing 2x2 to 3x2: every tile keeps its row and column', () => { + expect(T.fitTileCells(['a', 'b', 'c', 'd'], 2, 3, 2)).toEqual(['a', 'b', null, 'c', 'd', null]); + // 2x1 to 2x2 likewise (the same as packing there). + expect(T.fitTileCells(['a', 'b'], 2, 2, 2)).toEqual(['a', 'b', null, null]); + }); + + it('shrinking: kept where every tile fits, else packed in reading order', () => { + // 3x2 -> 2x2 with the third column empty: the tiles stay put. + expect(T.fitTileCells(['a', 'b', null, 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'b', 'd', 'e']); + // A tile in the third column: packed, holes collapsed. + expect(T.fitTileCells(['a', null, 'c', 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'c', 'd', 'e']); + // 2x2 -> 3x1 (a wider window, three tiles): row 1 does not fit, packed. + expect(T.fitTileCells(['a', null, 'c', 'd'], 2, 3, 1)).toEqual(['a', 'c', 'd']); + }); +}); + describe('dragTrackFractions (divider drags)', () => { const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) => (T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min); From 45ca9c347b85ec3ee1a6b00787af034570675070 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 03:08:14 +0200 Subject: [PATCH 80/90] feat(tiles): the grid is cells, and an empty cell can be any cell Owner feedback 1: "so the empty tab doesnt always have to be the last one! so I can move freely around and the empty tab can also be tab nr 4 or 3". This replaces the slot refusal of aecada8c. grid.cells (a session id or null per cell) is now the one source of truth; grid.ids is a getter deriving the tiles in reading order, so everything that only wants the tiled sessions (focus neighbour, cycling, the load queue's order, the picker, closeSession) is unchanged. The shape still comes from the tile count and the cap counts tiles, never empty cells. - A tile dragged onto an empty cell moves there and leaves its own cell empty, nothing else moving (_moveTileToCell, through _reorderTiles: no remount, reconnect or reload; only a tile whose cell size changed fits). A tiled session's tab does the same; a tab of a session not tiled yet joins in the cell it is dropped on. Each slot knows its cell and reads "Drop a tab or a tile here". - Move Tile goes to the adjacent cell: into it when empty, a swap when a tile is there (tileCellInDirection). - Removing a tile leaves its cell empty; adding one takes the first empty cell. A shape change goes through fitTileCells: each tile keeps its row and column when all fit (2x2 growing to 3x2), else the tiles pack in reading order. - Focus never lands on an empty cell: Alt+Shift+Arrows run over the cells, Ctrl+Tab and Alt+[ ] over the tiles. - codeman:tile-grid stays ids only: its ids are the cells with null for an empty one. A reload brings the holes back when the shape is the same (a session gone since leaves its cell empty), another shape packs, the old packed format reads unchanged, and a followed #session= link keeps the holes. Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/tile-grid-plan.md | 66 +++--- docs/wiki/Keyboard-Shortcuts.md | 4 +- docs/wiki/Tile-Grid.md | 23 ++- src/web/public/i18n.js | 2 +- src/web/public/tile-grid.js | 253 +++++++++++++++-------- test/tile-grid-cells.test.ts | 342 ++++++++++++++++++++++++++++++++ test/tile-grid-i18n.test.ts | 2 +- test/tile-grid-move.test.ts | 202 +++++++++++++------ 10 files changed, 718 insertions(+), 182 deletions(-) create mode 100644 test/tile-grid-cells.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c3fcc81c..56e3dbdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed, and an empty slot refuses a tiled session (owner). The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6c0f7347..a477ed80 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -813,13 +813,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. -⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own, an empty slot only a session not tiled yet, so a tile is never moved to a slot; a refused drag is still held (`dropEffect: 'none'`, no highlight). Every move (that drag, a tiled tab's drop on a tile, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. +⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), each tile and each empty slot placed explicitly in its cell; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own (its own is held but refused, `dropEffect: 'none'`, no highlight), an empty slot anything. ⚠️ **The grid is cells** (owner: an empty cell can be any cell): `grid.cells` (a session id or `null` per cell, cols x rows of them) is the ONE source of truth and `grid.ids` a getter deriving the tiles in reading order, so never write `ids` (a leftover write in the non-strict `Object.assign` methods would be silently ignored). The shape comes from the tile count; within it a tile moved onto an empty cell goes there and leaves its own cell empty (`_moveTileToCell`), a removed tile leaves a hole, an added one takes the first hole or the cell a tab was dropped on (`_placeTile`), and a shape change goes through the pure `fitTileCells` (keep row and column when all fit, else pack). Each slot carries its cell (`data-cell`). The focus chords run `tileInDirection` over the CELLS (never onto a hole; the nearest tile in the nearest row for up/down), the Move Tile chords `tileCellInDirection` (the adjacent cell: into it when empty, a swap when not). Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. ⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. -⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. +⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants; a restore puts the holes back only when the cell count matches the current shape, else the tiles stay packed, and `_closeStoredTileGrid` writes the cells back, never the packed list. The old packed format reads as cells with no hole; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. ### Gesture control: the setting diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index c2b2b563..0727a237 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -21,8 +21,8 @@ or settled a question the spec left open. The invariants as built are in the setting off the toggle chord is inert. A grid opened another way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords. - **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the - grid gap is 0) and tiles are placed explicitly in reading order, which is also what the - empty-slot drop targets need. Fractions reset when the column or row count changes. + grid gap is 0) and every tile and every empty slot is placed explicitly in its cell + (`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes. - **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom (window too small for the minimum tile) follows focus instead. - **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane @@ -68,28 +68,46 @@ or settled a question the spec left open. The invariants as built are in batches, so lifecycle and hook events are unaffected, and leaving the grid re-subscribes the shown session. - **Tiles move** (owner request: "give me the option to move the tiles around"; not a - numbered decision). A tile's header, its free area (not the buttons, not the rename - input), drags it onto another tile and the two trade places. An empty slot refuses a tile, - its header drag and its tab alike (owner's answer, "dont move the tile": a slot is always - the last cell, so a move there shifted every tile after it); a session not tiled yet still - joins there. It is a native drag through the tab drop targets (capture phase, stopped - before xterm), carrying a type of its own and never text, and it is not `draggedTabId`, so - neither a text field nor the tab strip takes it; Escape or a drop anywhere else cancels - with nothing changed, focus included: the header focuses its tile on click, not on press - (owner's answer: best practice; the body keeps press-to-focus, so focus moves before a - press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile Left/Right/Up/Down, registry, - rebindable) swap the focused tile with the neighbour the focus chords pick, and focus - stays on it. Every move, a tiled tab's drop included, goes through `_reorderTiles`: no - remount, reconnect or reload; divider sizes belong to the cells, so only a tile whose cell - size changed fits (one PTY resize, #464). Moving is off while a tile is zoomed (the chords - still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on - the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow - chord families, focus and move, skip a text field, where shifted arrows select (owner's - answer: best practice). Default keys: every other two-modifier arrow chord is taken - (Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace in - GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by - the browsers, GNOME, KDE, macOS and Claude Code, and costs only a terminal editor's word - selection inside a tile while the grid is open. + numbered decision). The grid is CELLS, not a packed list (owner: "the empty tab doesnt + always have to be the last one ... it can also be tab nr 4 or 3"): `grid.cells` holds a + session id or `null` per cell and is the one source of truth, `grid.ids` the tiles in + reading order derived from it. The shape still comes from the tile count (the layout + table), the cap counts tiles, never empty cells, and an empty cell can be any cell (in + practice one at most: a 3x2 holds 5 or 6 tiles, a 2x2 3 or 4). A tile's header, its free + area (not the buttons, not the rename input), drags it: onto another tile the two trade + places, onto an empty cell it moves there and leaves its own cell empty, nothing else + moving; a tiled session's tab does the same, and a tab of a session not tiled yet joins in + the cell it is dropped on. It is a native drag through the tab drop targets (capture + phase, stopped before xterm), carrying a type of its own and never text, and it is not + `draggedTabId`, so neither a text field nor the tab strip takes it; Escape or a drop + anywhere else cancels with nothing changed, focus included: the header focuses its tile on + click, not on press (owner's answer: best practice; the body keeps press-to-focus, so + focus moves before a press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile + Left/Right/Up/Down, registry, rebindable) move the focused tile to the adjacent cell: into + it when empty, trading places when a tile is there, never jumping a cell; focus stays on + it. Every move goes through `_reorderTiles`: no remount, reconnect or reload; divider + sizes belong to the cells, so only a tile whose cell size changed fits (one PTY resize, + #464). Removing a tile leaves its cell empty where it was, and adding one takes the first + empty cell (or the one a tab was dropped on), while the shape stays; a shape change + (`fitTileCells`, constants.js) keeps each tile's row and column when all fit and otherwise + packs the tiles in reading order, which differs from plain packing only when a 2x2 grows + to a 3x2 (the four tiles stay put). Focus never lands on an empty cell: Alt+Shift+Arrows + go along the row past one, or to the nearest row with a tile (the same column, else the + nearest), and Ctrl+Tab and Alt+[ / ] cycle the tiles only. `codeman:tile-grid` stays ids + only: its `ids` are the cells, `null` for an empty one (a build before cells drops the + nulls and reads them packed); a reload brings the holes back when the shape is the same, a + session gone by then leaves its cell empty, another shape packs, and the old packed format + reads unchanged. A fresh grid (the picker's Open, Ctrl/Cmd+click with the grid closed, + "Open group as tiles") opens packed; only the toggle and the page-load restore bring holes + back. Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so + their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as + the owner confirmed) and with a single tile. Both arrow chord families, focus and move, + skip a text field, where shifted arrows select (owner's answer: best practice). Default + keys: every other two-modifier arrow chord is taken (Ctrl+Alt switches workspaces, + Ctrl+Alt+Shift moves a window to another workspace in GNOME, Alt is back/forward, + Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by the browsers, GNOME, KDE, + macOS and Claude Code, and costs only a terminal editor's word selection inside a tile + while the grid is open. - **A tile that joins before its pane exists resends its size when the pid appears** (`TerminalTile.paneStarted()`): the server drops a resize for a session with no PTY and spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index f289513a..9928e84e 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -45,8 +45,8 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | --------------------------- | ------------------------------------------------------------ | | `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). | | `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | -| `Ctrl+Shift+Arrows` | Move the focused tile: it trades places with that neighbour. | -| Drag a tile's header | Move the tile: dropped on another tile, the two swap. | +| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. | +| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | | Right-click the Tiles button | Choose which sessions to show as tiles. | diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index c42a79c2..31d4b9ba 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -65,18 +65,23 @@ whose agent exited inside its pane says so instead; close that session from `⋯ ## Moving tiles Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade -places; the dropped tile takes the focus. Empty slots are for adding sessions (drop a tab -there), so a tile cannot be dropped on one. Press `Escape` or let go anywhere else and nothing -changes, not even which tile has the focus: a header focuses its tile when you click it, not -when you press it. +places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else +moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press +`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a +header focuses its tile when you click it, not when you press it. -With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile left, right, up or down: it -trades places with the tile next to it (the one `Alt+Shift+Arrows` would focus) and keeps the -focus. +With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or +down: into the empty slot if that is the place, else trading places with the tile there. It +keeps the focus. A moved tile takes the size of the place it lands in: column widths and row heights stay -where you dragged the dividers. Tiles do not move while one is zoomed. The new order is -saved with the grid. +where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is, +the empty slot included, is saved with the grid and comes back on reload. + +Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a +new tile takes the first empty place. When the number of tiles changes the grid's shape (four +tiles to five is two columns to three), the tiles keep their places if they still fit, or line +up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot. ## Keys diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 3e9e03fa..e0cb2f48 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -140,7 +140,7 @@ 'Zoom this tile': '放大此窗格', 'Restore the grid': '恢复平铺网格', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', - 'Drop a tab here': '将标签页拖放到此处', + 'Drop a tab or a tile here': '将标签页或窗格拖放到此处', // A tile header's tooltip while tiles can move (with the state above it: a pattern below). 'Drag to move the tile': '拖动可移动窗格', 'Resize tile columns': '调整窗格列宽', diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b7d2c1ea..b3699c8a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -24,7 +24,8 @@ // Per-device tile font size (a tile is a fraction of the screen). const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // The grid this device last had, ids only (sanitizeTileGridState, constants.js): -// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`. `open: false` keeps it +// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells +// in reading order with `null` for an empty one. `open: false` keeps it // remembered for one-click return; restored on reload inside handleInit. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // Trailing debounce for refitting tiles after the grid area changes size, so a @@ -72,8 +73,10 @@ function isTextFieldTarget(target) { class TileGridModel { constructor() { this.open = false; - // Session ids in reading order (row-major). - this.ids = []; + // The grid's cells in reading order (row-major), cols x rows of them: a + // session id, or null for an empty cell. THE source of truth for where + // each tile is (owner: an empty cell can be any cell); `ids` derives from it. + this.cells = []; // id -> { tile: TerminalTile, el: HTMLElement } this.tiles = new Map(); this.focusedId = null; @@ -98,6 +101,11 @@ class TileGridModel { has(id) { return this.open && this.tiles.has(id); } + + /** The tiled sessions in reading order, holes skipped (a fresh array: change `cells`, never this). */ + get ids() { + return this.cells.filter(Boolean); + } } Object.assign(CodemanApp.prototype, { @@ -225,7 +233,9 @@ Object.assign(CodemanApp.prototype, { // non-shell session) only ever produced a copy that was thrown away. this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) }); grid.open = true; - grid.ids = []; + grid.cells = []; + grid.cols = 0; + grid.rows = 0; grid.focusedId = focus; document.querySelector('.main')?.classList.add('tiles-active'); const section = this._tileGridSection(); @@ -234,6 +244,8 @@ Object.assign(CodemanApp.prototype, { // first fit measures its real cell; the focused tile connects first, so // its capture is the one the queue starts with. for (const id of wanted) this._mountTile(id); + // Packed from the first cell (_applyTileLayout pads the shape with empty cells). + grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { @@ -291,7 +303,9 @@ Object.assign(CodemanApp.prototype, { grid.slots = []; grid.colFr = []; grid.rowFr = []; - grid.ids = []; + grid.cells = []; + grid.cols = 0; + grid.rows = 0; grid.focusedId = null; grid.zoomedId = null; grid.autoZoom = false; @@ -624,7 +638,8 @@ Object.assign(CodemanApp.prototype, { focusTileInDirection(direction) { const grid = this._tileGrid; if (!grid?.open) return; - const id = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols); + // Over the cells: focus skips an empty one, never lands on it. + const id = window.CodemanTileGrid.tileInDirection(grid.cells, grid.focusedId, direction, grid.cols); if (id) this.selectSession(id); }, @@ -672,12 +687,17 @@ Object.assign(CodemanApp.prototype, { } }, - /** Adds one session as a tile (open grid only). Returns whether it was added. */ - addTile(sessionId) { + /** + * Adds one session as a tile (open grid only), into `cell` when that cell is + * empty (a tab dropped on it), else the first empty cell in reading order + * (_placeTile). Returns whether it was added. + */ + addTile(sessionId, { cell = -1 } = {}) { const grid = this._tileGrid; if (!grid?.open || grid.tiles.has(sessionId)) return false; if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; if (!this._mountTile(sessionId)) return false; + this._placeTile(sessionId, cell); // A tile added while one is zoomed by hand is meant to be seen. if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; this._applyTileLayout(); @@ -688,7 +708,37 @@ Object.assign(CodemanApp.prototype, { }, /** - * Removes one tile; the session keeps running. When it held focus, `refocus` + * Puts a tile just mounted into a cell. The shape for one more tile comes + * first (fitTileCells: the same shape keeps every cell; 2x2 growing to 3x2 + * keeps each tile where it is), then `cell` if it is empty there, else the + * first empty cell in reading order. + */ + _placeTile(sessionId, cell = -1) { + const grid = this._tileGrid; + const { cols, rows } = this._tileShapeFor(grid.ids.length + 1); + grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows); + grid.cols = cols; + grid.rows = rows; + const k = Number.isInteger(cell) && grid.cells[cell] === null ? cell : grid.cells.indexOf(null); + // (No empty cell cannot happen: the shape for n tiles has at least n cells. + // Appended, the next layout packs it in.) + if (k === -1) grid.cells.push(sessionId); + else grid.cells[k] = sessionId; + }, + + /** Columns x rows (and whether they fit) for `count` tiles in the grid area as it is now. */ + _tileShapeFor(count) { + const rect = this._tileGridSection().getBoundingClientRect?.() || { width: 0, height: 0 }; + return window.CodemanTileGrid.computeTileLayout({ + count, + width: rect.width || window.innerWidth, + height: rect.height || window.innerHeight, + }); + }, + + /** + * Removes one tile; the session keeps running. Its cell becomes empty where + * it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus` * moves focus to the neighbouring tile (next in grid order, else previous), * as the app's choice (`auto`: no idle alert is spent). The last tile * leaving closes the grid: with `refocus` the single view then shows that @@ -714,7 +764,7 @@ Object.assign(CodemanApp.prototype, { this._destroyTerminalTile(entry.tile); entry.el.remove(); grid.tiles.delete(sessionId); - grid.ids.splice(grid.ids.indexOf(sessionId), 1); + grid.cells[grid.cells.indexOf(sessionId)] = null; // Before the layout, which may zoom the focused tile on a small window. if (wasFocused) grid.focusedId = null; this._applyTileLayout(); @@ -775,7 +825,7 @@ Object.assign(CodemanApp.prototype, { // The pid this tile last saw, so a pane that starts later is noticed. pid: this.sessions.get(sessionId)?.pid ?? null, }); - grid.ids.push(sessionId); + // Where it goes is the caller's (grid.cells). this._renderTileHeader(sessionId); return true; }, @@ -786,8 +836,8 @@ Object.assign(CodemanApp.prototype, { * (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event * stopped: a tab drag carries the session id as text, and xterm's helper * textarea would otherwise accept that drop and type the id into a PTY. - * `accepts(id)` is the target's own rule (a tile: any session but its own; - * an empty slot: only a session not tiled yet). A session it does not accept + * `accepts(id)` is the target's own rule (a tile takes any session but its + * own; an empty cell takes any). A session it does not accept * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no * drop follows). Any other drag (a file) is left alone. */ @@ -830,58 +880,77 @@ Object.assign(CodemanApp.prototype, { }, /** - * Puts the tiles in a new reading order (the same sessions): the one path - * every move takes, a header drag, the tab of a tiled session dropped on a - * tile, and the Move Tile chords. Nothing is remounted, reconnected or - * reloaded, and no session joins or leaves. Divider sizes belong to the - * cells, so a moved tile takes its new cell's size: each tile whose cell - * size changed fits once (its xterm and one PTY resize together, #464), - * every other tile is left alone. Refused while a tile is zoomed (moving is - * off then). + * Puts the tiles in new cells (the same sessions, the same shape): the one + * path every move takes, a header drag, the tab of a tiled session dropped + * on a tile or an empty cell, and the Move Tile chords. Nothing is + * remounted, reconnected or reloaded, and no session joins or leaves. + * Divider sizes belong to the cells, so a moved tile takes its new cell's + * size: each tile whose cell size changed fits once (its xterm and one PTY + * resize together, #464), every other tile is left alone. Refused while a + * tile is zoomed (moving is off then). * - * @param {string[]} ids - the new order + * @param {(string|null)[]} cells - the new cells * @returns {boolean} false when refused, true otherwise (also when nothing moved) */ - _reorderTiles(ids) { + _reorderTiles(cells) { const grid = this._tileGrid; - if (!grid?.open || grid.zoomedId) return false; - if (ids.length !== grid.ids.length || ids.some((id) => !grid.tiles.has(id))) return false; - if (ids.every((id, k) => grid.ids[k] === id)) return true; + if (!grid?.open || grid.zoomedId || cells.length !== grid.cells.length) return false; + const tiles = cells.filter(Boolean); + if (tiles.length !== grid.tiles.size || new Set(tiles).size !== tiles.length) return false; + if (tiles.some((id) => !grid.tiles.has(id))) return false; + if (cells.every((id, k) => (id || null) === grid.cells[k])) return true; // A divider drag in progress measured the tiles at their old places. this._tileDividerDragTeardown?.(); const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`; - const before = new Map(grid.ids.map((id, k) => [id, cell(k)])); - grid.ids = ids.slice(); + const before = new Map(); + grid.cells.forEach((id, k) => id && before.set(id, cell(k))); + grid.cells = cells.map((id) => id || null); this._applyTileLayout(); // Synchronous: the fit's measurement forces the new placement's layout. - grid.ids.forEach((id, k) => { - if (before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit(); + grid.cells.forEach((id, k) => { + if (id && before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit(); }); return true; }, - /** Two tiles trade places (_reorderTiles). */ + /** Two tiles trade cells (_reorderTiles). */ _swapTiles(a, b) { - const ids = this._tileGrid.ids.slice(); - const i = ids.indexOf(a); - const j = ids.indexOf(b); + const cells = this._tileGrid.cells.slice(); + const i = cells.indexOf(a); + const j = cells.indexOf(b); if (i === -1 || j === -1) return false; - ids[i] = b; - ids[j] = a; - return this._reorderTiles(ids); + cells[i] = b; + cells[j] = a; + return this._reorderTiles(cells); }, /** - * Move Tile Left/Right/Up/Down: the focused tile trades places with its - * neighbour in that direction, the neighbour the Alt+Shift+Arrow focus - * chords pick (tileInDirection). Focus stays on the moved tile. Nothing while - * a tile is zoomed, or at an edge. + * A tile moves into empty cell `k`, leaving its own cell empty: nothing else + * moves (_reorderTiles). False when `k` is not an empty cell, or moving is off. + */ + _moveTileToCell(sessionId, k) { + const cells = this._tileGrid.cells.slice(); + const from = cells.indexOf(sessionId); + if (from === -1 || !Number.isInteger(k) || cells[k] !== null) return false; + cells[from] = null; + cells[k] = sessionId; + return this._reorderTiles(cells); + }, + + /** + * Move Tile Left/Right/Up/Down: the focused tile goes to the cell next to it + * in that direction (tileCellInDirection, never jumping a cell): into it + * when it is empty, trading places when a tile is there. Focus stays on the + * moved tile. Nothing while a tile is zoomed, or at an edge. */ moveTileInDirection(direction) { const grid = this._tileGrid; if (!grid?.open || grid.zoomedId || !grid.focusedId) return; - const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols); - if (neighbor) this._swapTiles(grid.focusedId, neighbor); + const from = grid.cells.indexOf(grid.focusedId); + const to = window.CodemanTileGrid.tileCellInDirection(from, direction, grid.cols, grid.cells.length); + if (to === -1) return; + if (grid.cells[to]) this._swapTiles(grid.focusedId, grid.cells[to]); + else this._moveTileToCell(grid.focusedId, to); }, /** @@ -899,11 +968,9 @@ Object.assign(CodemanApp.prototype, { if (!this._swapTiles(draggedId, targetId)) return; } else { this._tileDividerDragTeardown?.(); - const index = grid.ids.indexOf(targetId); if (!this._mountTile(draggedId)) return; - // _mountTile appended it; it takes the replaced tile's place instead. - grid.ids.pop(); - grid.ids.splice(index, 1, draggedId); + // It takes the replaced tile's cell. + grid.cells[grid.cells.indexOf(targetId)] = draggedId; const old = grid.tiles.get(targetId); this._destroyTerminalTile(old.tile); old.el.remove(); @@ -919,13 +986,20 @@ Object.assign(CodemanApp.prototype, { }, /** - * A tab dropped on an empty slot joins the grid there. A session already - * tiled is not moved to a slot (the slot refuses it, see _syncTileSlots). + * Something dropped on empty cell `cell`: a tab of a session not tiled yet + * joins the grid in that cell; a tiled session (its tab, or the tile dragged + * by its header) moves into it, leaving its own cell empty + * (_moveTileToCell, refused while a tile is zoomed). Either way the dropped + * session takes focus (a human selection). */ - dropSessionOnSlot(draggedId) { + dropSessionOnSlot(draggedId, cell) { const grid = this._tileGrid; if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; - if (grid.tiles.has(draggedId) || !this.addTile(draggedId)) return; + if (grid.tiles.has(draggedId)) { + if (!this._moveTileToCell(draggedId, cell)) return; + } else if (!this.addTile(draggedId, { cell })) { + return; + } this.selectSession(draggedId); }, @@ -1249,9 +1323,10 @@ Object.assign(CodemanApp.prototype, { }, /** - * The header moves its tile: dragged onto another tile the two trade places - * (dropSessionOnTile, the path a dragged tab takes, through the same - * capture-phase drop targets, _acceptTabDrops); an empty slot refuses it. A native drag, so Escape and a drop anywhere else are the + * The header moves its tile: dragged onto another tile the two trade places, + * onto an empty cell it moves there and leaves its own cell empty + * (dropSessionOnTile / dropSessionOnSlot, the path a dragged tab takes, + * through the same capture-phase drop targets, _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the * browser's own cancel: nothing moves, and dragend clears what the drag * painted. The drag carries a type of its own and never text, so no text * field or terminal, in this page or another application, can take it as @@ -1444,16 +1519,18 @@ Object.assign(CodemanApp.prototype, { this.removeTile(sessionId); }, - /** Columns x rows for the current tile count, applied to the grid section. */ + /** + * Columns x rows for the current tile count, applied to the grid section. + * The shape comes from the count alone; within it each tile sits in its + * cell (grid.cells) and any cell may be empty. When the shape changes, the + * cells follow fitTileCells (each tile keeps its row and column if all fit, + * else the tiles pack in reading order). + */ _applyTileLayout() { const grid = this._tileGrid; const section = this._tileGridSection(); - const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 }; - const { cols, rows, fits } = window.CodemanTileGrid.computeTileLayout({ - count: grid.ids.length, - width: rect.width || window.innerWidth, - height: rect.height || window.innerHeight, - }); + const { cols, rows, fits } = this._tileShapeFor(grid.ids.length); + grid.cells = window.CodemanTileGrid.fitTileCells(grid.cells, grid.cols, cols, rows); if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1); if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1); grid.cols = cols; @@ -1476,18 +1553,23 @@ Object.assign(CodemanApp.prototype, { for (const entry of grid.tiles.values()) this._paintTileHandle(entry); // Zoomed: one cell; the other tiles stay connected but hidden (CSS), so // they measure nothing and send no resize. Otherwise every tile is placed - // explicitly in reading order, with a divider track between columns and - // between rows. + // explicitly in its cell, with a divider track between columns and between + // rows, and every empty cell holds a drop slot. section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr); section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr); - grid.ids.forEach((id, k) => { + const holes = []; + grid.cells.forEach((id, k) => { + if (!id) { + holes.push(k); + return; + } const el = grid.tiles.get(id)?.el; if (!el) return; el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1); el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1); }); this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows); - this._syncTileSlots(zoomed ? 0 : cols * rows - grid.ids.length, cols); + this._syncTileSlots(zoomed ? [] : holes, cols); this._persistTileGrid(); }, @@ -1511,26 +1593,23 @@ Object.assign(CodemanApp.prototype, { }, // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a - // 3x2): drop targets for a tab, after the tiles in reading order. - _syncTileSlots(count, cols) { + // 3x2), wherever they are: drop targets for a tab (it joins there) and for a + // tile (it moves there). Each slot knows its cell (`data-cell`). + _syncTileSlots(holes, cols) { const grid = this._tileGrid; grid.slots ||= []; - while (grid.slots.length > count) grid.slots.pop().remove(); - while (grid.slots.length < count) { + while (grid.slots.length > holes.length) grid.slots.pop().remove(); + while (grid.slots.length < holes.length) { const slot = document.createElement('div'); slot.className = 'tile-slot'; - slot.textContent = 'Drop a tab here'; - // Only a session not tiled yet: a tile is never moved to a slot (owner: - // "dont move the tile"; a slot is always last, so a move there shifted - // every tile after it). - this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId), { - accepts: (id) => !this._tileGrid?.tiles.has(id), - }); + slot.textContent = 'Drop a tab or a tile here'; + this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId, Number(slot.dataset.cell))); this._tileGridSection().appendChild(slot); grid.slots.push(slot); } grid.slots.forEach((slot, i) => { - const k = grid.ids.length + i; + const k = holes[i]; + slot.dataset.cell = String(k); slot.style.gridColumn = String(2 * (k % cols) + 1); slot.style.gridRow = String(2 * Math.floor(k / cols) + 1); }); @@ -1596,7 +1675,8 @@ Object.assign(CodemanApp.prototype, { const start = isCol ? e.clientX : e.clientY; const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H; const affected = []; - grid.ids.forEach((id, k) => { + grid.cells.forEach((id, k) => { + if (!id) return; const track = isCol ? k % grid.cols : Math.floor(k / grid.cols); if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile); }); @@ -1799,7 +1879,10 @@ Object.assign(CodemanApp.prototype, { const state = { v: 1, open, - ids: grid.ids.slice(), + // The cells, null for an empty one, so a hole comes back where it was + // (sanitizeTileGridState; a build before cells drops the nulls and reads + // the tiles packed, as it always did). + ids: grid.cells.slice(), focused: grid.focusedId, zoomed: grid.autoZoom ? null : grid.zoomedId, colFr: grid.colFr.slice(), @@ -1843,6 +1926,14 @@ Object.assign(CodemanApp.prototype, { // Exactly the stored set: an open split closes without joining it (decision 8, case a). if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; const grid = this._tileGrid; + // openTileGrid packed the tiles from the first cell. The stored holes come + // back where they were when the shape is the same as when they were stored + // (as many cells as this layout has); a session gone since leaves its cell + // empty then. Otherwise the tiles stay packed. + const cells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); + if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) { + grid.cells = cells; + } // openTileGrid laid the grid out with equal tracks. The stored ones go back // on; _applyTileLayout drops them again if they do not match the column or // row count (the window may have changed the layout since). @@ -1879,8 +1970,10 @@ Object.assign(CodemanApp.prototype, { _closeStoredTileGrid() { const stored = this._readStoredTileGrid(); if (!stored?.open) return; + // Stored as it was read, holes included (`ids` carries the cells). + const { cells, ...rest } = stored; try { - localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...stored, open: false })); + localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false })); } catch { /* Per-device convenience only. */ } diff --git a/test/tile-grid-cells.test.ts b/test/tile-grid-cells.test.ts new file mode 100644 index 00000000..bb67a918 --- /dev/null +++ b/test/tile-grid-cells.test.ts @@ -0,0 +1,342 @@ +/** + * @fileoverview The grid as cells (owner: "the empty tab doesnt always have to + * be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a + * session id or `null` per cell and is the one source of truth; `grid.ids` is + * the tiles in reading order, derived. The shape still comes from the tile + * count (the layout table), and the cap counts tiles, never empty cells. + * + * - Removing a tile without a shape change leaves its cell empty where it was. + * - Adding one without a shape change takes the first empty cell (or the cell a + * tab was dropped on: tile-grid-move.test.ts). + * - A shape change keeps each tile's row and column when all fit (2x2 growing + * to 3x2), else the tiles pack in reading order (fitTileCells). + * - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it, + * and Ctrl+Tab / Alt+[ ] cycle through the tiles only. + * - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload + * brings the holes back when the shape is the same, a session gone by then + * leaves its cell empty, a different shape packs, and the old packed format + * reads unchanged. A followed `#session=` link keeps the holes. + * - A divider drag refits only the tiles in its two tracks, holes skipped. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + documentAddEventListener, + localStore, + makeGridApp, + rafCallbacks, + resetGridHarness, + section, + type GridApp, + tileEl, +} from './mocks/tile-grid-vm.js'; + +const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; +const FIVE = SIX.slice(0, 5); +const KEY = 'codeman:tile-grid'; +const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot')); +const stored = () => JSON.parse(localStore.get(KEY) ?? 'null'); +const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile; +const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow]; + +// Under 1800px wide three tiles take a 2x2 (over it, 3x1). +const narrow = () => { + section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); +}; + +function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp { + const app = makeGridApp(all); + app.openTileGrid(ids, { focusedId }); + return app; +} + +function setCells(app: GridApp, cells: Array) { + app._tileGrid.cells = cells.slice(); + app._applyTileLayout(); +} + +beforeEach(() => { + resetGridHarness(); + narrow(); +}); +afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; +}); + +describe('the cells are the source of truth', () => { + it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => { + const app = openGrid(FIVE); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + expect(app._tileGrid.ids).toEqual(FIVE); + expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']); + // A tile sits in its cell, the slot in the empty one. + expect(place('s-b')).toEqual(['5', '1']); + expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']); + }); + + it('opening packs from the first cell and pads the shape with empty cells', () => { + const app = openGrid(FIVE); + expect(app._tileGrid.cells).toEqual([...FIVE, null]); + }); +}); + +describe('removing a tile', () => { + it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => { + const app = openGrid(SIX); + app.removeTile('s-b'); + expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']); + expect(place('s-c')).toEqual(['5', '1']); + expect(slots()).toHaveLength(1); + expect(slots()[0].dataset.cell).toBe('1'); + // Nothing else was remounted. + expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']); + expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']); + }); + + it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => { + let app = openGrid(FIVE); + setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']); + // Removing the only tile in the third column: the rest keep their places. + app.removeTile('s-c'); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']); + resetGridHarness(); + narrow(); + app = openGrid(FIVE); + setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']); + // A tile is left in the third column: packed in reading order. + app.removeTile('s-e'); + expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']); + }); + + it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => { + const app = openGrid(SIX, 's-a'); + setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']); + app.removeTile('s-b'); + app.removeTile('s-a'); + expect(app.activeSessionId).toBe('s-c'); + }); +}); + +describe('adding a tile', () => { + it('without a shape change: the first empty cell in reading order', () => { + const app = openGrid(FIVE); + setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']); + app.addTile('s-f'); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']); + expect(slots()).toHaveLength(0); + }); + + it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => { + const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']); + // 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place. + app.addSessionToTiles('s-e'); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + app._joinTileGridFromRun('s-f'); + expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']); + }); + + it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => { + const app = openGrid(['s-a', 's-b', 's-c', 's-d']); + app.addTile('s-e'); + // [a b / c d] -> [a b e / c d _] + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]); + expect(place('s-c')).toEqual(['1', '3']); + expect(place('s-e')).toEqual(['5', '1']); + }); + + it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => { + const app = openGrid(['s-a', 's-b']); + // A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2. + app._tileGrid.cells = ['s-a', null, 's-b', null]; + app._tileGrid.cols = 2; + app._tileGrid.rows = 2; + app.addTile('s-c', { cell: 3 }); + expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']); + // A cell that is taken is not: the first empty one instead. + resetGridHarness(); + narrow(); + const other = openGrid(['s-a', 's-b']); + other._tileGrid.cells = ['s-a', null, 's-b', null]; + other._tileGrid.cols = 2; + other._tileGrid.rows = 2; + other.addTile('s-c', { cell: 2 }); + expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]); + }); + + it('the cap counts tiles, never empty cells', () => { + const app = openGrid(FIVE); + setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']); + expect(app.addTile('s-f')).toBe(true); + expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']); + expect(app.addTile('s-other')).toBe(false); + }); +}); + +describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => { + it('positions do not map between shapes: the tiles keep their reading order, packed', () => { + const app = openGrid(['s-a', 's-b', 's-c']); + setCells(app, ['s-a', null, 's-b', 's-c']); + section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 }); + app._applyTileLayout(); + expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']); + narrow(); + app._applyTileLayout(); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]); + }); +}); + +describe('focus never lands on an empty cell', () => { + 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); + return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1]; + } + const chord = (key: string, mods: Record) => ({ + type: 'keydown', + key, + code: key, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + target: { closest: () => null }, + ...mods, + }); + const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true }); + + it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => { + const app = openGrid(FIVE, 's-a'); + // [a _ b / c d e] + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + delete app.selectSession; + const onKeydown = handlerFor(app); + onKeydown(focusKey('Right')); + expect(app.activeSessionId).toBe('s-b'); + onKeydown(focusKey('Right')); + expect(app.activeSessionId).toBe('s-b'); + app._selectTiledSession('s-d', { auto: true }); + // Up from d (the hole above): the nearest tile in that row, the lower column on a tie. + onKeydown(focusKey('Up')); + expect(app.activeSessionId).toBe('s-a'); + expect(app._tileGrid.focusedId).toBe('s-a'); + }); + + it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => { + const app = openGrid(FIVE, 's-a'); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + delete app.selectSession; + const seen: string[] = []; + for (let i = 0; i < 5; i++) { + app._cycleTileFocus(1); + seen.push(app.activeSessionId); + } + expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']); + app._cycleTileFocus(-1); + expect(app.activeSessionId).toBe('s-e'); + }); +}); + +describe('persistence: the cells, holes included', () => { + function reload(ids: string[] = SIX): GridApp { + // The page goes away; a fresh one on the same device restores the grid. + section.children = []; + FakeTile.all = []; + const app = makeGridApp(ids); + expect(app._restoreTileGrid()).toBe(true); + return app; + } + + it('a hole comes back where it was when the shape is the same', () => { + const app = openGrid(FIVE, 's-c'); + setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']); + expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']); + app._tileGrid.open = false; + const again = reload(); + expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']); + expect(again.activeSessionId).toBe('s-c'); + expect(slots()[0].dataset.cell).toBe('2'); + }); + + it('a session gone by then leaves its cell empty if the shape still fits', () => { + const app = openGrid(SIX); + app._tileGrid.open = false; + const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']); + expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']); + }); + + it('otherwise (another shape) the grid packs', () => { + const app = openGrid(FIVE); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + app._tileGrid.open = false; + // s-d gone: four tiles take a 2x2. + const again = reload(['s-a', 's-b', 's-c', 's-e']); + expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']); + }); + + it('another shape packs even when the stored holes would fit the new one', () => { + // Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2. + localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] })); + const again = reload(['s-a', 's-b', 's-c']); + expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]); + }); + + it('the old packed format reads unchanged', () => { + localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' })); + const again = reload(); + expect(again._tileGrid.cells).toEqual([...FIVE, null]); + expect(again.activeSessionId).toBe('s-b'); + }); + + it('a followed #session= link closes the stored grid with its holes kept', () => { + const app = openGrid(FIVE); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + const fresh = makeGridApp(SIX); + fresh._closeStoredTileGrid(); + expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }); + expect(stored()).not.toHaveProperty('cells'); + void app; + }); +}); + +describe('a move keeps every tile', () => { + it('cells that lose or duplicate a tile are refused, and nothing changes', () => { + const app = openGrid(FIVE); + const before = app._tileGrid.cells.slice(); + expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false); + expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false); + expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false); + expect(app._tileGrid.cells).toEqual(before); + }); +}); + +describe('a divider drag with a hole', () => { + it('refits only the tiles in its two columns, the hole skipped', () => { + const app = openGrid(FIVE); + // [a _ b / c d e]: column 0 holds a, c; column 1 holds d. + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + for (const t of FakeTile.all) t.fit.mockClear(); + const d = app._tileGrid.dividers.get('col-0') as FakeEl; + d.dispatch('pointerdown', { + button: 0, + clientX: 500, + clientY: 100, + pointerId: 1, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 }); + for (const cb of rafCallbacks.splice(0)) cb(); + d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 }); + const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length])); + expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 }); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 4ee26d44..dcc470fd 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -277,7 +277,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'This window fits 4 tiles', 'This window fits 1 tile', 'The grid already holds what this window fits (4)', - 'Drop a tab here', + 'Drop a tab or a tile here', 'Resize tile columns', 'Resize tile rows', 'Zoom this tile', diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index ba8dbfb3..80aba2e0 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -177,52 +177,87 @@ describe('dragging a tile by its header', () => { delete (section as unknown as Record).getBoundingClientRect; }); - // Owner: "dont move the tile". A slot is always the last cell, so a move - // there shifted every tile after it; the slot refuses a tile instead. - it('the slot refuses it: held (never reaching anything below), no highlight, nothing moves', () => { - const app = openGrid(['s-a', 's-b', 's-c'], 's-b'); - app.markIdleAlertSeen.mockClear(); - localStore.delete('codeman:tile-grid'); - const costsNothing = snapshotCost(); - clearFits(); - expect(slots()).toHaveLength(1); - startDrag('s-a'); - const o = over(slots()[0]); - expect(o.preventDefault).toHaveBeenCalled(); - expect(o.stopPropagation).toHaveBeenCalled(); - expect(o.dataTransfer.dropEffect).toBe('none'); + // Owner: an empty cell can be any cell, and a tile dragged onto one moves + // THERE, leaving its own cell empty; nothing else moves. + const LAYOUTS = [ + { name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], cells: 4 }, + { name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), cells: 6 }, + ]; + for (const layout of LAYOUTS) { + it(`${layout.name}: from every cell into the hole wherever it is, by header drag`, () => { + const bad: string[] = []; + for (let hole = 0; hole < layout.cells; hole++) { + for (let from = 0; from < layout.cells; from++) { + if (from === hole) continue; + resetGridHarness(); + section.getBoundingClientRect = () => ({ + width: 1700, + height: 1000, + top: 0, + left: 0, + right: 1700, + bottom: 1000, + }); + const app = openGrid(layout.ids, layout.ids[0]); + const cells: Array = layout.ids.slice(); + cells.splice(hole, 0, null); + app._tileGrid.cells = cells.slice(0, layout.cells); + app._applyTileLayout(); + const moved = app._tileGrid.cells[from]; + const costsNothing = snapshotCost(); + expect(slots()).toHaveLength(1); + expect(slots()[0].dataset.cell).toBe(String(hole)); + const { over: o } = dragTileOnto(moved, slots()[0]); + const expected = app._tileGrid.cells.slice(); + const want = cells.slice(0, layout.cells); + want[hole] = moved; + want[from] = null; + const ok = + JSON.stringify(expected) === JSON.stringify(want) && + o.dataTransfer.dropEffect === 'move' && + app.activeSessionId === moved && + JSON.stringify(stored().ids) === JSON.stringify(want) && + slots().length === 1 && + slots()[0].dataset.cell === String(from); + if (!ok) bad.push(`hole ${hole} from ${from}: got ${JSON.stringify(expected)}`); + costsNothing(); + } + } + expect(bad).toEqual([]); + }); + } + + it('the moved tile sits in the hole (its grid place), and the slot takes its old place', () => { + const app = openGrid(SIX.slice(0, 5)); + // [a b c / d e _]: c into the hole below it. + dragTileOnto('s-c', slots()[0]); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-c']); + expect([tileEl('s-c').style.gridColumn, tileEl('s-c').style.gridRow]).toEqual(['5', '3']); + expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['5', '1']); expect(slots()[0].classList.contains('tile--drop-target')).toBe(false); - // A browser sends no drop on a refused target; one that did never reaches the slot's handler. - const toSlot = vi.spyOn(app, 'dropSessionOnSlot'); - drop(slots()[0]); - expect(toSlot).not.toHaveBeenCalled(); - toSlot.mockRestore(); - end('s-a'); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); - expect(app.activeSessionId).toBe('s-b'); - expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); - expect(localStore.has('codeman:tile-grid')).toBe(false); - expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); - costsNothing(); }); - it('so does the tab of a tiled session; a session not tiled yet still joins there', () => { - const app = openGrid(['s-a', 's-b', 's-c']); - app.draggedTabId = 's-a'; - const o = over(slots()[0]); - expect(o.dataTransfer.dropEffect).toBe('none'); + it('a tab of a tiled session moves the same way; a session not tiled yet joins in THAT cell', () => { + const app = openGrid(SIX.slice(0, 5)); + app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e']; + app._applyTileLayout(); + app.draggedTabId = 's-e'; drop(slots()[0]); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); - // dropSessionOnSlot itself refuses a tiled session too. - app.dropSessionOnSlot('s-a'); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); - + expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', null]); app.draggedTabId = 's-other'; expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move'); drop(slots()[0]); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c', 's-other']); + expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', 's-other']); expect(app.activeSessionId).toBe('s-other'); }); + + it('while a tile is zoomed there is no slot, and a move into a hole is refused', () => { + const app = openGrid(SIX.slice(0, 5)); + app.zoomTile('s-a'); + expect(slots()).toHaveLength(0); + expect(app._moveTileToCell('s-b', 5)).toBe(false); + expect(app._tileGrid.cells).toEqual([...SIX.slice(0, 5), null]); + }); }); it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => { @@ -528,9 +563,9 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => { return keydown[1]; } - // The neighbour in each direction, by cell, written out by hand (null: an - // edge). A partial last row: down from a cell above an empty one goes to the - // last tile, the rule the focus chords follow. + // The cell next to each cell in each direction, written out by hand (null: + // an edge). A move goes to that cell: a swap when a tile is there, a move + // into it when it is empty (the partial layouts below). const TABLES: Record< string, { ids: string[]; cols: number; width?: number; next: Array> } @@ -565,28 +600,6 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => { { left: 4, right: null, up: 2, down: null }, ], }, - '2x2 with 3 tiles': { - cols: 2, - ids: ['s-a', 's-b', 's-c'], - width: 1700, - next: [ - { left: null, right: 1, up: null, down: 2 }, - { left: 0, right: null, up: null, down: 2 }, - { left: null, right: null, up: 0, down: null }, - ], - }, - '3x2 with 5 tiles': { - cols: 3, - ids: SIX.slice(0, 5), - width: 1700, - next: [ - { left: null, right: 1, up: null, down: 3 }, - { left: 0, right: 2, up: null, down: 4 }, - { left: 1, right: null, up: null, down: 4 }, - { left: null, right: 4, up: 0, down: null }, - { left: 3, right: null, up: 1, down: null }, - ], - }, }; afterEach(() => { @@ -632,6 +645,71 @@ describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => { }); } + // Owner: an empty cell can be any cell. With the hole in every cell in turn, + // every tile and every direction: into the hole, or a swap, or nothing. + const HOLED = [ + { name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], adjacency: TABLES['2x2'].next }, + { name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), adjacency: TABLES['3x2'].next }, + ]; + for (const layout of HOLED) { + it(`${layout.name}: with the hole anywhere, every tile, every direction (into the hole, a swap, or nothing)`, () => { + const bad: string[] = []; + const size = layout.adjacency.length; + for (let hole = 0; hole < size; hole++) { + const cells: Array = layout.ids.slice(); + cells.splice(hole, 0, null); + for (let i = 0; i < size; i++) { + if (i === hole) continue; + for (const dir of Object.keys(ARROW) as Dir[]) { + resetGridHarness(); + section.getBoundingClientRect = () => ({ + width: 1700, + height: 1000, + top: 0, + left: 0, + right: 1700, + bottom: 1000, + }); + const moved = cells[i] as string; + const app = openGrid(layout.ids, moved); + app._tileGrid.cells = cells.slice(); + app._applyTileLayout(); + const costsNothing = snapshotCost(); + const e = chord(dir); + handlerFor(app)(e); + const want = cells.slice(); + const j = layout.adjacency[i][dir]; + if (j !== null) [want[i], want[j]] = [want[j], want[i]]; + const ok = + e.preventDefault.mock.calls.length === 1 && + JSON.stringify(app._tileGrid.cells) === JSON.stringify(want) && + app.activeSessionId === moved && + app._tileGrid.focusedId === moved && + JSON.stringify(stored().ids) === JSON.stringify(want); + if (!ok) bad.push(`hole ${hole}, cell ${i} ${dir}: got ${JSON.stringify(app._tileGrid.cells)}`); + costsNothing(); + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + } + } + } + expect(bad).toEqual([]); + }); + } + + it('a move into a hole across columns of different widths: only the moved tile fits, once', () => { + section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); + const five = SIX.slice(0, 5); + const app = openGrid(five, 's-b'); + app._tileGrid.colFr = [1, 1, 2]; + app._applyTileLayout(); + clearFits(); + // [a b c / d e _]: b (column 1) down into... e is there; first move e right into the hole. + app._selectTiledSession('s-e', { auto: true }); + handlerFor(app)(chord('right')); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-e']); + expect(fitCounts(five)).toEqual({ 's-a': 0, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 }); + }); + it('focus stays on the moved tile through several moves, and the order is stored', () => { const app = openGrid(SIX, 's-a'); const onKeydown = handlerFor(app); From 64298101b06ee054340890508884cf55187b62c1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 03:53:10 +0200 Subject: [PATCH 81/90] feat(tiles): pure helpers for a tile count of 2, 4 or 6 The Tiles button's right-click becomes a count menu (owner decision 10): how many tiles, 2, 4 or 6, default 6, remembered per device. These are its pure parts in constants.js (window.CodemanTileGrid): - TILE_GRID_COUNTS / TILE_GRID_COUNT_DEFAULT and sanitizeTileCount: a remembered count is one of 2, 4, 6, anything else reads as 6. - tileGridSetForCount: what the grid opens (or an open grid shows) trimmed or filled to N: trimmed from the end with the session to focus always kept, filled from the open sessions in tab order; fewer sessions than N give fewer tiles; never past the cap. - tileCellCols: the column count a stored cell list was laid out with (stored cells carry no shape of their own). - reformTileCells: a count change is a shape change: the tiles that stay keep their cells, the cell model's rule (fitTileCells) reshapes, and the tiles that join fill the empty cells in reading order, holes first. Nothing uses them yet; the menu comes next. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 93 ++++++++++++++++ test/tile-grid-count-helpers.test.ts | 157 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 test/tile-grid-count-helpers.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 03d352bd..b170c453 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1848,6 +1848,45 @@ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; } +/** + * The tile counts the Tiles button's right-click menu offers, and the count a + * click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md). + */ +const TILE_GRID_COUNTS = [2, 4, 6]; +const TILE_GRID_COUNT_DEFAULT = 6; + +/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */ +function sanitizeTileCount(raw) { + const n = Number(raw); + return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT; +} + +/** + * `base` (what the grid would open, or what an open grid shows, in its order) + * trimmed or filled to `n` tiles: trimmed from the end, the session to focus + * (`keepId`) always kept (it takes the last place when it sat past `n`, as in + * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order) + * with the ones not in it yet. Fewer sessions than `n` give fewer tiles. + * + * @param {string[]} base + * @param {string[]} all + * @param {number} n - at most TILE_GRID_MAX + * @param {string|null} [keepId] + * @returns {string[]} + */ +function tileGridSetForCount(base, all, n, keepId = null) { + const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX)); + const ids = []; + for (const id of [...(base || []), ...(all || [])]) { + if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id); + } + // Every `base` id comes before every filler, so a trim never drops a base id + // in favour of one. + let out = ids.slice(0, max); + if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId]; + return out; +} + /** * Which tile takes focus when `id` leaves the grid: the next one in grid * order, else the previous one, else null. @@ -1962,6 +2001,54 @@ function fitTileCells(cells, oldCols, cols, rows) { return out; } +/** + * How many columns a grid of `length` cells was laid out with: stored cells + * carry no shape of their own, and the layout table gives each cell count one + * shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other + * length (fitTileCells then packs). + * + * @param {number} length + * @returns {number} + */ +function tileCellCols(length) { + // Every cell count is some count's shape on a wide grid area (2x2, the + // narrow 3-tile shape, is also the 4-tile one). + for (let n = 1; n <= TILE_LAYOUT_MAX; n++) { + const { cols, rows } = computeTileLayout({ count: n }); + if (cols * rows === length) return cols; + } + return 0; +} + +/** + * The cells of a grid re-formed to another set of tiles (a count picked in the + * Tiles menu, or the Tiles button bringing back a remembered grid with more or + * fewer tiles): the tiles in `keep` stay in their cells and every other cell + * empties, then the cell model's shape rule (fitTileCells: each tile keeps its + * row and column when all fit, else they pack in reading order), then the + * tiles in `add` fill the empty cells in reading order, holes first. + * + * @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide + * @param {number} oldCols + * @param {string[]} keep - tiles that stay + * @param {string[]} add - tiles that join, in the order they fill + * @param {number} cols - the new shape + * @param {number} rows + * @returns {(string|null)[]} + */ +function reformTileCells(cells, oldCols, keep, add, cols, rows) { + const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null)); + const out = fitTileCells(kept, oldCols, cols, rows); + for (const id of add) { + if (!id || out.includes(id)) continue; + const k = out.indexOf(null); + // Not for a shape made for the count; a full grid takes no more. + if (k === -1) break; + out[k] = id; + } + return out; +} + /** * The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the * grid is open: tiles cycle in reading order and wrap. @@ -2288,6 +2375,12 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + sanitizeTileCount, + tileGridSetForCount, + tileCellCols, + reformTileCells, + TILE_GRID_COUNTS, + TILE_GRID_COUNT_DEFAULT, TILE_GRID_MAX, TILE_LAYOUT_MAX, TILE_MIN_W, diff --git a/test/tile-grid-count-helpers.test.ts b/test/tile-grid-count-helpers.test.ts new file mode 100644 index 00000000..99b0fcb0 --- /dev/null +++ b/test/tile-grid-count-helpers.test.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview The tile count (owner decision 10): the pure helpers behind the + * Tiles button's 2 / 4 / 6 right-click menu. + * + * - `sanitizeTileCount`: a remembered count is one of TILE_GRID_COUNTS, else + * the default (6). + * - `tileGridSetForCount`: what the grid opens (or an open grid shows) trimmed + * or filled to N: trimmed from the end with the session to focus always + * kept, filled from the open sessions in tab order; fewer sessions than N + * give fewer tiles; never past the cap. + * - `tileCellCols`: the column count a stored cell list was laid out with. + * - `reformTileCells`: a count change is a shape change: kept tiles stay in + * their cells, the cell model's rule (fitTileCells) reshapes, joining tiles + * fill the empty cells in reading order, holes first. + * + * The helpers from constants.js as the shared harness loads them. + * Port: N/A. + */ +import { describe, expect, it } from 'vitest'; +import { windowStub } from './mocks/tile-grid-vm.js'; + +type Cells = (string | null)[]; +type Helpers = { + sanitizeTileCount(raw: unknown): number; + tileCellCols(length: number): number; + reformTileCells(cells: Cells, oldCols: number, keep: string[], add: string[], cols: number, rows: number): Cells; + computeTileLayout(p: { count: number; width?: number }): { cols: number; rows: number }; + tileGridSetForCount(base: string[], all: string[], n: number, keepId?: string | null): string[]; + TILE_GRID_COUNTS: number[]; + TILE_GRID_COUNT_DEFAULT: number; + TILE_GRID_MAX: number; +}; +const T = windowStub.CodemanTileGrid as Helpers; +const all = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8']; + +describe('sanitizeTileCount', () => { + it('offers 2, 4 and 6, default 6', () => { + expect(T.TILE_GRID_COUNTS).toEqual([2, 4, 6]); + expect(T.TILE_GRID_COUNT_DEFAULT).toBe(6); + }); + + it('keeps a valid count, as a number or as stored text', () => { + expect(T.sanitizeTileCount(2)).toBe(2); + expect(T.sanitizeTileCount('4')).toBe(4); + expect(T.sanitizeTileCount('6')).toBe(6); + }); + + it('anything else is the default', () => { + for (const raw of [null, undefined, '', '3', 5, 9, 0, -2, 'six', '{}', NaN]) { + expect(T.sanitizeTileCount(raw)).toBe(6); + } + }); +}); + +describe('tileGridSetForCount', () => { + it('fills from tab order after the base, skipping ones already in it', () => { + expect(T.tileGridSetForCount(['t5', 't2'], all, 6)).toEqual(['t5', 't2', 't1', 't3', 't4', 't6']); + }); + + it('trims from the end', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 4)).toEqual(['t1', 't2', 't3', 't4']); + }); + + it('keeps the session to focus when it sat past N, in the last place', () => { + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 2, 't5')).toEqual(['t1', 't5']); + expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4'], all, 2, 't2')).toEqual(['t1', 't2']); + }); + + it('a base id is never trimmed in favour of a filler', () => { + expect(T.tileGridSetForCount(['t7', 't8'], all, 2)).toEqual(['t7', 't8']); + }); + + it('fewer open sessions than N: fewer tiles', () => { + expect(T.tileGridSetForCount(['t1'], ['t1', 't2', 't3'], 6)).toEqual(['t1', 't2', 't3']); + expect(T.tileGridSetForCount([], [], 4)).toEqual([]); + }); + + it('never past the cap, and at least one', () => { + expect(T.tileGridSetForCount([], all, 9)).toHaveLength(T.TILE_GRID_MAX); + expect(T.tileGridSetForCount([], all, 0)).toEqual(['t1']); + }); + + it('drops duplicates and non-ids', () => { + expect(T.tileGridSetForCount(['t2', 't2', '', null as unknown as string], all, 3)).toEqual(['t2', 't1', 't3']); + }); +}); + +describe('tileCellCols', () => { + it('reads the shape back from the cell count, as the layout table lays it out', () => { + expect([1, 2, 3, 4, 6, 9].map((n) => T.tileCellCols(n))).toEqual([1, 2, 3, 2, 3, 3]); + }); + + it('agrees with computeTileLayout for every count, wide and narrow', () => { + for (let n = 1; n <= 9; n++) { + for (const width of [Infinity, 0]) { + const { cols, rows } = T.computeTileLayout({ count: n, width }); + expect(T.tileCellCols(cols * rows)).toBe(cols); + } + } + }); + + it('an unknown length is 0 (the tiles then pack)', () => { + for (const n of [0, 5, 7, 8, 10, -1, NaN]) expect(T.tileCellCols(n)).toBe(0); + }); +}); + +describe('reformTileCells', () => { + it('growing 2 (2x1) to 6 (3x2): both stay in their cells, the four new ones fill in reading order', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'd', 'e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'c', + 'd', 'e', 'f', + ]); + }); + + it('growing fills the holes first, in reading order', () => { + // 5 of a 3x2 with the hole in the middle of the first row, then 6. + expect(T.reformTileCells(['a', null, 'b', 'c', 'd', 'e'], 3, ['a', 'b', 'c', 'd', 'e'], ['f'], 3, 2)).toEqual([ + 'a', 'f', 'b', + 'c', 'd', 'e', + ]); + // 3 of a 2x2 (hole first) growing to 4 keeps the shape: the hole fills. + expect(T.reformTileCells([null, 'a', 'b', 'c'], 2, ['a', 'b', 'c'], ['d'], 2, 2)).toEqual(['d', 'a', 'b', 'c']); + }); + + it('a 2x2 growing to 3x2 keeps every tile at its row and column', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b', 'c', 'd'], ['e', 'f'], 3, 2)).toEqual([ + 'a', 'b', 'e', + 'c', 'd', 'f', + ]); + }); + + it('shrinking 6 (3x2) to 4 (2x2): a tile in the third column does not fit, so the kept ones pack', () => { + expect(T.reformTileCells(['a', 'b', 'c', 'd', 'e', 'f'], 3, ['a', 'b', 'c', 'd'], [], 2, 2)).toEqual([ + 'a', 'b', 'c', 'd', + ]); + }); + + it('shrinking keeps rows and columns when every kept tile still fits', () => { + // 3x2 to 2x2 keeping two tiles inside the first two columns: they stay at + // their row and column, holes and all (packing would move b up). + expect(T.reformTileCells([null, 'a', 'x', 'b', null, 'y'], 3, ['a', 'b'], [], 2, 2)).toEqual([ + null, 'a', + 'b', null, + ]); + // 2x2 to 2x1 keeping the first row. + expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b'], [], 2, 1)).toEqual(['a', 'b']); + }); + + it('a stored grid of unknown shape (oldCols 0) packs', () => { + expect(T.reformTileCells([null, 'a', 'b'], 0, ['a', 'b'], ['c'], 2, 2)).toEqual(['a', 'b', 'c', null]); + }); + + it('a full grid takes no more, and a tile already there is not added twice', () => { + expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'a'], 2, 1)).toEqual(['a', 'b']); + expect(T.reformTileCells(['a', null], 2, ['a'], ['a', 'c'], 2, 1)).toEqual(['a', 'c']); + }); +}); From 7debebb7b38bde79fd63b291fc5c4c118d3a678e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 04:10:55 +0200 Subject: [PATCH 82/90] feat(tiles): right-click Tiles is a 2 / 4 / 6 count menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 10 ("give me then the option to choose only HOW many tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives: "Click opens 6"). The click still opens the grid at once; right-click, Shift+F10 or the Menu key on the button opens a small menu of three counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the remembered one checked. It replaces the session picker, which is gone (method, markup hook, CSS, zh-CN entries). - A pick is remembered per device in codeman:tile-count (default 6; codeman:tile-grid stays ids only) and opens that many tiles. The click and Ctrl+Shift+G then open with it, at most what the window fits. - Which sessions: the rule the click already had (the grid this tab last had, else an open split's two, else tab order, the active one included and focused), trimmed from the end (the focused one kept) or filled from tab order to the count. A remembered grid comes back with its tiles first, in their cells, holes filled first, then tab order (owner answer, superseding decision 8's "exactly the stored set"). A page-load restore still brings back exactly what was stored. - With the grid open a pick re-forms it: a count change is a shape change under the cell model's rule (reformTileCells), the focused tile always kept, every joining tile mounted and laid out before any of them connects, so each fits once and sends one PTY resize. - Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that session among them and focused (owner answer: N, not N+1). - A count the window cannot fit is greyed out with the reason; a remembered one stays checked, the keyboard starts on the largest that fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone (the global handler gives it the key first, like the tab-group menu) and puts the keyboard back on the Tiles button; Tab and a click elsewhere close it. - zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help modal row); the i18n test harvests the menu now, with a session named "6 tiles" as the user-text trap. Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts (the button checks kept, every picker check carried over to the menu, plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch connect); the open-set, cap, restore, shortcuts, split-coexistence and i18n expectations follow the count; the Help modal test escapes its label (the new one has parentheses). The vm harness tracks document.activeElement and makes SVG elements. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 7 +- src/web/public/constants.js | 8 +- src/web/public/i18n.js | 14 +- src/web/public/index.html | 4 +- src/web/public/styles.css | 118 +++--- src/web/public/tile-grid.js | 419 ++++++++++++++------- test/help-modal-shortcuts.test.ts | 4 +- test/mocks/tile-grid-vm.ts | 14 +- test/tile-grid-cap.test.ts | 35 +- test/tile-grid-count-menu.test.ts | 453 +++++++++++++++++++++++ test/tile-grid-i18n.test.ts | 71 ++-- test/tile-grid-open-set.test.ts | 54 +-- test/tile-grid-picker.test.ts | 198 ---------- test/tile-grid-restore.test.ts | 30 ++ test/tile-grid-shortcuts.test.ts | 9 +- test/tile-grid-split-coexistence.test.ts | 18 +- 16 files changed, 973 insertions(+), 483 deletions(-) create mode 100644 test/tile-grid-count-menu.test.ts delete mode 100644 test/tile-grid-picker.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 390f64b4..f7669682 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1417,12 +1417,17 @@ class CodemanApp { this._tabLayoutDragKeydown(e); return; } + // So does the Tiles count menu: it closes alone and gives the keyboard + // back to the Tiles button (tile-grid.js). + if (this._tileCountMenu) { + this.closeTileCountMenu({ refocus: true }); + return; + } this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); this.closeSessionManager(); this.closeCommandPalette?.(); - this.closeTilePicker?.(); this.closeShortcutOverlay?.(); // Overlay layouts only: below 1024px the sidebar is a modal off-canvas // drawer over the terminal, so Escape must close it. The docked desktop diff --git a/src/web/public/constants.js b/src/web/public/constants.js index b170c453..773c0127 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1793,8 +1793,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) { } /** - * The sessions the Tiles picker offers (and the Tiles button opens, case c of - * tileGridOpenSet), in tab order: live ones only, never a session popped out to + * The sessions the Tiles button can open (case c of tileGridOpenSet, and the + * ones a count fills a grid with), in tab order: live ones only, never a session popped out to * its own window (that window owns its PTY size). A session with no PTY * attached IS offered: its tile shows the Attach overlay. * @@ -1820,8 +1820,8 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) { * a. the grid this tab last had (`stored`, already sanitized: live, not * detached, at most the cap), if any of its sessions survive; * b. else an open split's two sessions, Pane A focused; - * c. else the open sessions in tab order, the picker's list (no detached - * ones), up to `limit`, the active session always among them and focused + * c. else the open sessions in tab order (buildTilePickerSessions: no + * detached ones), up to `limit`, the active session always among them and focused * (when it sits past the limit, the first `limit - 1` others come with it). * Null when there is nothing to open. * diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e0cb2f48..ff3ad3f0 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -103,10 +103,10 @@ Tiles: '平铺', Split: '分屏', 'Tiled sessions': '平铺的会话', - 'Tiles: show several sessions side by side (right-click to choose which)': - '平铺:并排显示多个会话(右键单击可选择会话)', - 'Tiles: back to a single session (right-click to choose which sessions)': - '平铺:返回单个会话(右键单击可选择会话)', + 'Tiles: show several sessions side by side (right-click for how many)': + '平铺:并排显示多个会话(右键单击可选择窗格数量)', + 'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)', + 'How many tiles': '窗格数量', 'Split: unavailable while tiles are open': '分屏:平铺打开时不可用', 'Toggle Tile Grid': '切换平铺网格', 'Focus Tile Left': '聚焦左侧窗格', @@ -125,7 +125,7 @@ 'Zoom Focused Tile': '放大聚焦的窗格', 'Remove Focused Tile': '移除聚焦的窗格', 'Add the Session to the Tile Grid': '将该会话加入平铺网格', - 'Choose Which Sessions to Tile': '选择要平铺的会话', + 'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)', 'a tab': '标签页', 'the Tiles button': '平铺按钮', Click: '单击', @@ -133,8 +133,6 @@ Arrows: '方向键', 'not bound': '未绑定', 'Open group as tiles': '以平铺方式打开分组', - 'Show sessions as tiles': '以平铺方式显示会话', - 'Open tiles': '打开平铺', 'No sessions to show as tiles': '没有可平铺显示的会话', 'This group has no session to show as tiles': '此分组没有可平铺显示的会话', 'Zoom this tile': '放大此窗格', @@ -1058,7 +1056,7 @@ (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, ], // Tile grid: counts, exit codes and durations pass through. - [/^Up to (\d+) tiles$/, (_m, n) => `最多 ${n} 个窗格`], + [/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`], [/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`], [/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`], [ diff --git a/src/web/public/index.html b/src/web/public/index.html index 3fbe4530..d868c437 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -192,7 +192,7 @@ - +
—
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f54b3343..8f5281cf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19766,10 +19766,8 @@ body.tile-grid-resizing--row * { text-align: center; } -/* The grid's accent buttons: the Attach overlay's, and the picker's Open - (which narrows the padding in its own block below). */ -.tile-attach-btn, -.tile-picker-open { +/* The Attach overlay's accent button. */ +.tile-attach-btn { padding: 4px 14px; font: inherit; color: var(--accent-ink, #fff); @@ -19779,8 +19777,7 @@ body.tile-grid-resizing--row * { cursor: pointer; } -.tile-attach-btn:disabled, -.tile-picker-open:disabled { +.tile-attach-btn:disabled { opacity: 0.5; cursor: default; } @@ -19802,70 +19799,95 @@ body.tile-grid-resizing--row * { pointer-events: none; } -/* Tiles picker (tile-grid.js openTilePicker): a checkbox per open session, - positioned `fixed` under the Tiles button; same layer as the split picker. */ -.tile-picker-menu { +/* The Tiles button's right-click menu (tile-grid.js openTileCountMenu): how + many tiles, 2, 4 or 6, each with its shape; positioned `fixed` under the + button, on the split picker's layer and in its floating style. */ +.tile-count-menu { position: fixed; z-index: 1000; - min-width: 240px; - max-width: 340px; - max-height: 60vh; + min-width: 168px; display: flex; flex-direction: column; + gap: 2px; padding: 6px; - background: var(--bg-secondary, #1a1a1a); - border: 1px solid var(--border-color, #333); - border-radius: 6px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + background: var(--floating-bg, var(--bg-secondary, #1a1a1a)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--control-border, var(--border-color, #333)); + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25); font-size: 13px; } -.tile-picker-list { - overflow-y: auto; - min-height: 0; +.tile-count-heading { + padding: 2px 8px 4px; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted, #888); } -.tile-picker-item { +.tile-count-item { display: flex; align-items: center; - gap: 8px; - padding: 4px 6px; - border-radius: 4px; + gap: 10px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: none; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } -.tile-picker-item:hover { - background: color-mix(in srgb, var(--text-muted, #888) 14%, transparent); +.tile-count-item:hover:not(:disabled), +.tile-count-item:focus { + outline: none; + background: color-mix(in srgb, var(--text-muted, #888) 16%, transparent); } -.tile-picker-item span { - overflow: hidden; +.tile-count-item:disabled { + opacity: 0.4; + cursor: default; +} + +/* The shape: the grid's own layout for that count, as outlined cells; the + checked count's cells are filled with the accent. */ +.tile-count-glyph { + flex: 0 0 auto; + width: 30px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.2; + opacity: 0.75; +} + +.tile-count-item[aria-checked='true'] .tile-count-glyph { + fill: color-mix(in srgb, var(--accent, #4a9eff) 35%, transparent); + stroke: var(--accent, #4a9eff); + opacity: 1; +} + +.tile-count-label { + flex: 1 1 auto; white-space: nowrap; - text-overflow: ellipsis; } -.tile-picker-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 6px 4px 2px; - border-top: 1px solid var(--border-color, #333); - margin-top: 4px; +.tile-count-check { + flex: 0 0 1em; + color: var(--accent, #4a9eff); + text-align: center; } -.tile-picker-hint, -.tile-picker-empty { - color: var(--text-muted); - font-size: 12px; -} - -.tile-picker-empty { - padding: 6px 8px; -} - -.tile-picker-open { - padding: 4px 12px; +.tile-count-hint { + padding: 4px 8px 2px; + border-top: 1px solid var(--control-border, var(--border-color, #333)); + margin-top: 2px; + color: var(--text-muted, #888); + font-size: 11px; } /* Split-picker menu: a small popover listing sessions to split with, appended diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b3699c8a..b8985e7d 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -28,6 +28,10 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // in reading order with `null` for an empty one. `open: false` keeps it // remembered for one-click return; restored on reload inside handleInit. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; +// How many tiles a click on Tiles opens: the count last picked in its +// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so +// the stored grid above stays ids only. +const TILE_GRID_COUNT_KEY = 'codeman:tile-count'; // 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. const TILE_GRID_REFIT_MS = 150; @@ -69,6 +73,34 @@ function isTextFieldTarget(target) { return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true; } +/** + * The count menu's picture of `n` tiles: the grid's own layout for that count + * (2x1, 2x2, 3x2) as small rounded cells. + */ +function tileCountGlyph(n) { + const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ count: n }); + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('class', 'tile-count-glyph'); + svg.setAttribute('viewBox', '0 0 30 20'); + svg.setAttribute('aria-hidden', 'true'); + const gap = 3; + const w = (30 - gap * (cols - 1)) / cols; + const h = (20 - gap * (rows - 1)) / rows; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const rect = document.createElementNS(NS, 'rect'); + rect.setAttribute('x', String(c * (w + gap))); + rect.setAttribute('y', String(r * (h + gap))); + rect.setAttribute('width', String(w)); + rect.setAttribute('height', String(h)); + rect.setAttribute('rx', '1.5'); + svg.appendChild(rect); + } + } + return svg; +} + /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { constructor() { @@ -362,8 +394,8 @@ Object.assign(CodemanApp.prototype, { btn.classList.toggle('tiles-open', open); btn.setAttribute('aria-pressed', open ? 'true' : 'false'); const title = open - ? 'Tiles: back to a single session (right-click to choose which sessions)' - : 'Tiles: show several sessions side by side (right-click to choose which)'; + ? 'Tiles: back to a single session (right-click for how many tiles)' + : 'Tiles: show several sessions side by side (right-click for how many)'; btn.title = title; btn.setAttribute('aria-label', title); }, @@ -371,8 +403,8 @@ Object.assign(CodemanApp.prototype, { /** * How many tiles the grid takes here and now: what the terminal area fits * (the grid section, or the single view it would replace), at most the cap - * (TILE_GRID_MAX), never less than one. The texts say which of the two - * binds, so a large monitor never reads "this window fits 6". + * (TILE_GRID_MAX), never less than one. `full` says which of the two binds, + * so a large monitor never reads "this window fits 6". */ _tileGridLimit() { const T = window.CodemanTileGrid; @@ -387,131 +419,236 @@ Object.assign(CodemanApp.prototype, { const byCap = fits >= max; return { capacity, - hint: byCap ? `Up to ${max} tiles` : `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`, full: byCap ? `The grid holds at most ${max} tiles` : `The grid already holds what this window fits (${capacity})`, }; }, + /** The tile count a click on Tiles opens (the menu's last pick, per device), default 6. */ + _tileGridCount() { + let raw = null; + try { + raw = localStorage.getItem(TILE_GRID_COUNT_KEY); + } catch { + /* Storage unavailable: the default. */ + } + return window.CodemanTileGrid.sanitizeTileCount(raw); + }, + + _rememberTileGridCount(count) { + try { + localStorage.setItem(TILE_GRID_COUNT_KEY, String(window.CodemanTileGrid.sanitizeTileCount(count))); + } catch { + /* Per-device convenience only. */ + } + }, + /** - * Right-click on the Tiles button (its click opens the grid at once, see - * toggleTileGrid): a picker with a checkbox per open session, in tab order, - * preselected with the tiles shown now, else the grid this tab last left - * (else the active session and an open split's two), and an Open button. - * Boxes past what the grid takes are disabled. With the grid open, Open - * replaces its set. + * Right-click on the Tiles button (or Shift+F10 / the Menu key on it, which + * fire the same contextmenu event; its click opens the grid at once, see + * toggleTileGrid): how many tiles, 2, 4 or 6 (owner decision 10), each with + * its shape, the remembered count checked. A count the window cannot fit is + * greyed out, and the menu says why; the keyboard then starts on the largest + * that fits (the count a click opens here). Picking one opens the grid with + * that many tiles, or re-forms the open grid to it, and remembers it for the + * click. Arrows move, Enter or Space picks, Escape closes and gives the + * keyboard back to the Tiles button, Tab closes. */ - openTilePicker(event) { + openTileCountMenu(event) { // The right-click: the browser's own context menu stays away. event?.preventDefault?.(); - if (this._tilePicker) { - this.closeTilePicker(); + if (this._tileCountMenu) { + this.closeTileCountMenu(); return; } if (!this.canOpenTileGrid()) return; const T = window.CodemanTileGrid; - const limit = this._tileGridLimit(); - const capacity = limit.capacity; - const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); - const gridOpen = this._tilesOwnTerminal(); - const remembered = (gridOpen ? this._tileGrid.ids : this._readStoredTileGrid()?.ids || []).filter((id) => - candidates.some((c) => c.id === id) - ); - const seed = remembered.length - ? remembered - : [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean); - const checked = new Set(seed.slice(0, capacity)); - + const capacity = this._tileGridLimit().capacity; + const fitsText = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`; + const current = this._tileGridCount(); const menu = document.createElement('div'); - menu.id = 'tilePickerMenu'; - menu.className = 'tile-picker-menu'; - menu.setAttribute('role', 'dialog'); - menu.setAttribute('aria-label', 'Show sessions as tiles'); - const list = document.createElement('div'); - list.className = 'tile-picker-list'; - const boxes = []; - for (const c of candidates) { - const row = document.createElement('label'); - row.className = 'tile-picker-item'; - const box = document.createElement('input'); - box.type = 'checkbox'; - box.value = c.id; - box.checked = checked.has(c.id); - const name = document.createElement('span'); - // A session literally named like a UI string must not be translated. - name.setAttribute('data-i18n-skip', ''); - name.textContent = c.label; - row.append(box, name); - list.appendChild(row); - boxes.push(box); - } - const footer = document.createElement('div'); - footer.className = 'tile-picker-footer'; - const hint = document.createElement('span'); - hint.className = 'tile-picker-hint'; - hint.textContent = limit.hint; - const open = document.createElement('button'); - open.type = 'button'; - open.className = 'tile-picker-open'; - open.textContent = 'Open tiles'; - footer.append(hint, open); - if (candidates.length === 0) { - const empty = document.createElement('div'); - empty.className = 'tile-picker-empty'; - empty.textContent = 'No sessions to show as tiles'; - menu.appendChild(empty); - } else { - menu.append(list, footer); - } - - const sync = () => { - const count = boxes.filter((b) => b.checked).length; - for (const b of boxes) { - b.disabled = !b.checked && count >= capacity; - b.title = b.disabled ? limit.full : ''; + menu.id = 'tileCountMenu'; + menu.className = 'tile-count-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', 'How many tiles'); + const heading = document.createElement('div'); + heading.className = 'tile-count-heading'; + heading.setAttribute('aria-hidden', 'true'); + heading.textContent = 'Tiles'; + menu.appendChild(heading); + const items = []; + for (const n of T.TILE_GRID_COUNTS) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'tile-count-item'; + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', n === current ? 'true' : 'false'); + item.tabIndex = -1; + item.dataset.count = String(n); + if (n > capacity) { + item.disabled = true; + item.setAttribute('aria-disabled', 'true'); + item.title = fitsText; } - open.disabled = count === 0; - }; - for (const b of boxes) b.addEventListener('change', sync); - sync(); - open.addEventListener('click', () => { - const ids = boxes.filter((b) => b.checked).map((b) => b.value); - this.closeTilePicker(); - if (ids.length > 0) this._replaceTileGrid(ids); - }); + const label = document.createElement('span'); + label.className = 'tile-count-label'; + label.textContent = `${n} tiles`; + const check = document.createElement('span'); + check.className = 'tile-count-check'; + check.setAttribute('aria-hidden', 'true'); + check.textContent = n === current ? '\u2713' : ''; + item.append(tileCountGlyph(n), label, check); + item.addEventListener('click', (e) => { + e.stopPropagation?.(); + if (!item.disabled) this._pickTileCount(n); + }); + item.addEventListener('pointerenter', () => { + if (!item.disabled) item.focus?.(); + }); + menu.appendChild(item); + items.push(item); + } + if (capacity < T.TILE_GRID_MAX) { + const hint = document.createElement('div'); + hint.className = 'tile-count-hint'; + hint.textContent = fitsText; + menu.appendChild(hint); + } document.body.appendChild(menu); const btn = document.querySelector('.btn-tile-grid'); if (btn?.getBoundingClientRect) { - // Under the Tiles button (.tile-picker-menu is position: fixed). + // Under the Tiles button (.tile-count-menu is position: fixed). const rect = btn.getBoundingClientRect(); - menu.style.top = `${rect.bottom + 4}px`; - menu.style.right = `${window.innerWidth - rect.right}px`; + menu.style.top = `${rect.bottom + 6}px`; + menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`; } - // A click elsewhere or Escape closes it. A click on the Tiles button needs no - // exception: its own handler (toggleTileGrid) closes the picker first. + const enabled = () => items.filter((i) => !i.disabled); + const move = (delta) => { + const list = enabled(); + if (list.length === 0) return; + const at = list.indexOf(document.activeElement); + const next = at === -1 ? (delta > 0 ? 0 : list.length - 1) : (at + delta + list.length) % list.length; + list[next].focus?.(); + }; + // A click elsewhere closes it. A click on the Tiles button needs no + // exception: its own handler (toggleTileGrid) closes the menu first. const onOutside = (e) => { if (menu.contains?.(e.target)) return; - this.closeTilePicker(); + this.closeTileCountMenu(); }; + // The menu's keys, in the capture phase while it is open. Escape normally + // reaches the app's global handler first, which closes the menu alone and + // gives the keyboard back (app.js); this one covers the rest. const onKey = (e) => { - if (e.key === 'Escape') this.closeTilePicker(); + if (e.key === 'Escape') { + e.preventDefault?.(); + this.closeTileCountMenu({ refocus: true }); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault?.(); + move(1); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault?.(); + move(-1); + } else if (e.key === 'Home' || e.key === 'End') { + e.preventDefault?.(); + const list = enabled(); + list[e.key === 'Home' ? 0 : list.length - 1]?.focus?.(); + } else if (e.key === 'Enter' || e.key === ' ') { + const item = items.find((i) => i === document.activeElement); + if (!item) return; + // Not the button's own activation too. + e.preventDefault?.(); + if (!item.disabled) this._pickTileCount(Number(item.dataset.count)); + } else if (e.key === 'Tab') { + this.closeTileCountMenu(); + } }; - this._tilePicker = { menu, onOutside, onKey }; + this._tileCountMenu = { menu, onOutside, onKey }; document.addEventListener('click', onOutside); - document.addEventListener('keydown', onKey); - (boxes.find((b) => !b.disabled) || open).focus?.(); + document.addEventListener('keydown', onKey, true); + // The checked count, or when the window cannot fit it, the largest that fits. + const start = items.find((i) => !i.disabled && i.getAttribute('aria-checked') === 'true') || enabled().at(-1); + (start || menu).focus?.(); }, - /** Idempotent: the global Escape handler calls it whether or not the picker is open. */ - closeTilePicker() { - const picker = this._tilePicker; - if (!picker) return; - this._tilePicker = null; - document.removeEventListener('click', picker.onOutside); - document.removeEventListener('keydown', picker.onKey); - picker.menu.remove(); + /** + * Idempotent: the global Escape handler may call it whether or not the menu + * is open. The keyboard goes back to the Tiles button when it was in the + * menu (`refocus` unset), or when asked (Escape); a click elsewhere has + * already moved it, and a pick passes `refocus: false`. + */ + closeTileCountMenu({ refocus } = {}) { + const menu = this._tileCountMenu; + if (!menu) return; + if (refocus === undefined) refocus = !!menu.menu.contains?.(document.activeElement); + this._tileCountMenu = null; + document.removeEventListener('click', menu.onOutside); + document.removeEventListener('keydown', menu.onKey, true); + menu.menu.remove(); + if (refocus) document.querySelector('.btn-tile-grid')?.focus?.(); + }, + + /** + * A count picked in the menu: remembered for the click, then the grid opens + * with that many tiles (what the click opens), or the open grid is re-formed + * to it (_reformTileGrid). Never more than the window fits; fewer open + * sessions than the count give fewer tiles. + */ + _pickTileCount(count) { + this.closeTileCountMenu({ refocus: false }); + this._rememberTileGridCount(count); + const grid = this._tileGrid; + if (!grid?.open) { + this.toggleTileGrid(); + return; + } + const T = window.CodemanTileGrid; + const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity); + const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); + this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId)); + }, + + /** + * The open grid re-formed to the tiles `target` (a count picked in the + * menu): a count change is a shape change, so the cell model's rule applies + * (reformTileCells: the tiles that stay keep their row and column when all + * fit, else they pack in reading order), and the tiles that join fill the + * empty cells in reading order, holes first. The focused tile is always + * among those that stay (tileGridSetForCount). Every tile that joins is + * mounted and the grid laid out ONCE before any of them connects (as in + * openTileGrid): a tile connected into an intermediate layout would fit that + * cell first and resize its PTY twice. A zoom the user chose ends: the grid + * they asked for is meant to be seen. + */ + _reformTileGrid(target) { + const grid = this._tileGrid; + if (!grid?.open) return; + const T = window.CodemanTileGrid; + const leaving = grid.ids.filter((id) => !target.includes(id)); + const joining = target.filter((id) => !grid.tiles.has(id)); + if (leaving.length === 0 && joining.length === 0) return; + // A divider or header drag in progress measured the old grid. + this._tileDividerDragTeardown?.(); + this._endTileMoveDrag(); + for (const id of leaving) { + const entry = grid.tiles.get(id); + this._destroyTerminalTile(entry.tile); + entry.el.remove(); + grid.tiles.delete(id); + } + if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; + const staying = grid.ids.filter((id) => grid.tiles.has(id)); + const mounted = joining.filter((id) => this._mountTile(id)); + const { cols, rows } = this._tileShapeFor(grid.tiles.size); + grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows); + grid.cols = cols; + grid.rows = rows; + this._applyTileLayout(); + for (const id of mounted) this._connectTile(id); + this._scheduleTileGridRefit(); + this.renderSessionTabs?.(); }, /** @@ -598,14 +735,18 @@ Object.assign(CodemanApp.prototype, { /** * The Tiles button's click and Ctrl+Shift+G, one function so the two never - * drift (owner decision 8): opens the grid at once, no picker in the way, or - * closes it to the single view of the focused session. What opens is + * drift (owner decision 8): opens the grid at once, no menu in the way, or + * closes it to the single view of the focused session. What opens is the + * remembered count of tiles (the right-click menu's last pick, default 6, + * at most what the window fits; owner decision 10), chosen by * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open - * split's two sessions, else the open sessions in tab order up to what the - * grid takes here, the active one focused. The picker is a right-click away. + * split's two sessions, else the open sessions in tab order, the active one + * focused; then trimmed or filled to the count (the focused one kept). A + * remembered grid comes back with its tiles in their cells, the ones the + * count adds filling its empty cells first (_openStoredTileGrid). */ toggleTileGrid() { - this.closeTilePicker(); + this.closeTileCountMenu(); if (this._tilesOwnTerminal()) { this.closeTileGrid({ keepStored: true, reselect: true }); return; @@ -617,21 +758,30 @@ Object.assign(CodemanApp.prototype, { this.showToast?.('No sessions to show as tiles', 'info'); return; } - if (set.source === 'stored') this._openStoredTileGrid(stored); + if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids); else this.openTileGrid(set.ids, { focusedId: set.focusedId }); }, - /** What the toggle would open now (see toggleTileGrid); `stored` saves a second read. */ - _tileGridOpenSet(stored = this._readStoredTileGrid()) { - return window.CodemanTileGrid.tileGridOpenSet({ + /** + * What the toggle would open now (see toggleTileGrid): `count` tiles (the + * remembered count), or as many as the window fits; `stored` saves a + * second read. + */ + _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) { + const T = window.CodemanTileGrid; + const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity)); + const set = T.tileGridOpenSet({ stored, split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null, sessions: this.sessions, sessionOrder: this.sessionOrder, detachedIds: this.detachedSessions, activeId: this.activeSessionId, - limit: this._tileGridLimit().capacity, + limit: n, }); + if (!set) return null; + const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); + return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) }; }, /** Alt+Shift+Arrows: a human selection of the tile in that direction. */ @@ -1006,7 +1156,9 @@ Object.assign(CodemanApp.prototype, { /** * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a * human selection: the user clicked its tab). With the grid closed it opens - * on what the Tiles toggle would bring back, plus this session. Returns false + * what the Tiles toggle would, with this session among the tiles and + * focused: the remembered count in total (owner answer to decision 10's + * questions), never one more. Returns false * when the grid cannot open here (narrow or solo window), so the click is an * ordinary one. */ @@ -1028,8 +1180,9 @@ Object.assign(CodemanApp.prototype, { this.selectSession(sessionId); return true; } + const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); const base = this._tileGridOpenSet()?.ids || []; - const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId]; + const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; this.openTileGrid(ids, { focusedId: sessionId, auto: false }); return true; }, @@ -1057,8 +1210,8 @@ Object.assign(CodemanApp.prototype, { }, /** - * Opens the grid on `ids` in place of whatever it shows (the picker's Open, - * "Open group as tiles"): the session in focus keeps focus when `ids` holds + * Opens the grid on `ids` in place of whatever it shows ("Open group as + * tiles"): the session in focus keeps focus when `ids` holds * it, otherwise the first one takes it. Chosen BEFORE an open grid closes, * because the close drops activeSessionId: as in selectSession's tile * branch, the parked terminal still holds what it showed before the grid, @@ -1311,8 +1464,8 @@ Object.assign(CodemanApp.prototype, { actions.append( button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)), zoomBtn, - // (No + here, owner decision 9: tiles are added from the Tiles button's - // picker, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) + // (No + here, owner decision 9: tiles are added from the Tiles button + // and its count menu, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) // Removes the tile ONLY: the session keeps running. Killing it stays // behind the menu's Close session and its confirm. button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) @@ -1919,18 +2072,36 @@ Object.assign(CodemanApp.prototype, { /** * Opens a stored grid: its tiles and focus, then the fractions it had (only * if they still match the layout) and a zoom the user chose. `auto`: the app - * is putting it back, so no idle alert is spent. + * is putting it back, so no idle alert is spent. `ids` is the set to open, + * the stored one by default (a reload); the Tiles button passes it trimmed + * or filled to the remembered count, a shape change the cell model's rule + * handles (reformTileCells). */ - _openStoredTileGrid(stored) { + _openStoredTileGrid(stored, ids = stored.ids) { const focus = stored.zoomed || stored.focused; - // Exactly the stored set: an open split closes without joining it (decision 8, case a). - if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; + // The stored set (an open split closes without joining it, decision 8, + // case a), trimmed or filled to the count by the caller. + if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; const grid = this._tileGrid; - // openTileGrid packed the tiles from the first cell. The stored holes come - // back where they were when the shape is the same as when they were stored - // (as many cells as this layout has); a session gone since leaves its cell - // empty then. Otherwise the tiles stay packed. - const cells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); + const T = window.CodemanTileGrid; + // openTileGrid packed the tiles from the first cell. The stored tiles go + // back to their cells (a session gone since leaves its cell empty): as + // they were when this is the stored set in the shape it was stored with, + // otherwise by the cell model's rule, the tiles the count adds filling the + // empty cells in reading order, holes first. + const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); + const kept = storedCells.filter(Boolean); + const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id)); + const cells = sameSet + ? storedCells + : T.reformTileCells( + storedCells, + T.tileCellCols(storedCells.length), + kept, + grid.ids.filter((id) => !kept.includes(id)), + grid.cols, + grid.rows + ); if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) { grid.cells = cells; } diff --git a/test/help-modal-shortcuts.test.ts b/test/help-modal-shortcuts.test.ts index 2c1e1f5d..20e54c94 100644 --- a/test/help-modal-shortcuts.test.ts +++ b/test/help-modal-shortcuts.test.ts @@ -31,7 +31,7 @@ function escapeRegExp(value: string): string { function expectShortcut(html: string, keys: string[], label: string): void { const keyPattern = keys.map((key) => `${escapeRegExp(key)}`).join('\\s*\\+\\s*'); - expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i')); + expect(html).toMatch(new RegExp(`${keyPattern}.*?${escapeRegExp(label)}`, 'i')); } describe('help modal shortcuts', () => { @@ -58,7 +58,7 @@ describe('help modal shortcuts', () => { expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down'); expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile'); expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid'); - expectShortcut(helpModal, ['Right-click'], 'Choose Which Sessions to Tile'); + expectShortcut(helpModal, ['Right-click'], 'Choose How Many Tiles (2, 4 or 6)'); }); it('documents terminal input shortcuts without advertising stale run shortcuts', () => { diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 7b818648..ba61f63f 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -13,6 +13,10 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { vi } from 'vitest'; +/** What has the keyboard: the last FakeEl focused (document.activeElement). */ +let focusedEl: unknown = null; +export const activeElement = () => focusedEl; + /** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ export class FakeEl { id = ''; @@ -59,7 +63,10 @@ export class FakeEl { textContent = ''; value = ''; type = ''; - focus = vi.fn(); + /** Records itself as `document.activeElement` (see activeElement()). */ + focus = vi.fn(() => { + focusedEl = this; + }); select = vi.fn(); setPointerCapture = vi.fn(); releasePointerCapture = vi.fn(); @@ -226,6 +233,10 @@ const context = vm.createContext({ removeEventListener: documentRemoveEventListener, documentElement: { dataset: {} }, createElement: () => new FakeEl(), + createElementNS: () => new FakeEl(), + get activeElement() { + return focusedEl; + }, getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)), body, querySelector: (sel: string) => @@ -313,6 +324,7 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp { /** Resets the shared fake DOM and tile registry between tests. */ export function resetGridHarness() { FakeTile.all = []; + focusedEl = null; idleCallbacks.length = 0; rafCallbacks.length = 0; localStore.clear(); diff --git a/test/tile-grid-cap.test.ts b/test/tile-grid-cap.test.ts index 7dfe0dfa..dc3e5e95 100644 --- a/test/tile-grid-cap.test.ts +++ b/test/tile-grid-cap.test.ts @@ -5,7 +5,7 @@ * * Every way into the grid stops at the cap even where the window would fit * nine (the harness window is 2400x1200): opening, adding, a session Run - * makes, Ctrl/Cmd+click, the picker and "Open group as tiles". + * makes, Ctrl/Cmd+click, the count menu and "Open group as tiles". * The texts say which limit binds: "at most 6" for the cap, "what this window * fits" for a smaller window. * @@ -36,7 +36,7 @@ function fullGrid(): GridApp { describe('the cap', () => { it('is 6, worded as the cap where the window would fit more', () => { const app = makeGridApp(EIGHT); - expect(app._tileGridLimit()).toEqual({ capacity: 6, hint: 'Up to 6 tiles', full: AT_MOST }); + expect(app._tileGridLimit()).toEqual({ capacity: 6, full: AT_MOST }); }); it('a smaller window is worded by the window', () => { @@ -44,7 +44,6 @@ describe('the cap', () => { wrapRect = { width: 1200, height: 900 }; expect(app._tileGridLimit()).toEqual({ capacity: 4, - hint: 'This window fits 4 tiles', full: 'The grid already holds what this window fits (4)', }); }); @@ -77,26 +76,18 @@ describe('every way in stops at the cap', () => { expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info'); }); - it('the picker greys out the seventh box', () => { + it('the count menu offers nothing past the cap, and its 6 opens six of eight', () => { const app = makeGridApp(EIGHT); - app.openTilePicker({ stopPropagation: vi.fn() }); - const picker = body.children.find((c) => c.id === 'tilePickerMenu')!; - const boxes = picker.children[0].children.map((row) => row.children[0]); - expect(picker.children[1].children[0].textContent).toBe('Up to 6 tiles'); - for (const box of boxes) { - // A disabled box cannot be ticked (the browser ignores the click). - if (!EIGHT.includes(box.value) || box.checked || box.disabled) continue; - box.checked = true; - box.dispatch('change'); - } - const checked = boxes.filter((b) => b.checked).map((b) => b.value); - expect(checked).toHaveLength(6); - const left = boxes.filter((b) => !b.checked); - expect(left.length).toBeGreaterThan(0); - for (const box of left) { - expect(box.disabled).toBe(true); - expect(box.title).toBe(AT_MOST); - } + app.openTileCountMenu({ preventDefault: vi.fn() }); + const menu = body.children.find((c) => c.id === 'tileCountMenu')!; + const items = menu.children.filter((c) => c.attrs.role === 'menuitemradio'); + expect(items.map((i) => Number(i.dataset.count))).toEqual([2, 4, 6]); + // The cap binds, not the window: nothing greyed out, no "fits" line. + expect(items.every((i) => !i.disabled)).toBe(true); + expect(menu.children.some((c) => c.className === 'tile-count-hint')).toBe(false); + items.at(-1)!.dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.ids).toHaveLength(6); + expect(app._tileGrid.ids).toEqual(['s-other', ...EIGHT.slice(0, 5)]); }); it('"Open group as tiles" shows the first six of a larger group', () => { diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts new file mode 100644 index 00000000..5c802b7b --- /dev/null +++ b/test/tile-grid-count-menu.test.ts @@ -0,0 +1,453 @@ +/** + * @fileoverview The header Tiles button and its right-click count menu (owner + * decision 10, which replaced the session picker). + * + * - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker + * class) and hard-gated to desktop widths like Split: a JS width check plus a + * CSS `@media (max-width: 1179px)` backstop, and never in a solo window. + * With the grid open it closes it (`aria-pressed`). + * - Right-click (contextmenu, which Shift+F10 and the Menu key fire too) opens + * a menu of three counts, 2, 4 and 6, each with its shape, the remembered + * count checked (default 6). No session names in it, so no user text. + * - A count the window cannot fit is greyed out with the reason, and the + * keyboard starts on the largest that fits. + * - Arrows move over the counts that fit (wrapping), Home/End, Enter or Space + * picks, Escape closes and gives the keyboard back to the Tiles button (the + * menu owns its Escape in the global handler, like the tab-group menu), Tab + * and a click elsewhere close it. + * - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid` + * stays ids only) and opens that many tiles: the session to focus included, + * the rest from tab order. The click and Ctrl+Shift+G then open with it. + * - With the grid open a pick re-forms it: a shape change under the cell + * model's rule, the focused tile always kept, tiles dropped from the end, + * new ones filling the empty cells first, all mounted and laid out before + * any connects. Fewer open sessions than the count give fewer tiles. + * - The session picker is gone (no method, no CSS, no markup hook). + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + activeElement, + body, + bySelector, + localStore, + makeGridApp, + resetGridHarness, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f', 's-g']; +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); +const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const appSrc = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); +const gridSrc = readFileSync(resolve(PUBLIC, 'tile-grid.js'), 'utf8'); + +function makeButton() { + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden'; + bySelector.set('.btn-tile-grid', btn); + return btn; +} + +const menu = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null; +const items = () => menu()!.children.filter((c) => c.attrs.role === 'menuitemradio'); +const item = (n: number) => items().find((i) => i.dataset.count === String(n))!; +const hint = () => menu()!.children.find((c) => c.className === 'tile-count-hint') ?? null; +const focused = () => (activeElement() as FakeEl | null)?.dataset?.count ?? null; +const key = (app: GridApp, k: string) => { + const ev = { key: k, preventDefault: vi.fn() }; + app._tileCountMenu.onKey(ev); + return ev; +}; +const open = (app: GridApp) => app.openTileCountMenu({ preventDefault: vi.fn() }); + +let wrapRect = { width: 2400, height: 1200 }; +beforeEach(() => { + resetGridHarness(); + wrapRect = { width: 2400, height: 1200 }; + const wrap = new FakeEl(); + wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height }); + bySelector.set('.terminal-wrap', wrap); + makeButton(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {})); + return app; +} + +describe('the Tiles button', () => { + it('shows only when its setting is on and the window is desktop-wide', () => { + const app = makeGridApp(IDS); + const btn = makeButton(); + app._applyTileGridButtonVisibility(false); + expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); + app._applyTileGridButtonVisibility(true); + expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false); + windowStub.innerWidth = 1100; + app._applyTileGridButtonVisibility(true); + expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); + }); + + it('never in a solo window', () => { + const app = makeGridApp(IDS); + app.isSoloWindow = true; + const btn = makeButton(); + app._applyTileGridButtonVisibility(true); + expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); + }); + + it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => { + expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/); + expect(css).toMatch( + /@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/ + ); + expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/); + }); + + it('with the grid open, a click closes it, and the button says so meanwhile', () => { + const app = gridApp(IDS.slice(0, 3)); + const btn = bySelector.get('.btn-tile-grid')!; + app.openTileGrid(IDS.slice(0, 3)); + expect(btn.getAttribute('aria-pressed')).toBe('true'); + expect(btn.classList.contains('tiles-open')).toBe(true); + expect(btn.title).toBe('Tiles: back to a single session (right-click for how many tiles)'); + app.toggleTileGrid(); + expect(app._tilesOwnTerminal()).toBe(false); + expect(btn.getAttribute('aria-pressed')).toBe('false'); + expect(btn.title).toBe('Tiles: show several sessions side by side (right-click for how many)'); + expect(menu()).toBeNull(); + }); + + it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => { + expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"'); + expect(html).toContain('title="Tiles: show several sessions side by side (right-click for how many)"'); + }); +}); + +describe('the count menu', () => { + it('offers 2, 4 and 6 with their shapes, the default 6 checked and focused; no user text', () => { + const app = gridApp(); + const ev = { preventDefault: vi.fn() }; + app.openTileCountMenu(ev); + expect(ev.preventDefault).toHaveBeenCalled(); + expect(menu()!.attrs.role).toBe('menu'); + expect(menu()!.attrs['aria-label']).toBe('How many tiles'); + expect(items().map((i) => i.dataset.count)).toEqual(['2', '4', '6']); + expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'false', 'true']); + expect(items().map((i) => i.children[1].textContent)).toEqual(['2 tiles', '4 tiles', '6 tiles']); + // Each shape is the grid's own layout for that count: 2x1, 2x2, 3x2 cells. + expect(items().map((i) => i.children[0].children.length)).toEqual([2, 4, 6]); + expect(items().every((i) => !i.disabled)).toBe(true); + expect(hint()).toBeNull(); + expect(focused()).toBe('6'); + expect(app._tilesOwnTerminal()).toBe(false); + // No session name anywhere in it. + expect(JSON.stringify(menu()!.children.map((c) => c.textContent))).not.toMatch(/s-[a-g]/); + }); + + it('checks the remembered count; a bad stored value reads as the default', () => { + const app = gridApp(); + localStore.set('codeman:tile-count', '4'); + open(app); + expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'true', 'false']); + expect(focused()).toBe('4'); + app.closeTileCountMenu(); + localStore.set('codeman:tile-count', '5'); + open(app); + expect(item(6).attrs['aria-checked']).toBe('true'); + }); + + it('greys out a count the window cannot fit, says why, and starts on the largest that fits', () => { + const app = gridApp(); + wrapRect = { width: 1200, height: 900 }; // fits 4 (2x2 of 600x450), not 3x2 + open(app); + expect(item(6).disabled).toBe(true); + expect(item(6).attrs['aria-disabled']).toBe('true'); + expect(item(6).title).toBe('This window fits 4 tiles'); + expect(item(4).disabled).toBe(false); + expect(hint()!.textContent).toBe('This window fits 4 tiles'); + // The remembered 6 stays checked (owner answer), the keyboard starts on 4. + expect(item(6).attrs['aria-checked']).toBe('true'); + expect(focused()).toBe('4'); + app.closeTileCountMenu(); + wrapRect = { width: 1000, height: 400 }; // fits 2 + open(app); + expect(items().map((i) => i.disabled)).toEqual([false, true, true]); + expect(hint()!.textContent).toBe('This window fits 2 tiles'); + }); + + it('a greyed count is never picked, by click or by Enter', () => { + const app = gridApp(); + wrapRect = { width: 1200, height: 900 }; + open(app); + item(6).dispatch('click', { stopPropagation: vi.fn() }); + expect(menu()).not.toBeNull(); + item(6).focus(); + key(app, 'Enter'); + expect(app._tilesOwnTerminal()).toBe(false); + expect(localStore.has('codeman:tile-count')).toBe(false); + }); + + it('arrows move over the counts that fit and wrap; Home and End', () => { + const app = gridApp(); + open(app); + expect(key(app, 'ArrowDown').preventDefault).toHaveBeenCalled(); + expect(focused()).toBe('2'); + key(app, 'ArrowRight'); + expect(focused()).toBe('4'); + key(app, 'ArrowUp'); + key(app, 'ArrowLeft'); + expect(focused()).toBe('6'); + key(app, 'Home'); + expect(focused()).toBe('2'); + key(app, 'End'); + expect(focused()).toBe('6'); + app.closeTileCountMenu(); + wrapRect = { width: 1200, height: 900 }; + open(app); + key(app, 'ArrowDown'); + expect(focused()).toBe('2'); + key(app, 'ArrowUp'); + expect(focused()).toBe('4'); + // The greyed 6 is skipped both ways. + key(app, 'ArrowDown'); + expect(focused()).toBe('2'); + }); + + it('Escape closes it and gives the keyboard back to the Tiles button; closing twice is harmless', () => { + const app = gridApp(); + open(app); + const btn = bySelector.get('.btn-tile-grid')!; + const ev = key(app, 'Escape'); + expect(ev.preventDefault).toHaveBeenCalled(); + expect(menu()).toBeNull(); + expect(activeElement()).toBe(btn); + expect(() => app.closeTileCountMenu()).not.toThrow(); + }); + + it('the menu owns its Escape in the global handler: it closes alone, with the keyboard back on Tiles', () => { + const escape = appSrc.slice(appSrc.indexOf("if (e.key === 'Escape') {"), appSrc.indexOf('this.closeAllPanels();')); + expect(escape).toMatch(/if \(this\._tileCountMenu\) \{\s*this\.closeTileCountMenu\(\{ refocus: true \}\);\s*return;/); + const app = gridApp(); + open(app); + app.closeTileCountMenu({ refocus: true }); + expect(activeElement()).toBe(bySelector.get('.btn-tile-grid')); + }); + + it('Tab closes it; a click elsewhere closes it, a click inside does not', () => { + const app = gridApp(); + open(app); + key(app, 'Tab'); + expect(menu()).toBeNull(); + open(app); + app._tileCountMenu.onOutside({ target: item(4) }); + expect(menu()).not.toBeNull(); + app._tileCountMenu.onOutside({ target: new FakeEl() }); + expect(menu()).toBeNull(); + }); + + it('a second right-click closes it; a click on Tiles closes it and toggles', () => { + const app = gridApp(); + open(app); + open(app); + expect(menu()).toBeNull(); + open(app); + app.toggleTileGrid(); + expect(menu()).toBeNull(); + expect(app._tilesOwnTerminal()).toBe(true); + }); + + it('refuses in a narrow window', () => { + const app = gridApp(); + windowStub.innerWidth = 1100; + open(app); + expect(menu()).toBeNull(); + }); +}); + +describe('picking a count', () => { + it('Enter on 2 opens two tiles, the active session included and focused, and remembers 2', () => { + const app = gridApp(); + app.activeSessionId = 's-c'; + open(app); + key(app, 'ArrowDown'); // 6 -> 2 + key(app, 'Enter'); + expect(menu()).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-other', 's-c']); + expect(app._tileGrid.focusedId).toBe('s-c'); + expect(localStore.get('codeman:tile-count')).toBe('2'); + // The grid's own key stays ids only. + const stored = JSON.parse(localStore.get('codeman:tile-grid')!); + expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']); + }); + + it('a click on 4 opens four, Space works too', () => { + const app = gridApp(); + open(app); + item(4).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.ids).toHaveLength(4); + app.closeTileGrid({ reselect: false }); + open(app); + item(2).focus(); + key(app, ' '); + expect(app._tileGrid.ids).toHaveLength(2); + }); + + it('then the click and Ctrl+Shift+G open with the remembered count', () => { + const app = gridApp(); + localStore.set('codeman:tile-count', '4'); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']); + app.toggleTileGrid(); + localStore.delete('codeman:tile-grid'); + localStore.set('codeman:tile-count', '2'); + app.runTileShortcut('toggle-tile-grid'); + expect(app._tileGrid.ids).toHaveLength(2); + }); + + it('the default is 6, and the click never opens more than the window fits', () => { + const app = gridApp(); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toHaveLength(6); + app.closeTileGrid({ keepStored: false, reselect: false }); + wrapRect = { width: 1200, height: 900 }; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toHaveLength(4); + }); + + it('fewer open sessions than the count: fewer tiles', () => { + const app = gridApp(['s-a', 's-b']); + open(app); + item(6).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b']); + }); +}); + +describe('Ctrl/Cmd+click on a tab with the grid closed', () => { + it('opens the remembered count in total, the clicked session among them and focused (owner answer 2)', () => { + const app = gridApp(); + localStore.set('codeman:tile-count', '4'); + app.addSessionToTiles('s-f'); + expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-f']); + expect(app._tileGrid.focusedId).toBe('s-f'); + }); + + it('with the default 6, six in total, never seven', () => { + const app = gridApp(); + app.addSessionToTiles('s-g'); + expect(app._tileGrid.ids).toHaveLength(6); + expect(app._tileGrid.ids.at(-1)).toBe('s-g'); + }); +}); + +describe('re-forming an open grid', () => { + it('6 to 2 keeps the tiles it has first, drops from the end, never the focused one', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-e' }); + open(app); + item(2).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.ids).toEqual(['s-a', 's-e']); + expect(app._tileGrid.focusedId).toBe('s-e'); + expect(app.activeSessionId).toBe('s-e'); + // The dropped ones were destroyed, not reloaded or kept connected. + const destroyed = FakeTile.all.filter((t) => t.destroy.mock.calls.length).map((t) => t.sessionId); + expect(destroyed.sort()).toEqual(['s-b', 's-c', 's-d', 's-f']); + expect(localStore.get('codeman:tile-count')).toBe('2'); + }); + + it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => { + const app = gridApp(); + app.openTileGrid(['s-c', 's-a']); + const before = new Map(FakeTile.all.map((t) => [t.sessionId, t])); + open(app); + item(6).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-other', 's-b', 's-d', 's-e']); + expect(app._tileFor('s-c')).toBe(before.get('s-c')); + expect(app._tileFor('s-a')).toBe(before.get('s-a')); + expect(before.get('s-c')!.connect).toHaveBeenCalledTimes(1); + }); + + it('every joining tile connects only once the grid has its final cells (one fit, one PTY resize)', () => { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + const seen: string[] = []; + const realMount = app._mountTile.bind(app); + app._mountTile = (id: string, opts?: unknown) => { + const ok = realMount(id, opts); + const tile = app._tileGrid.tiles.get(id)?.tile as FakeTile | undefined; + tile?.connect.mockImplementation(async () => { + seen.push(`${id}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(id)}`); + }); + return ok; + }; + open(app); + item(6).dispatch('click', { stopPropagation: vi.fn() }); + // Each one connected into the 3x2 grid, already in its final cell. + expect(seen).toEqual(['s-other:6:2', 's-c:6:3', 's-d:6:4', 's-e:6:5']); + }); + + it('holes fill first: a 3x2 of five with an empty middle cell grows to six there', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 5)); + app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e']; + app._applyTileLayout(); + open(app); + item(6).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-b', 's-c', 's-d', 's-e']); + }); + + it('6 to 4: the cell model rule (a tile left in the third column packs the rest)', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-a' }); + open(app); + item(4).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.cols).toBe(2); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']); + }); + + it('the same count changes nothing; a zoom the user chose ends with a re-form', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 4)); + const cells = app._tileGrid.cells.slice(); + open(app); + item(4).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.cells).toEqual(cells); + app.zoomTile('s-b'); + open(app); + item(2).dispatch('click', { stopPropagation: vi.fn() }); + expect(app._tileGrid.zoomedId).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + }); +}); + +describe('the session picker is gone', () => { + it('no picker method, element, style or markup hook is left', () => { + const app = makeGridApp(IDS); + expect(app.openTilePicker).toBeUndefined(); + expect(app.closeTilePicker).toBeUndefined(); + expect(gridSrc).not.toMatch(/tilePickerMenu|tile-picker|openTilePicker|closeTilePicker/); + expect(css).not.toMatch(/tile-picker/); + expect(html).not.toMatch(/openTilePicker/); + expect(appSrc).not.toMatch(/closeTilePicker/); + }); + + it('a tile has no + (owner decision 9): tiles come from the Tiles button, its menu, Ctrl/Cmd+click, a tab, a group or Run', () => { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + const header = app._tileGrid.tiles.get('s-a').header as FakeEl; + const actions = header.children.find((c) => c.className === 'tile-actions')!; + expect(actions.children.map((b) => b.className)).toEqual(['tile-btn tile-menu', 'tile-btn tile-zoom', 'tile-btn tile-remove']); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index dcc470fd..0c57bbad 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -5,7 +5,7 @@ * * - Runtime strings are HARVESTED from the real tile code (the shared vm * harness, test/mocks/tile-grid-vm.ts) driven through every state that - * writes text: the picker (cap and window wording), the Attach + * writes text: the count menu (cap and window wording), the Attach * overlay (not attached, attaching, exited, ended), zoom, the header * tooltip, the dividers, the empty slot, every toast, the crash-restart * confirm, the Tiles and Split button titles. Each must translate to text @@ -14,8 +14,8 @@ * - Static strings: the shortcut registry's tile entries (overlay and App * Settings list), and index.html run through the real translator in JSDOM * (the Tiles button, the App Settings chips, the Help modal's Tiles rows). - * - User text stays as typed: session names (tile header, picker) - * and group names carry data-i18n-skip, and a session name inside the + * - User text stays as typed: session names (tile header) and group names + * carry data-i18n-skip, and a session name inside the * confirm passes through the pattern untranslated. * * Port: N/A. @@ -163,34 +163,22 @@ async function exercise() { app.sessions.get('s-2').pid = null; app.sessions.get('s-3').paneExit = { status: 3 }; app.sessions.get('s-4').paneExit = { signal: 9 }; - // A session named like a UI string: user text, never translated. - app.sessions.get('s-5').name = 'Open tiles'; + // A session named like a UI string (a count menu label): user text, never translated. + app.sessions.get('s-5').name = '6 tiles'; - // The picker, the cap wording, then the window wording. - app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); - const boxes = () => - body.children.find((c) => c.id === 'tilePickerMenu')!.children[0].children.map((r) => r.children[0]); - for (const b of boxes()) { - if (b.checked || b.disabled) continue; - b.checked = true; - b.dispatch('change'); - } - harvestAll(app, 'picker, cap'); - app.closeTilePicker(); + // The count menu: the cap (nothing greyed), then the window wording. + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + harvestAll(app, 'count menu, cap'); + app.closeTileCountMenu(); wrapRect = { width: 1200, height: 900 }; - app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); - for (const b of boxes()) { - if (b.checked || b.disabled) continue; - b.checked = true; - b.dispatch('change'); - } - harvestAll(app, 'picker, window'); - app.closeTilePicker(); + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + harvestAll(app, 'count menu, window'); + app.closeTileCountMenu(); // A window that fits one tile words it in the singular. wrapRect = { width: 700, height: 500 }; - app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); - harvestAll(app, 'picker, one tile'); - app.closeTilePicker(); + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + harvestAll(app, 'count menu, one tile'); + app.closeTileCountMenu(); wrapRect = { width: 2400, height: 1200 }; // The grid: five tiles first (an empty slot), then the sixth. @@ -270,9 +258,11 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( ({ confirmText } = await exercise()); // ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.) const expected = [ - 'Show sessions as tiles', - 'Open tiles', - 'Up to 6 tiles', + 'How many tiles', + 'Tiles', + '2 tiles', + '4 tiles', + '6 tiles', 'The grid holds at most 6 tiles', 'This window fits 4 tiles', 'This window fits 1 tile', @@ -303,9 +293,9 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'This group has no session to show as tiles', 'No sessions to show as tiles', 'Split: unavailable while tiles are open', - 'Tiles: show several sessions side by side (right-click to choose which)', - 'Tiles: back to a single session (right-click to choose which sessions)', - 'Open tiles was stopped after crashing repeatedly. Restart it?', + 'Tiles: show several sessions side by side (right-click for how many)', + 'Tiles: back to a single session (right-click for how many tiles)', + '6 tiles was stopped after crashing repeatedly. Restart it?', ]; const missing = expected.filter((s) => !seen.has(s)); expect(missing).toEqual([]); @@ -316,7 +306,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( for (const [source, where] of seen) { const text = zh.api.t(source); // The session name inside the confirm is user text, allowed to stay. - const words = leftover(text.replace('Open tiles', '')); + const words = leftover(text.replace('6 tiles', '')); if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`); } expect(bad).toEqual([]); @@ -386,7 +376,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { const doc = dom.window.document; const extra = doc.createElement('div'); extra.innerHTML = - 'Open tiles' + + '6 tiles' + 'Tiles' + 'Tiles'; doc.body.appendChild(extra); @@ -417,6 +407,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { expect(leftover(tiles!.textContent!)).toEqual([]); expect(tiles!.textContent).toContain('切换平铺网格'); expect(tiles!.textContent).toContain('右键单击'); + expect(tiles!.textContent).toContain('选择窗格数量(2、4 或 6)'); // Moving tiles: the chords and the header drag. expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格'); expect(tiles!.textContent).toContain('拖动'); @@ -424,16 +415,16 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { }); it('user text stays as typed: a session name and a group name that are also UI words', () => { - expect(doc.getElementById('u-session')!.textContent).toBe('Open tiles'); + expect(doc.getElementById('u-session')!.textContent).toBe('6 tiles'); expect(doc.getElementById('u-group')!.textContent).toBe('Tiles'); expect(doc.getElementById('control')!.textContent).toBe('平铺'); }); }); describe('user text in the tile code', () => { - it('session names in a tile header and the picker are marked data-i18n-skip', () => { + it('session names in a tile header are marked data-i18n-skip', () => { // The harvest saw them only inside skipped subtrees. - expect(userText.has('Open tiles')).toBe(true); + expect(userText.has('6 tiles')).toBe(true); expect([...seen.keys()]).not.toContain('s-1'); const src = read('tile-grid.js'); expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2); @@ -444,7 +435,7 @@ describe('user text in the tile code', () => { }); it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => { - expect(confirmText).toEqual(['Open tiles was stopped after crashing repeatedly. Restart it?']); - expect(zh.api.t(confirmText[0])).toBe('Open tiles 因反复崩溃已被停止。要重启吗?'); + expect(confirmText).toEqual(['6 tiles was stopped after crashing repeatedly. Restart it?']); + expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?'); }); }); diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index b8c5f2d6..35c2527c 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -8,8 +8,8 @@ * focused. Detached sessions and ones that no longer exist are left out, as * in the picker. * - The button's click and Ctrl+Shift+G are the same function - * (`toggleTileGrid`); right-click (contextmenu) opens the picker. With the - * grid open the picker shows the current tiles, and Open replaces them. + * (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner + * decision 10). With the grid open a pick re-forms it, the focused tile kept. * * The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts). * Port: N/A. @@ -99,11 +99,11 @@ describe('the Tiles button and Ctrl+Shift+G', () => { return html; }; - it('a click opens the grid at once (the toggle), a right-click opens the picker', () => { + it('a click opens the grid at once (the toggle), a right-click opens the count menu', () => { expect(button()).toContain('onclick="app.toggleTileGrid()"'); - expect(button()).toContain('oncontextmenu="app.openTilePicker(event)"'); - expect(button()).toContain('right-click to choose which'); - expect(button()).not.toContain('onclick="app.openTilePicker'); + expect(button()).toContain('oncontextmenu="app.openTileCountMenu(event)"'); + expect(button()).toContain('right-click for how many'); + expect(button()).not.toContain('onclick="app.openTileCountMenu'); }); it('Ctrl+Shift+G runs the same toggle', () => { @@ -114,14 +114,18 @@ describe('the Tiles button and Ctrl+Shift+G', () => { describe('opening at once, in the app', () => { const IDS = ['s-a', 's-b', 's-c']; - const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null; + const picker = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null; + const pick = (n: number) => + picker()! + .children.find((c) => c.dataset.count === String(n))! + .dispatch('click', { stopPropagation: vi.fn() }); beforeEach(() => { resetGridHarness(); const wrap = new FakeEl(); bySelector.set('.terminal-wrap', wrap); }); - it('a click with the grid closed shows the tiles, no picker', () => { + it('a click with the grid closed shows the tiles, no menu', () => { const app = makeGridApp(IDS); app.activeSessionId = 's-b'; app.toggleTileGrid(); @@ -131,19 +135,19 @@ describe('opening at once, in the app', () => { expect(app.activeSessionId).toBe('s-b'); }); - it('a click closes a picker that a right-click left open', () => { + it('a click closes a menu that a right-click left open', () => { const app = makeGridApp(IDS); - app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); expect(picker()).not.toBeNull(); app.toggleTileGrid(); expect(picker()).toBeNull(); expect(app._tilesOwnTerminal()).toBe(true); }); - it('a click elsewhere closes the picker; a click inside it does not', () => { + it('a click elsewhere closes the menu; a click inside it does not', () => { const app = makeGridApp(IDS); const before = documentAddEventListener.mock.calls.length; - app.openTilePicker({ preventDefault: vi.fn() }); + app.openTileCountMenu({ preventDefault: vi.fn() }); const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>; const onClick = calls.find(([type]) => type === 'click')![1]; onClick({ target: picker()!.children[0] }); @@ -152,31 +156,27 @@ describe('opening at once, in the app', () => { expect(picker()).toBeNull(); }); - it('a right-click opens the picker and keeps the browser menu away', () => { + it('a right-click opens the count menu and keeps the browser menu away', () => { const app = makeGridApp(IDS); const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; - app.openTilePicker(ev); + app.openTileCountMenu(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(picker()).not.toBeNull(); expect(app._tilesOwnTerminal()).toBe(false); }); - it('a right-click with the grid open shows its tiles; Open replaces them', () => { + it('a right-click with the grid open re-forms it to the count picked, the focused tile kept', () => { const app = makeGridApp(IDS); app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' }); - app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); expect(app._tilesOwnTerminal()).toBe(true); - const menu = picker()!; - const boxes = menu.children[0].children.map((row) => row.children[0]); - expect(boxes.filter((b) => b.checked).map((b) => b.value)).toEqual(['s-a', 's-b']); - const boxA = boxes.find((b) => b.value === 's-a')!; - boxA.checked = false; - boxA.dispatch('change'); - const boxC = boxes.find((b) => b.value === 's-c')!; - boxC.checked = true; - boxC.dispatch('change'); - menu.children[1].children[1].dispatch('click'); - expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + pick(4); + // Its two first, then the open sessions in tab order. + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + pick(2); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); expect(app.activeSessionId).toBe('s-b'); }); }); diff --git a/test/tile-grid-picker.test.ts b/test/tile-grid-picker.test.ts deleted file mode 100644 index 820f9872..00000000 --- a/test/tile-grid-picker.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @fileoverview The header Tiles button and its picker. - * - * - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker - * class) and hard-gated to desktop widths like Split: a JS width check plus a - * CSS `@media (max-width: 1179px)` backstop, and never in a solo window. - * With the grid open it closes it (`aria-pressed`). - * - The picker has a checkbox per open session, in tab order, never a session - * popped out to its own window; names are text, never markup. It is - * preselected with the grid this tab last left, else the active session and - * an open split's two. Boxes past what the window can fit are disabled; Open - * opens the grid on the checked ones, focusing the active session if checked. - * - Escape closes it, and its close method is idempotent (the global Escape - * handler calls every close method). - * - A tile has no + (owner decision 9): tiles are added from this picker, - * Ctrl/Cmd+click, a dragged tab, a tab group or Run. - * - * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. - */ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - FakeEl, - body, - bySelector, - makeGridApp, - resetGridHarness, - windowStub, - type GridApp, -} from './mocks/tile-grid-vm.js'; - -const IDS = ['s-a', 's-b', 's-c']; -const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); - -function makeButton() { - const btn = new FakeEl(); - btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden'; - bySelector.set('.btn-tile-grid', btn); - return btn; -} - -const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null; -const rows = () => picker()!.children[0].children; -const boxOf = (id: string) => rows().find((r) => r.children[0].value === id)!.children[0]; -const openButton = () => picker()!.children[1].children[1]; -const check = (id: string, on = true) => { - const box = boxOf(id); - box.checked = on; - box.dispatch('change'); -}; - -let wrapRect = { width: 2400, height: 1200 }; -beforeEach(() => { - resetGridHarness(); - wrapRect = { width: 2400, height: 1200 }; - const wrap = new FakeEl(); - wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height }); - bySelector.set('.terminal-wrap', wrap); -}); -afterEach(() => { - vi.useRealTimers(); -}); - -describe('the Tiles button', () => { - it('shows only when its setting is on and the window is desktop-wide', () => { - const app = makeGridApp(IDS); - const btn = makeButton(); - app._applyTileGridButtonVisibility(false); - expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); - app._applyTileGridButtonVisibility(true); - expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false); - windowStub.innerWidth = 1100; - app._applyTileGridButtonVisibility(true); - expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); - }); - - it('never in a solo window', () => { - const app = makeGridApp(IDS); - app.isSoloWindow = true; - const btn = makeButton(); - app._applyTileGridButtonVisibility(true); - expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true); - }); - - it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => { - expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/); - expect(css).toMatch( - /@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/ - ); - expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/); - }); - - it('with the grid open, a click closes it, and the button says so meanwhile', () => { - const app = makeGridApp(IDS); - const btn = makeButton(); - app.selectSession = vi.fn(); - app.openTileGrid(IDS); - expect(btn.getAttribute('aria-pressed')).toBe('true'); - expect(btn.classList.contains('tiles-open')).toBe(true); - app.toggleTileGrid(); - expect(app._tilesOwnTerminal()).toBe(false); - expect(btn.getAttribute('aria-pressed')).toBe('false'); - expect(picker()).toBeNull(); - }); -}); - -describe('the picker', () => { - it('lists open sessions in tab order, never a detached one, names as text', () => { - const app = makeGridApp(IDS); - app.sessions.get('s-b').name = 'b'; - app.detachedSessions.add('s-c'); - app.sessions.get('s-a').pid = null; // offered: its tile shows the Attach overlay - app.openTilePicker({ stopPropagation: vi.fn() }); - const labels = rows().map((r) => r.children[1]); - expect(rows().map((r) => r.children[0].value)).toEqual(['s-other', 's-a', 's-b']); - expect(labels[2].textContent).toBe('b'); - expect(labels[2].children).toHaveLength(0); - expect(labels[2].getAttribute('data-i18n-skip')).toBe(''); - }); - - it('preselects the active session', () => { - const app = makeGridApp(IDS); - app.activeSessionId = 's-b'; - app.openTilePicker({ stopPropagation: vi.fn() }); - expect( - rows() - .filter((r) => r.children[0].checked) - .map((r) => r.children[0].value) - ).toEqual(['s-b']); - }); - - it('preselects the grid this tab last left', () => { - const app = makeGridApp(IDS); - app.selectSession = vi.fn(); - app.openTileGrid(['s-a', 's-c']); - app.closeTileGrid({ reselect: false }); - app.openTilePicker({ stopPropagation: vi.fn() }); - expect( - rows() - .filter((r) => r.children[0].checked) - .map((r) => r.children[0].value) - ).toEqual(['s-a', 's-c']); - }); - - it('disables boxes past what the window fits, and says how many', () => { - const app = makeGridApp(IDS); - wrapRect = { width: 1000, height: 400 }; // fits 2 (2x1 of 500x400) - app.openTilePicker({ stopPropagation: vi.fn() }); - expect(picker()!.children[1].children[0].textContent).toBe('This window fits 2 tiles'); - check('s-b'); - expect(boxOf('s-c').disabled).toBe(true); - expect(boxOf('s-other').disabled).toBe(true); - check('s-b', false); - expect(boxOf('s-c').disabled).toBe(false); - }); - - it('Open opens the grid on the checked sessions, in tab order, focusing the active one', () => { - const app = makeGridApp(IDS); - app.activeSessionId = 's-b'; - app.openTilePicker({ stopPropagation: vi.fn() }); - check('s-a'); - check('s-c'); - openButton().dispatch('click'); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); - expect(app.activeSessionId).toBe('s-b'); - expect(picker()).toBeNull(); - }); - - it('Open is disabled with nothing checked', () => { - const app = makeGridApp(IDS); - app.openTilePicker({ stopPropagation: vi.fn() }); - check('s-a', false); - expect(openButton().disabled).toBe(true); - }); - - it('Escape closes it, and closing twice is harmless (the global Escape calls every close method)', () => { - const app = makeGridApp(IDS); - app.openTilePicker({ stopPropagation: vi.fn() }); - const onKey = app._tilePicker.onKey; - onKey({ key: 'Escape' }); - expect(picker()).toBeNull(); - expect(() => app.closeTilePicker()).not.toThrow(); - }); - - it('the global Escape handler closes the picker', () => { - const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); - const escape = src.slice(src.indexOf("if (e.key === 'Escape') {"), src.indexOf('Option/Alt session navigation')); - expect(escape).toContain('this.closeTilePicker?.();'); - }); - - it('refuses in a narrow window', () => { - const app = makeGridApp(IDS); - windowStub.innerWidth = 1100; - app.openTilePicker({ stopPropagation: vi.fn() }); - expect(picker()).toBeNull(); - }); -}); diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index bcc9eb26..1367d597 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -168,6 +168,7 @@ describe('page load with a stored open grid', () => { it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => { localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' })); + localStore.set('codeman:tile-count', '2'); const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); expect(app._tilesOwnTerminal()).toBe(false); expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true }); @@ -178,6 +179,35 @@ describe('page load with a stored open grid', () => { expect(app.activeSessionId).toBe('s-c'); }); + it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => { + // Default 6 (owner answer 1, superseding decision 8's "exactly the stored set"). + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' })); + const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); + app.activeSessionId = 's-a'; + app.toggleTileGrid(); + // The two stay in the first row (2x1 to 2x2 keeps them), the rest join + // in tab order: every live session, fewer than the count. + expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a stored hole is filled first when the count needs more tiles', () => { + // A 2x2 of three with the hole first; the count asks for four. + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' })); + localStore.set('codeman:tile-count', '4'); + const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); + app.toggleTileGrid(); + // s-a joins in the hole (packing would have put it last). + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']); + }); + + it('a reload brings back exactly the stored grid, whatever the count', () => { + localStore.set('codeman:tile-count', '6'); + storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' }); + const app = pageLoad(IDS); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + }); + it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => { storeGrid({ ids: IDS, focused: 's-a' }); windowStub.innerWidth = 1100; diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index f260a50f..917b8d73 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -224,13 +224,14 @@ describe('the capture-phase handler', () => { }); describe('the actions', () => { - it('the toggle brings back the grid this tab last left, focus included', () => { + it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => { 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); + // Its three first, then the open sessions in tab order (the default count, 6). + expect(app._tileGrid.ids).toEqual([...IDS, 's-other']); expect(app.activeSessionId).toBe('s-c'); }); @@ -240,7 +241,9 @@ describe('the actions', () => { app.closeTileGrid({ reselect: false }); app.sessions.delete('s-b'); app.toggleTileGrid(); - expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app._tileGrid.ids).not.toContain('s-b'); + // Its cell stays where it was, and the count fills it first (s-other, tab order). + expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']); }); it('with every remembered session gone, it opens on the open sessions in tab order instead', () => { diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index 9917f25e..cbfb14d8 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -21,6 +21,7 @@ import { FakeTile, body, bySelector, + localStore, main, makeGridApp, resetGridHarness, @@ -59,25 +60,36 @@ describe('opening the grid over an open split', () => { expect(paneB.destroy).toHaveBeenCalledTimes(1); expect(app._splitPane).toBeNull(); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + // The split's two first, then tab order up to the count (default 6). + expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']); 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('a remembered grid wins over an open split: exactly its tiles, the split closed and not merged', () => { + it("with a count of 2, exactly the split's two", () => { + const app = makeGridApp(IDS); + localStore.set('codeman:tile-count', '2'); + openSplit(app); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + }); + + it('a remembered grid wins over an open split: its tiles first, the split closed and not merged', () => { const app = makeGridApp(IDS); app.selectSession = vi.fn(); app.openTileGrid(['s-c']); app.closeTileGrid({ reselect: false }); app.activeSessionId = 's-a'; + localStore.set('codeman:tile-count', '2'); const paneB = openSplit(app); app.toggleTileGrid(); expect(paneB.destroy).toHaveBeenCalledTimes(1); expect(app._splitPane).toBeNull(); - expect(app._tileGrid.ids).toEqual(['s-c']); + // Filled from tab order (s-other first), not seeded with the split's two. + expect(app._tileGrid.ids).toEqual(['s-c', 's-other']); expect(app.activeSessionId).toBe('s-c'); }); From 78bfe53dc75fe0b3edafe40b5a89178965f37bb9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 04:24:54 +0200 Subject: [PATCH 83/90] feat(tiles): the grid opens and closes with a short animation Owner request: "when clicking on the tile button first make this animation nicer". Recorded before: the click froze the page, six empty tiles cut in at once, each tile's history then scrolled in visibly as its load landed, and closing showed an empty single view for about a quarter of a second before its own replay scrolled in. The grid's own motion, on by default (not an entrance-animations.js theme, which are off by default): - Opening: each tile fades and settles in (opacity, translateY 6px, scale .97), 180 ms, 24 ms apart in reading order: the last of six is done at 300 ms. Its terminal stays transparent until the load queue reports its first capture done, then fades in whole (160 ms), so no replay scrolls by; a 15 s backstop shows it should that never come. A tile added later enters the same way. - Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these): the close stays synchronous, and a still copy of the tiles (clones: no xterm, socket or listener; inert, aria-hidden, no pointer) dims at once over the stage, holds until the single view's selectSession has replayed its session (at most 700 ms), then fades out. No empty single view between the two. A reopen drops a copy still showing; a web tab hides it. - A re-form to another count fades the old grid's copy out at once while the new tiles enter. - The count menu fades in (140 ms). Every one animates opacity and transform only, so FitAddon measures the final cell and each tile still sends one PTY resize (#464); nothing moves, and no copy is made, under prefers-reduced-motion. Tests (tile-grid-motion.test.ts): the stagger, the reveal and its backstop, the same fits and connects with and without motion, the held copy (released on settle, by its cap, removed on the last tile-leave or its fallback), only the toggle animates, a reopen purges, the zoomed tile alone, the re-form, reduced motion, and a CSS guard that every new keyframe touches only opacity and transform. The vm harness gains style.setProperty, cloneNode, isConnected and lastElementChild. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/styles.css | 132 +++++++++++++++ src/web/public/tile-grid.js | 173 ++++++++++++++++--- test/mocks/tile-grid-vm.ts | 34 +++- test/tile-grid-motion.test.ts | 308 ++++++++++++++++++++++++++++++++++ 4 files changed, 625 insertions(+), 22 deletions(-) create mode 100644 test/tile-grid-motion.test.ts diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8f5281cf..1afe5454 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19817,6 +19817,15 @@ body.tile-grid-resizing--row * { border-radius: 10px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25); font-size: 13px; + transform-origin: top right; + animation: tile-count-menu-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes tile-count-menu-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.96); + } } .tile-count-heading { @@ -19890,6 +19899,129 @@ body.tile-grid-resizing--row * { font-size: 11px; } +/* ── Tile grid motion (the grid's own default, not an entrance theme) ────── + Opening the grid, each tile fades and settles in, staggered in reading + order; its terminal stays transparent until its first capture lands, then + fades in whole (no replay scrolling by). Closing it with the Tiles toggle, a + still copy of the tiles (tile-grid.js _ghostTileGrid: clones, no + terminals) dims at once, stays over the single view until it has its + content, then fades. Every one animates opacity and transform only: + FitAddon reads the untransformed layout box, so each tile still fits once + at its final size and sends one PTY resize (#464). Nothing moves under + prefers-reduced-motion. */ +.tile.tile--entering { + animation: tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + animation-delay: calc(var(--tile-enter-index, 0) * 24ms); +} + +/* A tile that needs you enters AND pulses (one animation property). */ +.tile.tile--entering.tile--needs { + animation: + tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both, + tile-needs-pulse 1.6s ease-in-out infinite; +} + +@keyframes tile-enter { + from { + opacity: 0; + transform: translateY(6px) scale(0.97); + } +} + +.tile-body .xterm { + transition: opacity 160ms ease-out; +} + +.tile.tile--revealing .tile-body .xterm { + opacity: 0; +} + +/* The "Loading…" label breathes while the tile waits for its turn. */ +.tile.tile--loading .tile-body::after { + animation: tile-loading-breathe 1.2s ease-in-out infinite alternate; +} + +@keyframes tile-loading-breathe { + from { + opacity: 0.25; + } + to { + opacity: 0.7; + } +} + +.tile-grid-ghosts { + position: absolute; + z-index: 3; + display: grid; + gap: 0; + padding: 4px; + box-sizing: border-box; + pointer-events: none; +} + +/* A web tab takes the stage at once, the still copy with it. */ +.main.webview-active .tile-grid-ghosts { + display: none; +} + +.tile-grid-ghosts .tile.tile--leaving { + animation: tile-ghost-dim 150ms ease-out both; +} + +.tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving { + animation: tile-leave 170ms cubic-bezier(0.4, 0, 1, 1) both; +} + +.tile-grid-ghosts.tile-grid-ghosts--now.tile-grid-ghosts--release .tile.tile--leaving { + animation: tile-leave-now 170ms cubic-bezier(0.4, 0, 1, 1) both; +} + +@keyframes tile-ghost-dim { + to { + opacity: 0.8; + transform: scale(0.985); + } +} + +@keyframes tile-leave { + from { + opacity: 0.8; + transform: scale(0.985); + } + to { + opacity: 0; + transform: scale(0.97); + } +} + +@keyframes tile-leave-now { + to { + opacity: 0; + transform: scale(0.98); + } +} + +@media (prefers-reduced-motion: reduce) { + .tile-count-menu, + .tile.tile--entering, + .tile.tile--entering.tile--needs, + .tile.tile--loading .tile-body::after, + .tile-grid-ghosts .tile.tile--leaving, + .tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving, + .tile-grid-ghosts.tile-grid-ghosts--now.tile-grid-ghosts--release .tile.tile--leaving { + animation: none; + } + + .tile-grid-ghosts { + display: none; + } + + .tile-body .xterm { + transition: none; + } +} + /* Split-picker menu: a small popover listing sessions to split with, appended to document.body and positioned `fixed` by openSplitPicker() (JS sets top/right against the Split header button's own rect). z-index above the diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b8985e7d..1a954439 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -32,6 +32,16 @@ const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so // the stored grid above stays ids only. const TILE_GRID_COUNT_KEY = 'codeman:tile-count'; +// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still +// copy of a closing grid goes even if animationend never comes (a hidden tab, +// a cancelled animation). +const TILE_GHOST_FALLBACK_MS = 450; +// The longest a closing grid's still copy waits for the single view's replay +// before it fades anyway, so a slow load never leaves a stale picture up. +const TILE_GHOST_HOLD_MAX_MS = 700; +// A tile's terminal shows once its first capture has landed (the load queue +// says so); this is the backstop should that never be reported. +const TILE_REVEAL_FALLBACK_MS = 15000; // 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. const TILE_GRID_REFIT_MS = 150; @@ -194,7 +204,11 @@ Object.assign(CodemanApp.prototype, { // A quiet "loading" state on a tile until its capture has landed. onChange: (tile, state) => { const entry = grid.tiles.get(tile.sessionId); - if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle'); + if (entry?.tile !== tile) return; + entry.el.classList.toggle('tile--loading', state !== 'idle'); + // The first capture has landed (or failed): the terminal fades in, + // whole, instead of showing its replay scroll by. + if (state === 'idle') entry.el.classList.remove('tile--revealing'); }, }); } @@ -264,6 +278,8 @@ Object.assign(CodemanApp.prototype, { // here (a 1000-line serialize and up to 256 KB of localStorage for a // non-shell session) only ever produced a copy that was thrown away. this._cleanupPreviousSession(focus, { skipSnapshot: wanted.includes(this.activeSessionId) }); + // A grid closed a moment ago may still be fading out over the stage. + this._purgeTileGhosts(); grid.open = true; grid.cells = []; grid.cols = 0; @@ -274,8 +290,10 @@ Object.assign(CodemanApp.prototype, { this.hideWelcome(); // Mount every tile and lay the grid out BEFORE any tile connects, so each // first fit measures its real cell; the focused tile connects first, so - // its capture is the one the queue starts with. - for (const id of wanted) this._mountTile(id); + // its capture is the one the queue starts with. Each tile enters in + // reading order (opacity and transform only: the fit measures the final + // cell, so the animation adds no resize). + wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k })); // Packed from the first cell (_applyTileLayout pads the shape with empty cells). grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); @@ -300,17 +318,24 @@ Object.assign(CodemanApp.prototype, { * * `keepStored` remembers the grid for one-click return (toggleTileGrid). * `reselect` shows the focused session in the single view through a forced - * reload; pass false when the caller selects something itself. + * reload; pass false when the caller selects something itself. `animate` + * (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles + * over the stage until the single view has its content (_ghostTileGrid). * * The main terminal's cached content for EVERY tiled id is invalidated: it was * written before the grid opened, possibly hours ago, and selectSession paints * a snapshot as its first frame. */ - closeTileGrid({ keepStored = true, reselect = true } = {}) { + closeTileGrid({ keepStored = true, reselect = true, animate = false } = {}) { const grid = this._tileGrid; if (!grid?.open) return; const focusedId = grid.focusedId; const ids = grid.ids.slice(); + // The close itself stays synchronous (every caller relies on the grid + // being gone on return); what the user sees leave is a still copy of the + // tiles, dimmed at once, held until the single view has replayed its + // session (no empty flash), then faded out over it. + const releaseGhosts = animate ? this._ghostTileGrid({ hold: reselect }) : null; // Remembered (closed) for one-click return, or forgotten. if (keepStored) this._persistTileGrid({ open: false }); else this._forgetStoredTileGrid(); @@ -365,7 +390,92 @@ Object.assign(CodemanApp.prototype, { this._updateTileGridButtonState(); // The tabs drop their .in-tiles marker. this.renderSessionTabs?.(); - if (reselect) this._selectAfterTileGrid(focusedId); + if (reselect) { + const shown = this._selectAfterTileGrid(focusedId); + if (releaseGhosts) Promise.resolve(shown).finally(releaseGhosts); + } else { + releaseGhosts?.(); + } + }, + + /** False under prefers-reduced-motion: the grid then opens and closes at once. */ + _tileMotionAllowed() { + return !window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + }, + + /** + * A still copy of the open grid over the stage, so the tiles leave calmly + * while closeTileGrid (or a re-form) tears the real ones down at once. + * Clones only: no xterm, socket or listener comes along (the DOM renderer's + * rows and their style elements do, so the copy shows the text), and the + * layer is inert, hidden from assistive tech and takes no pointer. With + * `hold` it dims at once and waits for the returned release() (the single + * view has its content), at most TILE_GHOST_HOLD_MAX_MS; without, it fades + * out straight away. It removes itself when the fade ends (or after + * TILE_GHOST_FALLBACK_MS). Nothing under reduced motion (release is then a + * no-op). + * + * @returns {() => void} release + */ + _ghostTileGrid({ hold = false } = {}) { + const grid = this._tileGrid; + const section = document.getElementById('tileGrid'); + const main = section?.parentElement; + const none = () => {}; + if (!grid?.open || !section || !main || !this._tileMotionAllowed()) return none; + this._purgeTileGhosts(); + const s = section.getBoundingClientRect(); + const m = main.getBoundingClientRect(); + if (!(s.width > 0 && s.height > 0)) return none; + const layer = document.createElement('div'); + layer.className = hold ? 'tile-grid-ghosts' : 'tile-grid-ghosts tile-grid-ghosts--now'; + layer.setAttribute('aria-hidden', 'true'); + layer.inert = true; + layer.style.left = `${s.left - m.left}px`; + layer.style.top = `${s.top - m.top}px`; + layer.style.width = `${s.width}px`; + layer.style.height = `${s.height}px`; + layer.style.gridTemplateColumns = section.style.gridTemplateColumns; + layer.style.gridTemplateRows = section.style.gridTemplateRows; + // Zoomed, only the zoomed tile is on screen (the others are display: none + // under .tile-grid--zoomed, a rule the ghost layer does not carry). + const zoomed = section.classList.contains('tile-grid--zoomed'); + for (const id of grid.ids) { + const el = grid.tiles.get(id)?.el; + if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue; + const ghost = el.cloneNode(true); + ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging'); + ghost.classList.add('tile--leaving'); + layer.appendChild(ghost); + } + main.appendChild(layer); + let fallback = null; + let holdCap = null; + const done = () => { + clearTimeout(fallback); + clearTimeout(holdCap); + layer.remove(); + if (this._tileGhostLayer === layer) this._tileGhostLayer = null; + }; + const release = () => { + if (fallback !== null || !layer.isConnected) return; + clearTimeout(holdCap); + layer.classList.add('tile-grid-ghosts--release'); + fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS); + }; + layer.addEventListener('animationend', (e) => { + if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done(); + }); + if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS); + else release(); + this._tileGhostLayer = layer; + return release; + }, + + /** Drops a still copy still showing (the grid reopened, or another close came). */ + _purgeTileGhosts() { + this._tileGhostLayer?.remove(); + this._tileGhostLayer = null; }, /** @@ -629,6 +739,8 @@ Object.assign(CodemanApp.prototype, { const leaving = grid.ids.filter((id) => !target.includes(id)); const joining = target.filter((id) => !grid.tiles.has(id)); if (leaving.length === 0 && joining.length === 0) return; + // The old grid fades out over the new one as it forms. + this._ghostTileGrid(); // A divider or header drag in progress measured the old grid. this._tileDividerDragTeardown?.(); this._endTileMoveDrag(); @@ -640,7 +752,7 @@ Object.assign(CodemanApp.prototype, { } if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; const staying = grid.ids.filter((id) => grid.tiles.has(id)); - const mounted = joining.filter((id) => this._mountTile(id)); + const mounted = joining.filter((id, k) => this._mountTile(id, { enterIndex: k })); const { cols, rows } = this._tileShapeFor(grid.tiles.size); grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows); grid.cols = cols; @@ -748,7 +860,7 @@ Object.assign(CodemanApp.prototype, { toggleTileGrid() { this.closeTileCountMenu(); if (this._tilesOwnTerminal()) { - this.closeTileGrid({ keepStored: true, reselect: true }); + this.closeTileGrid({ keepStored: true, reselect: true, animate: true }); return; } if (!this.canOpenTileGrid()) return; @@ -817,10 +929,11 @@ Object.assign(CodemanApp.prototype, { // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // or, if that session is gone, the same fallback as closing the active tab. + // Returns the selection's promise (it settles once the replay is written), + // or undefined for the welcome screen. _selectAfterTileGrid(sessionId) { if (sessionId && this.sessions.has(sessionId)) { - this.selectSession(sessionId, { forceReload: true, auto: true }); - return; + return this.selectSession(sessionId, { forceReload: true, auto: true }); } this.activeSessionId = null; try { @@ -829,24 +942,23 @@ Object.assign(CodemanApp.prototype, { /* Nothing stored. */ } const next = this.sessionOrder.find((id) => this.sessions.has(id)); - if (next) { - this.selectSession(next, { auto: true }); - } else { - this.terminal?.clear(); - this.showWelcome(); - } + if (next) return this.selectSession(next, { auto: true }); + this.terminal?.clear(); + this.showWelcome(); + return undefined; }, /** * Adds one session as a tile (open grid only), into `cell` when that cell is * empty (a tab dropped on it), else the first empty cell in reading order - * (_placeTile). Returns whether it was added. + * (_placeTile). It enters as the `enterIndex`-th of a staggered group + * (_mountTile). Returns whether it was added. */ - addTile(sessionId, { cell = -1 } = {}) { + addTile(sessionId, { cell = -1, enterIndex = 0 } = {}) { const grid = this._tileGrid; if (!grid?.open || grid.tiles.has(sessionId)) return false; if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; - if (!this._mountTile(sessionId)) return false; + if (!this._mountTile(sessionId, { enterIndex })) return false; this._placeTile(sessionId, cell); // A tile added while one is zoomed by hand is meant to be seen. if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; @@ -924,12 +1036,31 @@ Object.assign(CodemanApp.prototype, { return true; }, - _mountTile(sessionId) { + /** + * Builds one tile (header, body, TerminalTile); where it goes is the + * caller's (grid.cells). It enters with a short fade and settle + * (styles.css .tile--entering, the `enterIndex`-th of a staggered group), + * and its terminal stays transparent until its first capture lands + * (.tile--revealing, cleared by the load queue, with a backstop timer). + * Opacity and transform only, never anything its fit reads. + */ + _mountTile(sessionId, { enterIndex = 0 } = {}) { const grid = this._tileGrid; const session = this.sessions.get(sessionId); if (!session || this.detachedSessions?.has(sessionId)) return false; const el = document.createElement('div'); - el.className = 'tile'; + el.className = 'tile tile--revealing'; + setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS); + if (this._tileMotionAllowed()) { + el.classList.add('tile--entering'); + el.style.setProperty('--tile-enter-index', String(enterIndex)); + const onEnd = (e) => { + if (e.target !== el || e.animationName !== 'tile-enter') return; + el.removeEventListener('animationend', onEnd); + el.classList.remove('tile--entering'); + }; + el.addEventListener('animationend', onEnd); + } el.dataset.sessionId = sessionId; // Header and body are siblings: the chrome is refreshed in place // (_renderTileHeader), never by rewriting the tile, which would take the diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index ba61f63f..9e95b7df 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -22,7 +22,14 @@ export class FakeEl { id = ''; className = ''; dataset: Record = {}; - style: Record = {}; + /** Inline style; `setProperty` (custom properties) writes into it too. */ + style: Record = Object.defineProperty({} as Record, 'setProperty', { + value(this: Record, name: string, value: string) { + this[name] = value; + }, + enumerable: false, + }); + inert = false; children: FakeEl[] = []; parentElement: FakeEl | null = null; attrs: Record = {}; @@ -90,6 +97,29 @@ export class FakeEl { get firstChild() { return this.children[0] ?? null; } + get lastElementChild() { + return this.children.at(-1) ?? null; + } + /** In the fake document: its ancestors end at `main` or `body`. */ + get isConnected(): boolean { + let n: FakeEl = this; + while (n.parentElement) n = n.parentElement; + return n === main || n === body; + } + /** A copy of this element and (deep) its subtree: classes, data, style, attributes, text; no listeners. */ + cloneNode(deep = false): FakeEl { + const copy = new FakeEl(); + copy.id = this.id; + copy.className = this.className; + copy.dataset = { ...this.dataset }; + for (const [k, v] of Object.entries(this.style)) copy.style[k] = v; + copy.attrs = { ...this.attrs }; + copy.textContent = this.textContent; + copy.title = this.title; + copy.hidden = this.hidden; + if (deep) for (const child of this.children) copy.appendChild(child.cloneNode(true)); + return copy; + } get nextSibling() { const siblings = this.parentElement?.children ?? []; return siblings[siblings.indexOf(this) + 1] ?? null; @@ -324,6 +354,8 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp { /** Resets the shared fake DOM and tile registry between tests. */ export function resetGridHarness() { FakeTile.all = []; + // A test's prefers-reduced-motion answer (window.matchMedia) goes with it. + delete windowStub.matchMedia; focusedEl = null; idleCallbacks.length = 0; rafCallbacks.length = 0; diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts new file mode 100644 index 00000000..fd331ffe --- /dev/null +++ b/test/tile-grid-motion.test.ts @@ -0,0 +1,308 @@ +/** + * @fileoverview The grid's open and close animation (owner request: "when + * clicking on the tile button first make this animation nicer"). + * + * - Opening: every tile mounts with `.tile--entering` and its place in reading + * order (`--tile-enter-index`, the CSS stagger), cleared by its own + * `tile-enter` animationend only. Its terminal stays transparent + * (`.tile--revealing`) until the load queue reports its first capture done, + * with a backstop timer. + * - The animation adds no fit and no PTY resize: a grid opened with motion + * fits and connects its tiles exactly as one opened under reduced motion. + * - Closing with the Tiles toggle (button, Ctrl+Shift+G; owner answer 4): a + * still copy of the tiles (clones, no TerminalTile) dims over the stage and + * waits until the single view's selection has settled, at most 700 ms, then + * fades and removes itself (animationend, or a fallback timer). Every other + * close cuts as before. A reopen drops a copy still showing. + * - Re-forming to another count: the old grid's copy fades at once, the new + * tiles enter staggered, the tiles that stay do not. + * - Reduced motion: no entrance, no copy, no stagger property. + * - The CSS: every new keyframe animates opacity and transform only, all of + * it stops under prefers-reduced-motion, and a web tab hides the copy. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + body, + bySelector, + main, + makeGridApp, + resetGridHarness, + section, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; +const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8'); + +const tiles = () => section.children.filter((c) => c.classList.contains('tile')); +const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts')); +const end = (el: FakeEl, animationName: string, target: FakeEl = el) => el.dispatch('animationend', { target, animationName }); +function reducedMotion(on: boolean) { + windowStub.matchMedia = (q: string) => ({ matches: on && q.includes('reduce'), addEventListener: vi.fn() }); +} + +beforeEach(() => { + resetGridHarness(); + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-tile-grid'; + bySelector.set('.btn-tile-grid', btn); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {})); + return app; +} + +describe('opening', () => { + it('each tile enters, staggered in reading order', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 4), { focusedId: 's-c' }); + expect(tiles().map((t) => t.classList.contains('tile--entering'))).toEqual([true, true, true, true]); + expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']); + }); + + it('a tile stops entering on its own tile-enter animationend, not on a child or another animation', () => { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + const el = tiles()[0]; + end(el, 'tile-needs-pulse'); + end(el, 'tile-enter', new FakeEl()); + expect(el.classList.contains('tile--entering')).toBe(true); + end(el, 'tile-enter'); + expect(el.classList.contains('tile--entering')).toBe(false); + expect(tiles()[1].classList.contains('tile--entering')).toBe(true); + }); + + it('a terminal stays transparent until the load queue reports its first capture done', () => { + // A queue that hands the grid's own state callback to the test. + const Queue = windowStub.TileLoadQueue; + let report: (tile: unknown, state: string) => void = () => {}; + windowStub.TileLoadQueue = class { + constructor(opts: { onChange: (tile: unknown, state: string) => void }) { + report = opts.onChange; + } + schedule(_t: unknown, _k: string, run: () => Promise) { + return run(); + } + drop() {} + }; + try { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + app._tileLoadQueue(); + expect(tiles().every((t) => t.classList.contains('tile--revealing'))).toBe(true); + const tileA = app._tileFor('s-a'); + report(tileA, 'queued'); + report(tileA, 'running'); + expect(tiles()[0].classList.contains('tile--revealing')).toBe(true); + report(tileA, 'idle'); + expect(tiles()[0].classList.contains('tile--revealing')).toBe(false); + expect(tiles()[1].classList.contains('tile--revealing')).toBe(true); + // A later load (a reconnect refresh) never hides it again. + report(tileA, 'running'); + expect(tiles()[0].classList.contains('tile--revealing')).toBe(false); + } finally { + windowStub.TileLoadQueue = Queue; + } + }); + + it('a backstop timer shows a terminal whose load never reports back', () => { + vi.useFakeTimers(); + const app = gridApp(); + app.openTileGrid(['s-a']); + vi.advanceTimersByTime(14_999); + expect(tiles()[0].classList.contains('tile--revealing')).toBe(true); + vi.advanceTimersByTime(1); + expect(tiles()[0].classList.contains('tile--revealing')).toBe(false); + }); + + it('adds no fit and no PTY resize: tiles fit and connect exactly as under reduced motion', () => { + vi.useFakeTimers(); + const counts = (motion: boolean) => { + resetGridHarness(); + reducedMotion(!motion); + const app = gridApp(); + app.openTileGrid(IDS); + vi.advanceTimersByTime(1000); + return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]); + }; + const withMotion = counts(true); + const without = counts(false); + expect(withMotion).toEqual(without); + // One connect per tile (its one resize goes out when its socket opens). + expect(withMotion.every(([connects]) => connects === 1)).toBe(true); + }); + + it('a tile added later enters too; one dropped on a tile enters in its place', () => { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + for (const t of tiles()) end(t, 'tile-enter'); + app.addTile('s-c'); + const added = tiles().find((t) => t.dataset.sessionId === 's-c')!; + expect(added.classList.contains('tile--entering')).toBe(true); + expect(tiles().filter((t) => t.classList.contains('tile--entering'))).toHaveLength(1); + }); +}); + +describe('closing with the Tiles toggle', () => { + it('leaves a still copy of the tiles over the stage: clones, inert, no terminals', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 3)); + const before = FakeTile.all.length; + app.toggleTileGrid(); + expect(app._tilesOwnTerminal()).toBe(false); + expect(ghosts()).toHaveLength(1); + const layer = ghosts()[0]; + expect(layer.inert).toBe(true); + expect(layer.attrs['aria-hidden']).toBe('true'); + expect(layer.children.map((g) => g.dataset.sessionId)).toEqual(['s-a', 's-b', 's-c']); + expect(layer.children.every((g) => g.classList.contains('tile--leaving'))).toBe(true); + expect(layer.children.some((g) => g.classList.contains('tile--entering'))).toBe(false); + // Copies only: no TerminalTile was made for them, the real ones are gone. + expect(FakeTile.all).toHaveLength(before); + expect(FakeTile.all.every((t) => t._destroyed)).toBe(true); + expect(tiles()).toHaveLength(0); + }); + + it('holds until the single view has its content, then fades and goes', async () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 2)); + let settle: () => void = () => {}; + app.selectSession = vi.fn(() => new Promise((r) => (settle = r))); + app.toggleTileGrid(); + const layer = ghosts()[0]; + expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false); + settle(); + // The selection's promise comes from another realm (the test's), so its + // settling reaches the grid after a few microtasks: a macrotask covers them. + await new Promise((r) => setTimeout(r, 0)); + expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true); + end(layer, 'tile-leave', layer.children[0]); + expect(ghosts()).toHaveLength(1); + end(layer, 'tile-leave', layer.lastElementChild!); + expect(ghosts()).toHaveLength(0); + }); + + it('a single view that never settles still gets it gone: 700 ms, then the fade fallback', () => { + vi.useFakeTimers(); + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 2)); + app.selectSession = vi.fn(() => new Promise(() => {})); + app.toggleTileGrid(); + const layer = ghosts()[0]; + vi.advanceTimersByTime(699); + expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false); + vi.advanceTimersByTime(1); + expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true); + vi.advanceTimersByTime(450); + expect(ghosts()).toHaveLength(0); + }); + + it('a zoomed grid leaves a copy of the zoomed tile only', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 3)); + app.zoomTile('s-b'); + app.toggleTileGrid(); + expect(ghosts()[0].children.map((g) => g.dataset.sessionId)).toEqual(['s-b']); + }); + + it('every other close cuts as before: no copy', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 2)); + app.closeTileGrid({ reselect: false }); + expect(ghosts()).toHaveLength(0); + app.openTileGrid(IDS.slice(0, 2)); + // A user pick of a tab that is not tiled leaves the grid (decision 1). + app.closeTileGrid({ keepStored: true }); + expect(ghosts()).toHaveLength(0); + }); + + it('reopening drops a copy still showing', () => { + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 2)); + app.selectSession = vi.fn(() => new Promise(() => {})); + app.toggleTileGrid(); + expect(ghosts()).toHaveLength(1); + app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {})); + app.toggleTileGrid(); + expect(ghosts()).toHaveLength(0); + expect(app._tilesOwnTerminal()).toBe(true); + }); +}); + +describe('re-forming to another count', () => { + it('the old grid fades at once, the new tiles enter staggered, the ones that stay do not', () => { + const app = gridApp(); + app.openTileGrid(['s-a', 's-b']); + for (const t of tiles()) end(t, 'tile-enter'); + app.openTileCountMenu({ preventDefault: vi.fn() }); + const menu = body.children.find((c) => c.id === 'tileCountMenu')!; + menu.children.find((c) => c.dataset.count === '6')!.dispatch('click', { stopPropagation: vi.fn() }); + const layer = ghosts()[0]; + expect(layer.classList.contains('tile-grid-ghosts--now')).toBe(true); + expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true); + const entering = tiles().filter((t) => t.classList.contains('tile--entering')); + expect(entering.map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']); + expect(entering.map((t) => t.dataset.sessionId)).not.toContain('s-a'); + }); +}); + +describe('reduced motion', () => { + it('no entrance, no stagger property, no copy on close', () => { + reducedMotion(true); + const app = gridApp(); + app.openTileGrid(IDS.slice(0, 3)); + expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false); + expect(tiles().some((t) => '--tile-enter-index' in t.style)).toBe(false); + app.toggleTileGrid(); + expect(ghosts()).toHaveLength(0); + }); +}); + +describe('the CSS', () => { + const keyframes = (name: string) => { + const m = css.match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`)); + return m ? m[1] : ''; + }; + + it('every new keyframe animates opacity and transform only', () => { + for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) { + const body = keyframes(name); + expect(body, name).not.toBe(''); + const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]); + expect(props.length, name).toBeGreaterThan(0); + expect(props.filter((p) => p !== 'opacity' && p !== 'transform'), name).toEqual([]); + } + }); + + it('the terminal reveal is an opacity transition only', () => { + expect(css).toMatch(/\.tile-body \.xterm \{\s*transition: opacity 160ms ease-out;\s*\}/); + expect(css).toMatch(/\.tile\.tile--revealing \.tile-body \.xterm \{\s*opacity: 0;\s*\}/); + }); + + it('stays within about 150 to 300 ms: 180 ms per tile, 24 ms apart, the last of six done at 300', () => { + expect(css).toMatch(/\.tile\.tile--entering \{\s*animation: tile-enter 180ms [^;]+;\s*animation-delay: calc\(var\(--tile-enter-index, 0\) \* 24ms\);/); + }); + + it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => { + const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,')); + const reduced = block.slice(0, block.indexOf('\n}\n')); + for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) { + expect(reduced).toContain(sel); + } + expect(reduced).toMatch(/animation: none;/); + expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/); + expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/); + }); +}); From dbaf328c0a3646bd99f01a3533bd630113df5252 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 04:40:15 +0200 Subject: [PATCH 84/90] perf(tiles): opening paints the tiles first, one terminal per frame after Owner answer: "paced connect: in". Measured on the checkpoint build: of the 140 ms between the click on Tiles and its first frame, about 100 ms was the six xterms being built inside the click, so nothing moved on screen for that long and the entrance could not start. openTileGrid now mounts every tile and lays the grid out as before, then _connectTilesPaced builds one terminal per animation frame, the focused tile's first, then reading order. The click paints its empty tiles in about 20 ms and the entrance plays while the terminals are built. The time until every tile has painted does not change: the load queue serves one capture at a time, so only the focused tile's connect is on its path, one frame later. Each tile still connects once, into its final cell (one fit, one PTY resize). - openTileGrid returns before the terminals exist, so a selection that focuses a tile whose terminal is not built yet hands the keyboard over in _connectTile (focusOnConnect), never when focus: false was asked, and to the newly focused tile when focus moved meanwhile. - _connectTile connects a tile once (entry.connected; a remount after Attach resets it), so a re-form or a remount before a tile's turn is never connected twice. - A grid closed or opened again meanwhile stops the old run (a run token), and asks for no further frames. Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the keyboard, close and reopen, removed and remounted tiles); the tests that read connect or the terminal's focus right after openTileGrid now run the queued frames first (flushFrames in the vm harness), every assertion kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/tile-grid.js | 62 +++++++-- test/mocks/tile-grid-vm.ts | 7 + test/tile-grid-count-menu.test.ts | 2 + test/tile-grid-dividers.test.ts | 3 + test/tile-grid-motion.test.ts | 2 + test/tile-grid-paced-connect.test.ts | 191 +++++++++++++++++++++++++++ test/tile-grid-park-guards.test.ts | 3 + test/tile-grid-select-branch.test.ts | 3 + 8 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 test/tile-grid-paced-connect.test.ts diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 1a954439..40156702 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -289,15 +289,17 @@ Object.assign(CodemanApp.prototype, { const section = this._tileGridSection(); this.hideWelcome(); // Mount every tile and lay the grid out BEFORE any tile connects, so each - // first fit measures its real cell; the focused tile connects first, so - // its capture is the one the queue starts with. Each tile enters in + // first fit measures its real cell. Each tile enters in // reading order (opacity and transform only: the fit measures the final // cell, so the animation adds no resize). wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k })); // Packed from the first cell (_applyTileLayout pads the shape with empty cells). grid.cells = wanted.filter((id) => grid.tiles.has(id)); this._applyTileLayout(); - for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); + // The tiles' frames paint first; their terminals are built one per frame + // after it, the focused tile's first, so its capture is the one the queue + // starts with (_connectTilesPaced). + this._connectTilesPaced([focus, ...wanted.filter((id) => id !== focus)]); if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { // The main terminal's observer watches a node that is now hidden; this one // catches window resizes, sidebar toggles and rail drags for the grid. @@ -1397,6 +1399,7 @@ Object.assign(CodemanApp.prototype, { const hadKeyboard = this._focusedTile === entry.tile; this._destroyTerminalTile(entry.tile); entry.tile = this._newTerminalTile(sessionId, entry.body); + entry.connected = false; this._connectTile(sessionId); if (hadKeyboard) this._noteFocusedTile(entry.tile); }, @@ -1779,13 +1782,46 @@ Object.assign(CodemanApp.prototype, { input.select?.(); }, + /** + * Starts a tile's terminal and socket (TerminalTile.connect builds the xterm + * synchronously, then loads through the grid's queue). Once per tile. A + * focus that selected this tile before its terminal existed lands now. + */ _connectTile(sessionId) { - this._tileGrid?.tiles - .get(sessionId) - ?.tile.connect() - .catch(() => { - /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ - }); + const grid = this._tileGrid; + const entry = grid?.tiles.get(sessionId); + if (!entry || entry.connected) return; + entry.connected = true; + entry.tile.connect().catch(() => { + /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ + }); + if (grid.focusOnConnect === sessionId) { + grid.focusOnConnect = null; + entry.tile.terminal?.focus(); + } + }, + + /** + * Opening the grid: the tiles' terminals are built one per animation frame, + * in `order` (the focused tile first), instead of all of them inside the + * click. Building six xterms took about 100 ms of the click's 140 ms before + * the first frame; paced, the click paints its empty frames in about 20 ms + * and the entrance plays while the terminals are built. Nothing waits for + * it: the load queue serves one capture at a time anyway, so only the + * focused tile's connect is on its critical path, one frame later. Each + * tile still fits once, at its final size (laid out before any connect), + * and sends one PTY resize. A tile removed, replaced or already connected + * meanwhile is skipped; a grid closed (or opened again) meanwhile stops it. + */ + _connectTilesPaced(order) { + const grid = this._tileGrid; + const run = (grid.paceRun = (grid.paceRun || 0) + 1); + const step = (k) => { + if (!grid.open || grid.paceRun !== run || k >= order.length) return; + this._connectTile(order[k]); + requestAnimationFrame(() => step(k + 1)); + }; + requestAnimationFrame(() => step(0)); }, /** @@ -2143,8 +2179,14 @@ Object.assign(CodemanApp.prototype, { const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); // The keyboard follows focus: shortcuts, voice and paste act on this tile. + // A tile whose terminal is not built yet (the grid just opened, paced) + // takes it as soon as it is (_connectTile). this._noteFocusedTile(entry.tile); - if (options.focus !== false) entry.tile.terminal?.focus(); + grid.focusOnConnect = null; + if (options.focus !== false) { + if (entry.connected) entry.tile.terminal?.focus(); + else grid.focusOnConnect = sessionId; + } this._persistTileGrid(); }, diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 9e95b7df..c0332449 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -222,6 +222,13 @@ export const clockNow = () => clock; export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = []; /** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */ export const rafCallbacks: Array<() => void> = []; +/** + * Runs every queued animation frame, and the frames those queue in turn (the + * grid builds its tiles' terminals one per frame), until none is left. + */ +export function flushFrames(limit = 100) { + for (let n = 0; n < limit && rafCallbacks.length; n++) for (const cb of rafCallbacks.splice(0)) cb(); +} /** What the code under test deferred with requestIdleCallback; a test runs them. */ export const idleCallbacks: Array<() => void> = []; export const windowStub: Record = { diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts index 5c802b7b..05726b5b 100644 --- a/test/tile-grid-count-menu.test.ts +++ b/test/tile-grid-count-menu.test.ts @@ -35,6 +35,7 @@ import { activeElement, body, bySelector, + flushFrames, localStore, makeGridApp, resetGridHarness, @@ -370,6 +371,7 @@ describe('re-forming an open grid', () => { it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => { const app = gridApp(); app.openTileGrid(['s-c', 's-a']); + flushFrames(); const before = new Map(FakeTile.all.map((t) => [t.sessionId, t])); open(app); item(6).dispatch('click', { stopPropagation: vi.fn() }); diff --git a/test/tile-grid-dividers.test.ts b/test/tile-grid-dividers.test.ts index 1e3e8fbe..b1e547ba 100644 --- a/test/tile-grid-dividers.test.ts +++ b/test/tile-grid-dividers.test.ts @@ -17,6 +17,7 @@ import { FakeEl, FakeTile, body, + flushFrames, makeGridApp, rafCallbacks, resetGridHarness, @@ -32,6 +33,8 @@ const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as Fak function grid(ids: string[]): GridApp { const app = makeGridApp(ids); app.openTileGrid(ids); + // The tiles' terminals are built one per frame (_connectTilesPaced). + flushFrames(); for (const t of FakeTile.all) { t.fit.mockClear(); t.localFit.mockClear(); diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts index fd331ffe..d2f1985a 100644 --- a/test/tile-grid-motion.test.ts +++ b/test/tile-grid-motion.test.ts @@ -30,6 +30,7 @@ import { FakeTile, body, bySelector, + flushFrames, main, makeGridApp, resetGridHarness, @@ -134,6 +135,7 @@ describe('opening', () => { reducedMotion(!motion); const app = gridApp(); app.openTileGrid(IDS); + flushFrames(); vi.advanceTimersByTime(1000); return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]); }; diff --git a/test/tile-grid-paced-connect.test.ts b/test/tile-grid-paced-connect.test.ts new file mode 100644 index 00000000..1b4f4403 --- /dev/null +++ b/test/tile-grid-paced-connect.test.ts @@ -0,0 +1,191 @@ +/** + * @fileoverview Opening the grid paints the tiles first and builds their + * terminals one per frame (owner answer: "paced connect: in"). + * + * - `openTileGrid` connects no tile inside the click: every tile is mounted + * and the grid laid out first, then one tile connects per animation frame, + * the focused one first, then reading order. Each still connects once, into + * its final cell (one fit, one PTY resize). + * - The keyboard goes to the focused tile's terminal as soon as it exists + * (`focusOnConnect`): not before, never for a `focus: false` selection, and + * to the newly focused tile when focus moved while they were being built. + * - A grid closed (or opened again) meanwhile stops the old run; a tile + * removed before its turn is skipped; a tile already connected (one a + * re-form added) is never connected twice; a remount after Attach connects + * its new terminal. + * + * 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, + flushFrames, + makeGridApp, + rafCallbacks, + resetGridHarness, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d']; +const connected = () => FakeTile.all.filter((t) => t.connect.mock.calls.length > 0).map((t) => t.sessionId); +const tileOf = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!; +/** Runs the frames queued so far, once (one step of the paced connect). */ +const oneFrame = () => { + for (const cb of rafCallbacks.splice(0)) cb(); +}; + +beforeEach(() => { + resetGridHarness(); + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-tile-grid'; + bySelector.set('.btn-tile-grid', btn); +}); + +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {})); + return app; +} + +describe('opening paints first, then one terminal per frame', () => { + it('nothing connects inside the click; then the focused tile first, then reading order, one per frame', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-c' }); + expect(app._tilesOwnTerminal()).toBe(true); + expect(FakeTile.all).toHaveLength(4); + expect(connected()).toEqual([]); + oneFrame(); + expect(connected()).toEqual(['s-c']); + oneFrame(); + expect(connected()).toEqual(['s-a', 's-c']); + oneFrame(); + oneFrame(); + expect(connected().sort()).toEqual(IDS); + oneFrame(); + expect(rafCallbacks).toHaveLength(0); + expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + }); + + it('every tile connects into its final cell: the grid was laid out before the first', () => { + const app = gridApp(); + app.openTileGrid(IDS); + const seen: string[] = []; + for (const t of FakeTile.all) { + t.connect.mockImplementation(async () => { + seen.push(`${t.sessionId}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(t.sessionId)}`); + }); + } + flushFrames(); + expect(seen).toEqual(['s-a:4:0', 's-b:4:1', 's-c:4:2', 's-d:4:3']); + }); +}); + +describe('the keyboard', () => { + it('lands in the focused tile once its terminal exists, not before', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-b' }); + expect(tileOf('s-b').terminal.focus).not.toHaveBeenCalled(); + oneFrame(); + expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1); + flushFrames(); + expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1); + expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled(); + }); + + it('follows a focus moved while the terminals are being built', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + app._selectTiledSession('s-d', {}); + flushFrames(); + expect(tileOf('s-d').terminal.focus).toHaveBeenCalled(); + expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled(); + }); + + it('never for a selection that asked for no focus', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + app._selectTiledSession('s-a', { focus: false }); + flushFrames(); + expect(FakeTile.all.some((t) => t.terminal.focus.mock.calls.length > 0)).toBe(false); + }); + + it('a tile already built takes the keyboard at once', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + flushFrames(); + app._selectTiledSession('s-c', {}); + expect(tileOf('s-c').terminal.focus).toHaveBeenCalledTimes(1); + }); +}); + +describe('a grid that changes meanwhile', () => { + it('closed: nothing connects any more, and no frame is asked for after it', () => { + const app = gridApp(); + app.openTileGrid(IDS); + oneFrame(); + app.closeTileGrid({ reselect: false }); + oneFrame(); + expect(rafCallbacks).toHaveLength(0); + flushFrames(); + expect(connected()).toEqual(['s-a']); + }); + + it('opened again: the old run stops at once, the new one starts with its own focused tile', () => { + const app = gridApp(); + app.openTileGrid(IDS, { focusedId: 's-a' }); + oneFrame(); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = null; + app.openTileGrid(IDS, { focusedId: 's-d' }); + oneFrame(); + const live = () => FakeTile.all.filter((t) => !t._destroyed && t.connect.mock.calls.length).map((t) => t.sessionId); + expect(live()).toEqual(['s-d']); + }); + + it('closed and opened again: the old run stops, the new one connects each tile once', () => { + const app = gridApp(); + app.openTileGrid(IDS); + oneFrame(); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = null; + app.openTileGrid(IDS); + flushFrames(); + const live = FakeTile.all.filter((t) => !t._destroyed); + expect(live).toHaveLength(4); + expect(live.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + }); + + it('a tile removed before its turn is skipped; one a re-form connected is not connected again', () => { + const app = gridApp(['s-a', 's-b', 's-c', 's-d', 's-e']); + app.openTileGrid(['s-a', 's-b', 's-c']); + app.removeTile('s-b'); + app.addTile('s-e'); + expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1); + flushFrames(); + expect(FakeTile.all.find((t) => t.sessionId === 's-b')!.connect).not.toHaveBeenCalled(); + expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1); + expect(tileOf('s-a').connect).toHaveBeenCalledTimes(1); + expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1); + }); + + it('a tile remounted before its turn (Attach) is connected once, not again by the paced run', () => { + const app = gridApp(); + app.openTileGrid(IDS); + app._remountTile('s-c'); + flushFrames(); + expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1); + }); + + it('a remount after Attach connects its new terminal', () => { + const app = gridApp(); + app.openTileGrid(IDS); + flushFrames(); + const old = tileOf('s-b'); + app._remountTile('s-b'); + const fresh = tileOf('s-b'); + expect(fresh).not.toBe(old); + expect(fresh.connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/tile-grid-park-guards.test.ts b/test/tile-grid-park-guards.test.ts index f9565a7f..82484793 100644 --- a/test/tile-grid-park-guards.test.ts +++ b/test/tile-grid-park-guards.test.ts @@ -36,6 +36,7 @@ import { FakeTile, advanceClock, clockNow, + flushFrames, localStore, main, makeGridApp, @@ -118,6 +119,8 @@ describe('parking the main terminal', () => { expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1); expect(main.classList.contains('tiles-active')).toBe(true); expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS); + // Built one per frame after the click (_connectTilesPaced), each once. + flushFrames(); expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); expect(app.activeSessionId).toBe('s-b'); expect(app._tilesOwnTerminal()).toBe(true); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 14c9b115..671accaa 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -22,6 +22,7 @@ import { FakeEl, FakeTile, bySelector, + flushFrames, idleCallbacks, localStore, makeGridApp, @@ -36,6 +37,8 @@ const IDS = ['s-a', 's-b', 's-c']; function openGrid(): GridApp { const app = makeGridApp(IDS); app.openTileGrid(IDS); + // The tiles' terminals are built one per frame (_connectTilesPaced). + flushFrames(); delete app.selectSession; // the prototype's, for real app._cleanupPreviousSession.mockClear(); app.markIdleAlertSeen.mockClear(); From 4ad283c6471f26307e1df17d0b95fe9ad1b462d3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 04:53:02 +0200 Subject: [PATCH 85/90] docs(tiles): the count menu, the open/close animation and the paced open - docs/tile-grid-plan.md: owner decision 10 (right-click Tiles is a 2 / 4 / 6 count menu, default 6, remembered per device; the session picker is gone; decision 8's "picker on right-click" and "exactly the stored set" superseded) with the owner's answers on the details; three as-built bullets (the count menu, the animation, painting first); the Tiles-button bullet rewritten for the count; the entry points, the capacity note, the multi-user row and the Escape invariant follow. - docs/architecture-invariants.md: the Opening paragraph rewritten (the count, the stored grid in its cells, the menu owning its Escape, the paced connect and focusOnConnect, the motion rules); the z-index list names the count menu and the closing grid's still copy. - CLAUDE.md: the tile grid paragraph and the z-index line. - Wiki: Tile Grid (the click, the count menu, the animation, reduced motion) and Keyboard Shortcuts (right-click Tiles). Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 4 +- docs/tile-grid-plan.md | 112 +++++++++++++++++++++++++------- docs/wiki/Keyboard-Shortcuts.md | 2 +- docs/wiki/Tile-Grid.md | 28 +++++--- 5 files changed, 111 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56e3dbdf..3181b13d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) @@ -387,7 +387,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog) -**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles picker (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) +**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a477ed80..725100e2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. -⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). +⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. @@ -1005,7 +1005,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Z-index layers -**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles picker (1000, `.tile-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). +**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). ## Security layers diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 0727a237..b92e5081 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -36,14 +36,60 @@ or settled a question the spec left open. The invariants as built are in one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`, `Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes compare with the last English value, not the translated DOM. -- **The Tiles button opens the grid at once** (owner decision 8): a click (and - `Ctrl+Shift+G`, the same `toggleTileGrid`) opens `tileGridOpenSet` (constants.js): the - grid this tab last had, else an open split's two sessions, else the open sessions in tab - order up to what the grid takes here (the cap, or fewer when the window fits fewer), the - active one always included and focused. A remembered grid wins over an open split: the - split closes and its sessions do not join (the owner's order, read literally). Right-click - opens the picker; with the grid open it shows the current tiles and Open replaces them. - Ctrl/Cmd+click on a tab with the grid closed opens the same set plus that session. +- **The Tiles button opens the grid at once** (owner decision 8, with the count of + decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered + count of tiles (default 6, at most what the window fits). `tileGridOpenSet` + (constants.js) picks the grid this tab last had, else an open split's two sessions, else + the open sessions in tab order, the active one always included and focused, and + `tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it + (from tab order) to the count. A remembered grid comes back with its tiles first, in + their cells, then sessions in tab order, to the count in total: the count is a shape + change under the cell model's rule (`reformTileCells`: the tiles keep their row and + column when all fit, else they pack in reading order) and the added tiles fill the empty + cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A + remembered grid still wins over an open split: the split closes and its sessions are not + seeded first. A page-load restore brings back exactly the stored grid, whatever the + count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that + session among them and focused (owner answer: N, not N+1). +- **Right-click on Tiles is a 2 / 4 / 6 count menu** (owner decision 10; the session + picker is gone). Three counts with their shapes (the grid's own 2x1, 2x2, 3x2 drawn as + cells), the remembered one checked. A count the window cannot fit is greyed out with the + reason ("This window fits N tiles"); a remembered count that does not fit stays checked + but greyed, the keyboard starts on the largest that fits, and a click opens what fits. + Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move + over the counts that fit, Enter or Space picks, Escape closes it alone (the global + Escape handler gives it the key first, like the tab-group menu) and puts the keyboard + back on the Tiles button, Tab and a click elsewhere close it. A pick is remembered per + device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many + tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always + stays, the others leave from the end or join from tab order, filling empty cells first, + every joining tile mounted and laid out before any connects (one fit, one PTY resize + each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab, + "Open group as tiles", Run) still add up to the cap of 6. +- **The grid opens and closes with a short animation, on by default** (owner request: + "when clicking on the tile button first make this animation nicer"). It is the grid's + own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile + fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in + reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added + later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the + load queue reports its first capture done, then fades in whole (160 ms), so no replay + scrolls by. Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these), a + still copy of the tiles (`_ghostTileGrid`: clones, no xterm, socket or listener; inert, + `aria-hidden`, no pointer) dims at once over the stage (so the click is answered) and + stays until the single view's `selectSession` has replayed its session, at most 700 ms, + then fades: no empty single view between the two. A re-form fades the old grid's copy at + once. The count menu fades in (140 ms). Every one animates opacity and transform only, so + FitAddon measures the final cell and each tile still sends one PTY resize; under + `prefers-reduced-motion` nothing moves and no copy is made. A web tab hides the copy. +- **Opening paints the frames first** (owner answer: "paced connect: in"). The six + terminals used to be built inside the click (about 100 of its 140 ms before the first + frame). `_connectTilesPaced` builds one per animation frame, the focused tile's first, + so the click paints its empty tiles in about 20 ms and the entrance plays while they are + built. The time until all tiles have painted is unchanged: the load queue serves one + capture at a time, so only the focused tile's connect is on its path, one frame later. + `openTileGrid` therefore returns before the terminals exist: a selection that focuses a + tile whose terminal is not built yet hands the keyboard over in `_connectTile` + (`focusOnConnect`), never when `focus: false` was asked. - **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable, so going back to nine is that one line. A stored grid with more ids comes back as its first @@ -59,9 +105,9 @@ or settled a question the spec left open. The invariants as built are in B's close is a tile button (26px). - **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" are gone; tiles are added from the Tiles - button (and its right-click picker), Ctrl/Cmd+click on a tab, a dragged tab, "Open group - as tiles" and Run joining the open grid. Where this spec describes a `+`, it no longer - exists. + button (and its right-click count menu), Ctrl/Cmd+click on a tab, a dragged tab, "Open + group as tiles" and Run joining the open grid. Where this spec describes a `+`, it no + longer exists. - **No SSE terminal stream while tiles own the terminal** (performance pass): the filter names a fixed id no session takes (`TILE_GRID_SSE_FILTER`), not `[activeSessionId]` as "Parking the main terminal" below says; the server's filter gates only terminal @@ -97,9 +143,9 @@ or settled a question the spec left open. The invariants as built are in only: its `ids` are the cells, `null` for an empty one (a build before cells drops the nulls and reads them packed); a reload brings the holes back when the shape is the same, a session gone by then leaves its cell empty, another shape packs, and the old packed format - reads unchanged. A fresh grid (the picker's Open, Ctrl/Cmd+click with the grid closed, - "Open group as tiles") opens packed; only the toggle and the page-load restore bring holes - back. Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so + reads unchanged. A fresh grid (Ctrl/Cmd+click with the grid closed, "Open group as + tiles") opens packed; only the toggle and the page-load restore bring holes back (the + toggle through `reformTileCells` when the count changes the set). Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow chord families, focus and move, skip a text field, where shifted arrows select (owner's answer: best practice). Default @@ -218,9 +264,9 @@ work also fixes gaps the split pane has today. ### Entry points - **Header Tiles button** (its own button, beside Split). As built (decision 8) - a click opens the grid at once, the same as the toggle shortcut; the picker - with checkboxes over open sessions, ordered like the tab strip, is on - right-click. When the grid is open, a click closes it. + a click opens the grid at once, the same as the toggle shortcut; right-click + is the 2 / 4 / 6 count menu (decision 10; the session picker it replaced is + gone). When the grid is open, a click closes it. - **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if closed). - **Drag a tab** from the strip onto a tile to replace it, or onto an empty @@ -246,8 +292,8 @@ Automatic by tile count, computed by a pure helper: | 7-9 | 3x3 | Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px, -roughly 60 columns at the default tile font), so the picker disables additions -the window cannot fit. +roughly 60 columns at the default tile font), so the count menu greys out the +counts the window cannot fit. Column and row dividers are draggable (generalizing the split divider): the grid stores track fractions (`grid-template-columns: fr fr …`), each @@ -678,7 +724,7 @@ and share one tile class: | A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) | | Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) | | Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other | -| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side | +| Multi-user mode | The grid only ever opens visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side | | Solo window | Tiles unavailable | ## Server @@ -729,8 +775,9 @@ Separate follow-up PRs worth doing (see "Follow-ups"). - **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the `.strict()` schema; the `--hidden` marker class has a `display: none` rule. - **Palette chords** are swallowed in every xterm key handler. -- **Escape**: the picker's close method returns early when the picker is not - open (the global Escape handler calls every close method). +- **Escape**: the count menu's close method returns early when the menu is not + open, and an open menu owns the Escape (it closes alone and the keyboard goes + back to the Tiles button, like the tab-group menu). - **User text** (names) via `textContent` / attributes, never `innerHTML`. - **No secrets in localStorage**: the stored grid holds ids only. - **Memory**: everything a tile creates is released in `destroy()`. @@ -963,12 +1010,27 @@ exits green. Use the browser runner for those files and read the file count. split's two sessions, else the open sessions in tab order up to the cap with the active one focused; `Ctrl+Shift+G` runs the same function. The picker is on right-click of the button (its title says so, as do the wiki and the - Help modal). + Help modal). Superseded in part by decision 10: right-click is now the count + menu, and a remembered grid is filled to the count instead of opening + exactly as stored. 9. **No + in the tile header.** Decided by the owner ("remove the + button from these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" went with it. Tiles are added from the Tiles button - and its right-click picker, Ctrl/Cmd+click on a tab, a dragged tab, "Open - group as tiles" and Run joining the open grid. + and its right-click count menu, Ctrl/Cmd+click on a tab, a dragged tab, + "Open group as tiles" and Run joining the open grid. +10. **Right-click Tiles is a 2 / 4 / 6 count menu.** Decided by the owner + ("give me then the option to choose only HOW many tiles, 2,4,6 default is 6 + so the menu is easier"; asked where it lives: "Click opens 6"): a click + still opens the grid at once, with the remembered count (default 6); the + right-click menu offers 2, 4 and 6, remembered per device; the session + picker is gone, and decision 8's "picker on right-click" is superseded. The + owner's answers on the details: the count wins over a remembered grid's + size (its tiles first, in their cells, holes filled first, then tab order); + Ctrl/Cmd+click with the grid closed opens the count in total, that session + focused; shrinking keeps the focused tile; only the toggle animates the + close; a remembered count larger than the window stays checked but greyed + and a click opens what fits; the close keeps its dimmed still until the + single view has painted (at most 700 ms); paced connect is in. ## Code anchors diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 9928e84e..3996685c 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -49,7 +49,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | -| Right-click the Tiles button | Choose which sessions to show as tiles. | +| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). | While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 31d4b9ba..640b4dc5 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -15,16 +15,21 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. ## Opening a grid -- **Tiles button**: one click shows the tiles straight away. You get the grid you last - had; if there is none, an open split as two tiles; otherwise your open sessions in tab - order, up to six (fewer if the window is too small), with the session you are on - focused. With the grid open, the same button closes it. -- **Right-click the Tiles button** to choose which sessions: a checkbox per open session in - tab order, starting with the tiles you have (or had), greying out the rest once the grid - is full. **Open tiles** shows them, replacing what the grid showed. +- **Tiles button**: one click shows the tiles straight away, as many as you last chose + (six until you choose; fewer if the window is too small or you have fewer sessions open). + You get the grid you last had, its tiles where they were, topped up with your open + sessions in tab order; if there is none, an open split's two first; otherwise your open + sessions in tab order, with the session you are on focused. With the grid open, the same + button closes it. +- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles: + **2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device + and is what the next click opens. With the grid open, picking a count re-forms it: the + tile you are in always stays, extra tiles leave from the end, new ones join from your tab + order. A count the window is too small for is greyed out, with the reason. - **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does. - **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With - the grid closed it opens what the Tiles button would show, plus that session. On macOS use + the grid closed it opens what the Tiles button would show, with that session among them + (still the count you chose in total). On macOS use `Cmd`: `Ctrl`+click there opens the tab's rename instead. - **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps running), or onto an empty slot to add it. Dragging a session that is already tiled onto @@ -35,7 +40,12 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a 2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the -window is too small for six; the picker says which limit applies. +window is too small for six; the count menu says which limit applies. + +Opening, the tiles fade in one after another and each terminal appears once its history +has loaded, rather than scrolling through it. Closing with the button, the tiles stay +on screen, dimmed, until the single session behind them has loaded, then fade away. With +reduced motion turned on in your system settings, the grid opens and closes at once. ## A tile From fcec77131c8d744214a6994062fa6661b9bcd85f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 05:13:10 +0200 Subject: [PATCH 86/90] fix(tiles): the count menu closes when the keyboard leaves it Found live: closing the grid with a click starts the single view's selection, which focuses its terminal when its replay lands, a few hundred milliseconds later. A right-click on Tiles in between opened the count menu with the keyboard in it, and the late focus then moved the keyboard into the terminal while the menu stayed open (3 of 3 tries), so the arrows, Enter or Escape meant for the menu went to the session's PTY instead (an Escape arrived there as an ESC byte). The menu now closes when the keyboard leaves it for another element, as any menu does. A focus going nowhere (a click on a button in Safari, which does not focus it) does not count, so a click on a count still picks it. Live afterwards: the menu either closes as the terminal takes the keyboard, or keeps it when the replay landed first; never open with the keyboard elsewhere. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- docs/tile-grid-plan.md | 4 +++- src/web/public/tile-grid.js | 10 ++++++++++ test/tile-grid-count-menu.test.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 725100e2..860e83c9 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), 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 (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. -⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. +⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index b92e5081..b319edc9 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -59,7 +59,9 @@ or settled a question the spec left open. The invariants as built are in Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move over the counts that fit, Enter or Space picks, Escape closes it alone (the global Escape handler gives it the key first, like the tab-group menu) and puts the keyboard - back on the Tiles button, Tab and a click elsewhere close it. A pick is remembered per + back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another + element close it (the single view a close starts focuses its terminal when its replay + lands; a menu left open behind that would send its keys there). A pick is remembered per device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always stays, the others leave from the end or join from tab order, filling empty cells first, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 40156702..0a6bc86e 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -677,6 +677,16 @@ Object.assign(CodemanApp.prototype, { this.closeTileCountMenu(); } }; + // The keyboard leaving the menu for something else closes it, as any menu: + // closing the grid starts a selection that focuses the single view's + // terminal when its replay lands, and a menu left open behind that would + // send the keys meant for it (arrows, Enter, Escape) into the terminal. A + // focus going nowhere (a click on a button in Safari, which does not focus + // it) does not count. + menu.addEventListener('focusout', (e) => { + const to = e.relatedTarget; + if (to && !menu.contains?.(to)) this.closeTileCountMenu({ refocus: false }); + }); this._tileCountMenu = { menu, onOutside, onKey }; document.addEventListener('click', onOutside); document.addEventListener('keydown', onKey, true); diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts index 05726b5b..04acd0d2 100644 --- a/test/tile-grid-count-menu.test.ts +++ b/test/tile-grid-count-menu.test.ts @@ -259,6 +259,24 @@ describe('the count menu', () => { expect(menu()).toBeNull(); }); + it('closes when the keyboard leaves it for something else (a late focus of the single view), not for nothing', () => { + const app = gridApp(); + open(app); + // Inside: moving between counts keeps it. + menu()!.dispatch('focusout', { relatedTarget: item(2) }); + expect(menu()).not.toBeNull(); + // Nowhere (a click on a button in Safari focuses nothing): kept. + menu()!.dispatch('focusout', { relatedTarget: null }); + expect(menu()).not.toBeNull(); + // Another element, the single view's terminal: closed, the keyboard left there. + const textarea = new FakeEl(); + textarea.className = 'xterm-helper-textarea'; + textarea.focus(); + menu()!.dispatch('focusout', { relatedTarget: textarea }); + expect(menu()).toBeNull(); + expect(activeElement()).toBe(textarea); + }); + it('a second right-click closes it; a click on Tiles closes it and toggles', () => { const app = gridApp(); open(app); From 4bb333e9bc3f5f60480312ab2bb7f3273e03fcfb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 06:15:19 +0200 Subject: [PATCH 87/90] style(header): hover moves the icon, never the button A global `.btn-icon-header:hover { transform: rotate(45deg) }`, meant for the settings gear, turned every header icon button on hover, so the folder, Tiles, Split and the rest swung their rounded hover background into a diamond. Three buttons had already cancelled it one by one (the font-size buttons, notifications, the sidebar toggle). The rule is gone, and with it those three overrides. Hover motion now moves the icon only: - the settings gear's icon turns 45 degrees (one tooth, so it lands on the same shape); - the Tiles button's four squares spread apart, each toward its corner; - the folder cross-fades to an open folder (a second drawing in its SVG, `.icon-folder-closed` / `.icon-folder-open`); - every other icon just takes the hover colour. Pointer devices only (`@media (hover: hover)`, so a tap cannot leave an icon stuck mid-motion), and the transitions are off under reduced motion. Owner request: the Tiles and folder buttons "weirdly turn" on hover. Checked live on a dark and a light skin (rest, mid, end frames). Pinned by test/header-icon-hover.test.ts, mutation-checked five ways (the button rotation back, the open drawing missing, the motion not hover-gated, a square spreading toward the wrong corner, reduced motion keeping its transition). Gate: 507 files, 9798 tests passed. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/tile-grid-plan.md | 9 +++ src/web/public/index.html | 2 +- src/web/public/styles.css | 81 +++++++++++++++++---- test/header-icon-hover.test.ts | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 test/header-icon-hover.test.ts diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index b319edc9..9e4a2b2d 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -68,6 +68,15 @@ or settled a question the spec left open. The invariants as built are in every joining tile mounted and laid out before any connects (one fit, one PTY resize each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab, "Open group as tiles", Run) still add up to the cap of 6. +- **Header icons move their icon on hover, never the button** (owner request: the Tiles + and folder buttons "weirdly turn" on hover; make a nicer hover). A global + `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the settings gear) + turned every header icon button, swinging its hover background into a diamond. Now + only the gear's ICON turns (45 degrees, one tooth), the Tiles button's four squares + spread apart, and the folder cross-fades to an open folder (`.icon-folder-closed` / + `.icon-folder-open` in its SVG); every other icon just takes the hover colour. Pointer + devices only (`@media (hover: hover)`), transitions off under reduced motion. Pinned by + `test/header-icon-hover.test.ts`. - **The grid opens and closes with a short animation, on by default** (owner request: "when clicking on the tile button first make this animation nicer"). It is the grid's own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile diff --git a/src/web/public/index.html b/src/web/public/index.html index d868c437..910f9316 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -189,7 +189,7 @@ - + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 1afe5454..ae470011 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2450,7 +2450,73 @@ html[data-tab-orientation='vertical'].tab-rail-compact .btn-icon-header:hover { background: rgba(255, 255, 255, 0.07); color: var(--text); - transform: rotate(45deg); +} + +/* Hover motion moves the icon, never the button: turning the whole button used + to swing its hover background into a diamond on every header icon. Now only the + gear turns, the folder opens and the Tiles squares spread apart; every other + icon just takes the hover colour. Pointer devices only (a tap must not leave an + icon stuck mid-motion), and no motion at all under reduced motion. */ +.btn-icon-header.btn-settings svg, +.btn-icon-header.btn-tile-grid svg rect, +.btn-icon-header.btn-file-viewer svg .icon-folder-closed, +.btn-icon-header.btn-file-viewer svg .icon-folder-open { + transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 160ms ease; +} + +.btn-icon-header.btn-tile-grid svg rect { + transform-box: fill-box; + transform-origin: center; +} + +.btn-icon-header.btn-file-viewer svg .icon-folder-open { + opacity: 0; + transform: translateY(1px); +} + +@media (hover: hover) { + .btn-icon-header.btn-settings:hover svg { + transform: rotate(45deg); + } + + .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(1) { + transform: translate(-1.5px, -1.5px); + } + + .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(2) { + transform: translate(1.5px, -1.5px); + } + + .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(3) { + transform: translate(-1.5px, 1.5px); + } + + .btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(4) { + transform: translate(1.5px, 1.5px); + } + + .btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed { + opacity: 0; + } + + .btn-icon-header.btn-file-viewer:hover svg .icon-folder-open { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .btn-icon-header.btn-settings svg, + .btn-icon-header.btn-tile-grid svg rect, + .btn-icon-header.btn-file-viewer svg .icon-folder-closed, + .btn-icon-header.btn-file-viewer svg .icon-folder-open { + transition: none; + } + + .btn-icon-header.btn-settings:hover svg, + .btn-icon-header.btn-tile-grid:hover svg rect { + transform: none; + } } /* Admin Panel header button: only shown to admins in multi-user mode (marker @@ -2569,10 +2635,6 @@ html[data-tab-orientation='vertical'].tab-rail-compact font-weight: 600; } -.btn-icon-header.btn-sm:hover { - transform: none; -} - .header-font-controls { display: flex; align-items: center; @@ -9195,10 +9257,6 @@ kbd { margin-right: 2px; } -.btn-icon-header.btn-notifications:hover { - transform: none; -} - .notification-badge { position: absolute; top: 2px; @@ -19172,11 +19230,6 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .ta display: none !important; } -/* .btn-icon-header:hover rotates 45deg globally — a panel glyph must not spin. */ -.btn-sidebar-toggle:hover { - transform: none; -} - html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle svg { transform: scaleX(-1); } diff --git a/test/header-icon-hover.test.ts b/test/header-icon-hover.test.ts new file mode 100644 index 00000000..74088b80 --- /dev/null +++ b/test/header-icon-hover.test.ts @@ -0,0 +1,125 @@ +/** + * @fileoverview Header icon buttons: hover motion moves the icon, never the button. + * + * A global `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the + * settings gear) turned EVERY header icon button on hover, so the folder, Tiles, + * Split and the rest swung their rounded hover background into a diamond. Three + * buttons had already been patched one by one with `transform: none`. The owner + * asked for a nicer hover on the folder and Tiles buttons; this file pins the + * shape of the fix: + * + * 1. No rule on a header icon button transforms the BUTTON on hover. + * 2. Only the gear's icon turns; the Tiles squares spread apart; the folder + * cross-fades from closed to open (two drawings in its SVG). + * 3. Those motions live inside `@media (hover: hover)`, so a tap on a touch + * screen cannot leave an icon stuck mid-motion, and reduced motion turns the + * transitions off. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import postcss, { type AtRule, type Rule } from 'postcss'; + +const ROOT = resolve(__dirname, '..'); +const css = readFileSync(resolve(ROOT, 'src/web/public/styles.css'), 'utf8'); +const html = readFileSync(resolve(ROOT, 'src/web/public/index.html'), 'utf8'); +const root = postcss.parse(css); + +interface FoundRule { + selector: string; + decls: Record; + media: string[]; +} + +function rules(): FoundRule[] { + const out: FoundRule[] = []; + root.walkRules((rule: Rule) => { + const media: string[] = []; + for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) { + if (p.type === 'atrule' && (p as AtRule).name === 'media') media.push((p as AtRule).params); + } + const decls: Record = {}; + rule.walkDecls((d) => { + decls[d.prop] = d.value; + }); + for (const selector of rule.selectors) out.push({ selector, decls, media }); + }); + return out; +} + +const all = rules(); +const iconRules = all.filter((r) => r.selector.includes('btn-icon-header')); + +function buttonTag(cls: string): string { + const i = html.indexOf(`class="btn-icon-header ${cls}`); + expect(i, `${cls} button in index.html`).toBeGreaterThan(-1); + return html.slice(i, html.indexOf('', i)); +} + +describe('header icon hover', () => { + it('never transforms the button itself on hover', () => { + const offenders = iconRules.filter( + (r) => /:hover\s*$/.test(r.selector) && r.decls.transform && r.decls.transform !== 'none' + ); + expect(offenders.map((r) => `${r.selector} { transform: ${r.decls.transform} }`)).toEqual([]); + }); + + it('turns only the gear icon, and only on pointer devices', () => { + const rotations = iconRules.filter((r) => /rotate\(/.test(r.decls.transform || '')); + expect(rotations.map((r) => r.selector)).toEqual(['.btn-icon-header.btn-settings:hover svg']); + expect(rotations[0].media).toContain('(hover: hover)'); + }); + + it('spreads the four Tiles squares apart, each toward its own corner', () => { + const svg = buttonTag('btn-tile-grid'); + expect(svg.match(/ { + const rule = iconRules.find( + (r) => r.selector === `.btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(${i + 1})` + ); + expect(rule?.decls.transform, `rect ${i + 1}`).toBe(`translate${offset}`); + expect(rule?.media).toContain('(hover: hover)'); + }); + const box = iconRules.find( + (r) => r.selector === '.btn-icon-header.btn-tile-grid svg rect' && r.decls['transform-box'] + ); + expect(box?.decls['transform-origin']).toBe('center'); + }); + + it('opens the folder: the open drawing is hidden at rest and replaces the closed one on hover', () => { + const svg = buttonTag('btn-file-viewer'); + expect(svg).toContain('class="icon-folder-closed"'); + expect(svg).toContain('class="icon-folder-open"'); + const rest = iconRules.find( + (r) => r.selector === '.btn-icon-header.btn-file-viewer svg .icon-folder-open' && r.decls.opacity + ); + expect(rest?.decls.opacity).toBe('0'); + expect(rest?.media).toEqual([]); + const hoverClosed = iconRules.find( + (r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed' + ); + const hoverOpen = iconRules.find( + (r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-open' + ); + expect(hoverClosed?.decls.opacity).toBe('0'); + expect(hoverOpen?.decls.opacity).toBe('1'); + expect(hoverClosed?.media).toContain('(hover: hover)'); + expect(hoverOpen?.media).toContain('(hover: hover)'); + }); + + it('turns the motion off under reduced motion', () => { + const reduced = iconRules.filter((r) => r.media.includes('(prefers-reduced-motion: reduce)')); + for (const sel of [ + '.btn-icon-header.btn-settings svg', + '.btn-icon-header.btn-tile-grid svg rect', + '.btn-icon-header.btn-file-viewer svg .icon-folder-closed', + '.btn-icon-header.btn-file-viewer svg .icon-folder-open', + ]) { + expect(reduced.find((r) => r.selector === sel)?.decls.transition, sel).toBe('none'); + } + for (const sel of ['.btn-icon-header.btn-settings:hover svg', '.btn-icon-header.btn-tile-grid:hover svg rect']) { + expect(reduced.find((r) => r.selector === sel)?.decls.transform, sel).toBe('none'); + } + }); +}); From d2a1d14eacd1c6e7ffa0c5c4f0b04d0216122b14 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 8 Oct 2026 17:33:26 +0200 Subject: [PATCH 88/90] feat(tiles): a hover card on the Tiles button says what a right-click does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback 1: "give me the hover info to right click over the tile button to adjust it". The only hint was the native title, which the browser shows late, small and unstyled. The Tiles button now has its own hover card, under it in the count menu's panel style: - "Tiles · N" with the remembered count (live: a pick in the menu changes it while the card is up), "Click: open the grid" ("close the grid" while it is open), "Right-click: choose 2, 4 or 6 tiles", and when the count does not fit the window what opens instead ("This window fits 4 tiles: a click opens 4"; open, what fits). Shown from the keyboard it adds "Shift+F10: the same menu from the keyboard". - Shows 300 ms after a pointer that hovers rests on the button, or after a keyboard focus (:focus-visible); never for a touch pointer or a device that cannot hover (plus a CSS @media (hover: none) backstop), never on a hidden button, never while the count menu is open. A short fade on opacity and transform, none under reduced motion; it takes no pointer. - Hides on pointer leave, blur, Escape, a scroll and a resize, and on a press, a click or a right-click on the button (capture phase, so it is gone before the menu or the grid opens, and it stays gone while the pointer rests there). openTileCountMenu hides it too, and the focus the menu's Escape puts back on the button brings no card back. - It replaces the button's native title (two tooltips never stack); the aria-label stays and aria-describedby points at the card, which always exists and is kept current, its keyboard line included while hidden, so screen readers hear the same text without a hover. Text is diffed against the last English, as the rest of the grid chrome, so the zh-CN translator is not fought on every refresh. - zh-CN for every line (平铺 · N, 单击, 右键单击, Shift+F10, the fits note); no other header button changes (its styles are .tile-hint only). Tests: tile-grid-hint.test.ts (install and aria wiring, the delay, each show and hide path, content per state and count, the menu rule, the translator, the CSS); the count menu and i18n tests read the accessible name instead of the removed title, and the i18n harvest covers the card (F10 joins the key names that stay Latin). The vm harness gains removeAttribute. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/i18n.js | 10 + src/web/public/index.html | 2 +- src/web/public/styles.css | 83 +++++++ src/web/public/tile-grid.js | 203 ++++++++++++++++- test/mocks/tile-grid-vm.ts | 4 + test/tile-grid-count-menu.test.ts | 9 +- test/tile-grid-hint.test.ts | 357 ++++++++++++++++++++++++++++++ test/tile-grid-i18n.test.ts | 35 ++- 8 files changed, 687 insertions(+), 16 deletions(-) create mode 100644 test/tile-grid-hint.test.ts diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index ff3ad3f0..e0be0d4d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -107,6 +107,11 @@ '平铺:并排显示多个会话(右键单击可选择窗格数量)', 'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)', 'How many tiles': '窗格数量', + // The Tiles button's hover card (the count and the fits note are patterns). + 'Click: open the grid': '单击:打开平铺网格', + 'Click: close the grid': '单击:关闭平铺网格', + 'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格', + 'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单', 'Split: unavailable while tiles are open': '分屏:平铺打开时不可用', 'Toggle Tile Grid': '切换平铺网格', 'Focus Tile Left': '聚焦左侧窗格', @@ -1057,6 +1062,11 @@ ], // Tile grid: counts, exit codes and durations pass through. [/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`], + [/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`], + [ + /^This window fits (\d+) tiles?: a click opens (\d+)$/, + (_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`, + ], [/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`], [/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`], [ diff --git a/src/web/public/index.html b/src/web/public/index.html index 910f9316..e49740bb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -192,7 +192,7 @@ - +
—