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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 00:13:32 +02:00
parent 20c2561a45
commit adeb22d7b2
5 changed files with 43 additions and 5 deletions
+1 -1
View File
@@ -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) **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 <cliId>` 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 <cliId>` 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) **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)
+2 -2
View File
@@ -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. ⚠️ **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). ⚠️ **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. ⚠️ **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 <cliId>` 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. ⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` 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.
+18
View File
@@ -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 "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 batches, so lifecycle and hook events are unaffected, and leaving the grid
re-subscribes the shown session. 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** - **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 (`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 spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a
+2
View File
@@ -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). | | `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. | | `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. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl`+click / `Cmd`+click a tab | Add that session to 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 which sessions to show as tiles. |
+20 -2
View File
@@ -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 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 `⋯`. 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 ## Keys
| Shortcut | Action | | Shortcut | Action |
| ------------------------ | ---------------------------------------------------------- | | ------------------------ | ---------------------------------------------------------- |
| `Ctrl+Shift+G` | Open or close the grid. | | `Ctrl+Shift+G` | Open or close the grid. |
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. | | `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. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. | | `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
| `Ctrl+L` | Clear the focused tile. | | `Ctrl+L` | Clear the focused tile. |
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). | | `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 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 also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
terminal as usual. With the Tiles setting off, `Ctrl+Shift+G` does nothing. `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 ## Leaving the grid