mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat(tiles): the Tiles button opens the grid at once; the picker is on right-click
Owner decision 8 ("when I hit the tiles button, open the tiles already!").
A click with the grid closed now opens it straight away, and Ctrl+Shift+G
runs the same function (toggleTileGrid), so the two cannot drift. What
opens comes from one pure helper, tileGridOpenSet (constants.js):
a. the grid this tab last had, if any of its sessions survive, opened
exactly (an open split closes and its sessions do not join);
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 what the grid takes here (the cap of 6, fewer when the
window fits fewer), the active session always among them and focused.
A click with the grid open still closes it.
The picker moved to right-click (oncontextmenu, browser menu suppressed).
With the grid open it is preselected with the current tiles, and Open
replaces them. Ctrl/Cmd+click on a tab with the grid closed opens the
toggle's set plus that session. The button's title, the Help modal and
the wiki say right-click chooses which sessions.
Docs: decision 8 and an as-built entry in the spec (Entry points too),
CLAUDE.md, the invariants (#tile-grid, Opening), the wiki's Tile Grid and
Keyboard Shortcuts pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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, 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). ⚠️ 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. → [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)
|
||||||
|
|
||||||
|
|||||||
@@ -807,6 +807,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
⚠️ **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), `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.
|
||||||
|
|
||||||
|
⚠️ **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).
|
||||||
|
|
||||||
⚠️ **Chrome.** The header is a fixed 24px sibling of the body, 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 24px sibling of the body, 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.
|
⚠️ **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.
|
||||||
|
|||||||
+19
-3
@@ -29,6 +29,14 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
|
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
|
||||||
its last frame.
|
its last frame.
|
||||||
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
|
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
|
||||||
|
- **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 grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is
|
- **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,
|
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
|
so going back to nine is that one line. A stored grid with more ids comes back as its first
|
||||||
@@ -143,9 +151,10 @@ work also fixes gaps the split pane has today.
|
|||||||
|
|
||||||
### Entry points
|
### Entry points
|
||||||
|
|
||||||
- **Header Tiles button** (its own button, beside Split). Opens a picker with
|
- **Header Tiles button** (its own button, beside Split). As built (decision 8)
|
||||||
checkboxes over open sessions, ordered like the tab strip. When the grid is
|
a click opens the grid at once, the same as the toggle shortcut; the picker
|
||||||
open, the button toggles it closed.
|
with checkboxes over open sessions, ordered like the tab strip, is on
|
||||||
|
right-click. When the grid is open, a click closes it.
|
||||||
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
||||||
closed).
|
closed).
|
||||||
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
||||||
@@ -879,6 +888,13 @@ exits green. Use the browser runner for those files and read the file count.
|
|||||||
real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table
|
real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table
|
||||||
keeps 7 to 9 working but unreachable, and the user-facing texts say "at most
|
keeps 7 to 9 working but unreachable, and the user-facing texts say "at most
|
||||||
6 tiles" when the cap, not the window, is what limits the grid.
|
6 tiles" when the cap, not the window, is what limits the grid.
|
||||||
|
8. **The Tiles button opens the grid directly.** Decided by the owner ("when I
|
||||||
|
hit the tiles button, open the tiles already!"): a click opens the grid with
|
||||||
|
no picker in the way, choosing the grid this tab last had, else an open
|
||||||
|
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).
|
||||||
|
|
||||||
## Code anchors
|
## Code anchors
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa
|
|||||||
| `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. |
|
||||||
| `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. |
|
||||||
|
|
||||||
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
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
|
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
|||||||
|
|
||||||
## Opening a grid
|
## Opening a grid
|
||||||
|
|
||||||
- **Tiles button**: with the grid closed it opens a picker, a checkbox per open session in
|
- **Tiles button**: one click shows the tiles straight away. You get the grid you last
|
||||||
tab order. It starts with the grid you last had (or the session you are on), says how many
|
had; if there is none, an open split as two tiles; otherwise your open sessions in tab
|
||||||
tiles this window fits, and greys out the rest. **Open tiles** shows them. With the grid
|
order, up to six (fewer if the window is too small), with the session you are on
|
||||||
open, the same button closes it.
|
focused. With the grid open, the same button closes it.
|
||||||
- **`Ctrl+Shift+G`**: toggles the grid. Opening brings back the grid you last had, else an
|
- **Right-click the Tiles button** to choose which sessions: a checkbox per open session in
|
||||||
open split as two tiles, else the session you are on.
|
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,
|
- **`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
|
opening the grid if it was closed. On macOS use `Cmd`: `Ctrl`+click there opens the tab's
|
||||||
rename instead.
|
rename instead.
|
||||||
|
|||||||
@@ -1802,6 +1802,40 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds, exclude) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||||
|
* (owner decision 8). In order:
|
||||||
|
* 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
|
||||||
|
* (when it sits past the limit, the first `limit - 1` others come with it).
|
||||||
|
* Null when there is nothing to open.
|
||||||
|
*
|
||||||
|
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
|
||||||
|
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||||
|
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||||
|
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
|
||||||
|
*/
|
||||||
|
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
|
||||||
|
if (stored?.ids?.length) {
|
||||||
|
const focus = stored.zoomed || stored.focused;
|
||||||
|
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
||||||
|
}
|
||||||
|
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||||
|
const pair = (split || []).filter(usable);
|
||||||
|
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||||
|
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||||||
|
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
||||||
|
if (all.length === 0) return null;
|
||||||
|
let ids = all.slice(0, max);
|
||||||
|
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||||||
|
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||||||
|
}
|
||||||
|
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which tile takes focus when `id` leaves the grid: the next one in grid
|
* Which tile takes focus when `id` leaves the grid: the next one in grid
|
||||||
* order, else the previous one, else null.
|
* order, else the previous one, else null.
|
||||||
@@ -2116,6 +2150,7 @@ if (typeof window !== 'undefined') {
|
|||||||
tileNeighbor,
|
tileNeighbor,
|
||||||
tileInDirection,
|
tileInDirection,
|
||||||
cycleTile,
|
cycleTile,
|
||||||
|
tileGridOpenSet,
|
||||||
TILE_GRID_MAX,
|
TILE_GRID_MAX,
|
||||||
TILE_LAYOUT_MAX,
|
TILE_LAYOUT_MAX,
|
||||||
TILE_MIN_W,
|
TILE_MIN_W,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||||
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.openTilePicker(event)" title="Tiles: show several sessions side by side" aria-label="Tiles: show several sessions side by side" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTilePicker(event)" title="Tiles: show several sessions side by side (right-click to choose which)" aria-label="Tiles: show several sessions side by side (right-click to choose which)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||||
@@ -840,6 +840,7 @@
|
|||||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||||
|
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
|
|||||||
+60
-27
@@ -161,14 +161,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
|
* 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
|
* 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
|
* adds what is missing and moves focus. `auto: false` makes the focus a human
|
||||||
* selection (it acknowledges that session's idle alert).
|
* selection (it acknowledges that session's idle alert). An open split
|
||||||
|
* closes (the two are never open together); `mergeSplit` (default) makes its
|
||||||
|
* two sessions the first tiles, false opens exactly `ids` (a stored grid).
|
||||||
*
|
*
|
||||||
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
|
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
|
||||||
* its snapshot of the session it shows is still right, and closes its socket.
|
* its snapshot of the session it shows is still right, and closes its socket.
|
||||||
*
|
*
|
||||||
* @returns {boolean} whether the grid is open afterwards
|
* @returns {boolean} whether the grid is open afterwards
|
||||||
*/
|
*/
|
||||||
openTileGrid(ids, { focusedId = null, auto = true } = {}) {
|
openTileGrid(ids, { focusedId = null, auto = true, mergeSplit = true } = {}) {
|
||||||
if (!this.canOpenTileGrid()) return false;
|
if (!this.canOpenTileGrid()) return false;
|
||||||
const grid = (this._tileGrid ||= new TileGridModel());
|
const grid = (this._tileGrid ||= new TileGridModel());
|
||||||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||||||
@@ -179,8 +181,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._splitPane) {
|
if (this._splitPane) {
|
||||||
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
||||||
this.closeSplitPane({ skipPrimaryResize: true });
|
this.closeSplitPane({ skipPrimaryResize: true });
|
||||||
requested = [...seed, ...requested];
|
if (mergeSplit) {
|
||||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
requested = [...seed, ...requested];
|
||||||
|
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const wanted = [];
|
const wanted = [];
|
||||||
for (const id of requested) {
|
for (const id of requested) {
|
||||||
@@ -323,7 +327,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const open = this._tilesOwnTerminal();
|
const open = this._tilesOwnTerminal();
|
||||||
btn.classList.toggle('tiles-open', open);
|
btn.classList.toggle('tiles-open', open);
|
||||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||||
const title = open ? 'Tiles: back to a single session' : 'Tiles: show several sessions side by side';
|
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)';
|
||||||
btn.title = title;
|
btn.title = title;
|
||||||
btn.setAttribute('aria-label', title);
|
btn.setAttribute('aria-label', title);
|
||||||
},
|
},
|
||||||
@@ -358,20 +364,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Tiles button: with the grid open it closes it (back to the single view
|
* Right-click on the Tiles button (its click opens the grid at once, see
|
||||||
* of the focused session); otherwise it opens a picker with a checkbox per
|
* toggleTileGrid): a picker with a checkbox per open session, in tab order,
|
||||||
* open session, in tab order, preselected with the grid this tab last left
|
* 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.
|
* (else the active session and an open split's two), and an Open button.
|
||||||
* Boxes past what the window can fit are disabled.
|
* Boxes past what the grid takes are disabled. With the grid open, Open
|
||||||
|
* replaces its set.
|
||||||
*/
|
*/
|
||||||
openTilePicker(event) {
|
openTilePicker(event) {
|
||||||
// As the split picker: the opening click must not reach the outside-click
|
// As the split picker: the opening event must not reach the outside-click
|
||||||
// listener this call installs.
|
// listener this call installs.
|
||||||
|
event?.preventDefault?.();
|
||||||
event?.stopPropagation?.();
|
event?.stopPropagation?.();
|
||||||
if (this._tilesOwnTerminal()) {
|
|
||||||
this.closeTileGrid({ keepStored: true, reselect: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._tilePicker) {
|
if (this._tilePicker) {
|
||||||
this.closeTilePicker();
|
this.closeTilePicker();
|
||||||
return;
|
return;
|
||||||
@@ -381,7 +385,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const limit = this._tileGridLimit();
|
const limit = this._tileGridLimit();
|
||||||
const capacity = limit.capacity;
|
const capacity = limit.capacity;
|
||||||
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
||||||
const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id));
|
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
|
const seed = remembered.length
|
||||||
? remembered
|
? remembered
|
||||||
: [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean);
|
: [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean);
|
||||||
@@ -443,7 +450,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const ids = boxes.filter((b) => b.checked).map((b) => b.value);
|
const ids = boxes.filter((b) => b.checked).map((b) => b.value);
|
||||||
this.closeTilePicker();
|
this.closeTilePicker();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
this.openTileGrid(ids, { focusedId: ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0] });
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
@@ -666,23 +681,41 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the grid, or closes it to the single view of the focused session.
|
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
|
||||||
* Opening brings back the grid this tab last left (decision 1: one step back
|
* drift (owner decision 8): opens the grid at once, no picker in the way, or
|
||||||
* after a selection outside it), else an open split as two tiles, else the
|
* closes it to the single view of the focused session. What opens is
|
||||||
* active session as one tile.
|
* `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.
|
||||||
*/
|
*/
|
||||||
toggleTileGrid() {
|
toggleTileGrid() {
|
||||||
|
this.closeTilePicker();
|
||||||
if (this._tilesOwnTerminal()) {
|
if (this._tilesOwnTerminal()) {
|
||||||
this.closeTileGrid({ keepStored: true, reselect: true });
|
this.closeTileGrid({ keepStored: true, reselect: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.canOpenTileGrid()) return;
|
if (!this.canOpenTileGrid()) return;
|
||||||
const remembered = this._readStoredTileGrid();
|
const stored = this._readStoredTileGrid();
|
||||||
if (remembered?.ids.length) {
|
const set = this._tileGridOpenSet(stored);
|
||||||
this._openStoredTileGrid(remembered);
|
if (!set) {
|
||||||
|
this.showToast?.('No sessions to show as tiles', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId });
|
if (set.source === 'stored') this._openStoredTileGrid(stored);
|
||||||
|
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({
|
||||||
|
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,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||||||
@@ -932,8 +965,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.selectSession(sessionId);
|
this.selectSession(sessionId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const remembered = this._readStoredTileGrid()?.ids || [];
|
const base = this._tileGridOpenSet()?.ids || [];
|
||||||
const base = remembered.length ? remembered : [this.activeSessionId].filter(Boolean);
|
|
||||||
const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId];
|
const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId];
|
||||||
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
|
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
|
||||||
return true;
|
return true;
|
||||||
@@ -1672,7 +1704,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
*/
|
*/
|
||||||
_openStoredTileGrid(stored) {
|
_openStoredTileGrid(stored) {
|
||||||
const focus = stored.zoomed || stored.focused;
|
const focus = stored.zoomed || stored.focused;
|
||||||
if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true })) return false;
|
// 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;
|
const grid = this._tileGrid;
|
||||||
// openTileGrid laid the grid out with equal tracks. The stored ones go back
|
// 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
|
// on; _applyTileLayout drops them again if they do not match the column or
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('help modal shortcuts', () => {
|
|||||||
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
|
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
|
||||||
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
|
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
|
||||||
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
|
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
|
||||||
|
expectShortcut(helpModal, ['Right-click'], 'Choose Which Sessions to Tile');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
||||||
|
|||||||
@@ -125,11 +125,12 @@ describe('dragging a tab onto an empty slot', () => {
|
|||||||
describe('Ctrl/Cmd+click on a tab', () => {
|
describe('Ctrl/Cmd+click on a tab', () => {
|
||||||
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
|
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
|
||||||
|
|
||||||
it('with the grid closed: opens it with the active session and the clicked one, focusing it', () => {
|
it('with the grid closed: opens what the Tiles toggle would, plus the clicked one, focusing it', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
app.activeSessionId = 's-a';
|
app.activeSessionId = 's-a';
|
||||||
|
// Nothing remembered, no split: the open sessions in tab order (the clicked one last).
|
||||||
app.handleSessionTabClick(click({ ctrlKey: true }), 's-c');
|
app.handleSessionTabClick(click({ ctrlKey: true }), 's-c');
|
||||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
|
||||||
expect(app.activeSessionId).toBe('s-c');
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The Tiles button opens the grid at once (owner decision 8).
|
||||||
|
*
|
||||||
|
* - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid
|
||||||
|
* this tab last had, if any of its sessions survive; (b) else an open
|
||||||
|
* split's two sessions; (c) else the open sessions in tab order up to the
|
||||||
|
* limit (never past the cap of 6), the active one always among them and
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Pure helper via `vm`, the app 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, body, bySelector, makeGridApp, resetGridHarness } from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
|
||||||
|
type Helpers = { tileGridOpenSet(p: Record<string, unknown>): 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();
|
||||||
|
const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||||
|
|
||||||
|
const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`);
|
||||||
|
const sessions = new Map(order.map((id) => [id, { id, name: id }]));
|
||||||
|
const base = { sessions, sessionOrder: order, limit: 6 };
|
||||||
|
|
||||||
|
describe('tileGridOpenSet (what the Tiles button opens)', () => {
|
||||||
|
it('a: the grid this tab last had, its focus (or its zoom) included', () => {
|
||||||
|
const stored = { ids: ['t4', 't2'], focused: 't2', zoomed: null };
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({
|
||||||
|
source: 'stored',
|
||||||
|
ids: ['t4', 't2'],
|
||||||
|
focusedId: 't2',
|
||||||
|
});
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe(
|
||||||
|
't2'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('b: else an open split, Pane A focused', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, stored: { ids: [] }, split: ['t5', 't7'], activeId: 't5' })).toEqual({
|
||||||
|
source: 'split',
|
||||||
|
ids: ['t5', 't7'],
|
||||||
|
focusedId: 't5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: else the open sessions in tab order up to the limit, the active one focused', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({
|
||||||
|
source: 'tabs',
|
||||||
|
ids: order.slice(0, 6),
|
||||||
|
focusedId: 't3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: an active session past the limit still comes, with the first ones before it', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({
|
||||||
|
source: 'tabs',
|
||||||
|
ids: ['t1', 't2', 't3', 't4', 't5', 't9'],
|
||||||
|
focusedId: 't9',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('c: a window that takes fewer gets fewer; nothing ever passes the cap', () => {
|
||||||
|
expect(T.tileGridOpenSet({ ...base, limit: 4, activeId: 't1' })?.ids).toEqual(['t1', 't2', 't3', 't4']);
|
||||||
|
expect(T.tileGridOpenSet({ ...base, limit: 99, activeId: 't1' })?.ids).toHaveLength(T.TILE_GRID_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out detached sessions and ones that no longer exist, as the picker does', () => {
|
||||||
|
const detachedIds = new Set(['t2']);
|
||||||
|
const out = T.tileGridOpenSet({ ...base, sessionOrder: ['gone', ...order], detachedIds, activeId: 't1' });
|
||||||
|
expect(out?.ids).toEqual(['t1', 't3', 't4', 't5', 't6', 't7']);
|
||||||
|
// A split whose Pane B was popped out: Pane A alone.
|
||||||
|
expect(T.tileGridOpenSet({ ...base, split: ['t1', 't2'], detachedIds, activeId: 't1' })?.ids).toEqual(['t1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when there is nothing to open', () => {
|
||||||
|
expect(T.tileGridOpenSet({ sessions: new Map(), sessionOrder: [], limit: 6, activeId: null })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Tiles button and Ctrl+Shift+G', () => {
|
||||||
|
const button = () => {
|
||||||
|
const html = INDEX_HTML.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)?.[0] ?? '';
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a click opens the grid at once (the toggle), a right-click opens the picker', () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+G runs the same toggle', () => {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/tile-grid.js'), 'utf8');
|
||||||
|
expect(src).toContain("if (id === 'toggle-tile-grid') this.toggleTileGrid();");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click with the grid closed shows the tiles, no picker', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.activeSessionId = 's-b';
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(picker()).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a click closes a picker that a right-click left open', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
|
expect(picker()).not.toBeNull();
|
||||||
|
app.toggleTileGrid();
|
||||||
|
expect(picker()).toBeNull();
|
||||||
|
expect(app._tilesOwnTerminal()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a right-click opens the picker and keeps the browser menu away', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||||
|
app.openTilePicker(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', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
|
||||||
|
app.openTilePicker({ 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']);
|
||||||
|
expect(app.activeSessionId).toBe('s-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -99,7 +99,7 @@ describe('the Tiles button', () => {
|
|||||||
app.openTileGrid(IDS);
|
app.openTileGrid(IDS);
|
||||||
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
||||||
expect(btn.classList.contains('tiles-open')).toBe(true);
|
expect(btn.classList.contains('tiles-open')).toBe(true);
|
||||||
app.openTilePicker({ stopPropagation: vi.fn() });
|
app.toggleTileGrid();
|
||||||
expect(app._tilesOwnTerminal()).toBe(false);
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||||
expect(picker()).toBeNull();
|
expect(picker()).toBeNull();
|
||||||
|
|||||||
@@ -157,14 +157,15 @@ describe('the capture-phase handler', () => {
|
|||||||
expect(app._tilesOwnTerminal()).toBe(false);
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
|
it('Ctrl+Shift+G opens the grid on the open sessions (the active one focused), then closes it, overriding the browser', () => {
|
||||||
const app = withTilesSetting(makeGridApp(IDS));
|
const app = withTilesSetting(makeGridApp(IDS));
|
||||||
app.selectSession = vi.fn();
|
app.selectSession = vi.fn();
|
||||||
const onKeydown = handlerFor(app);
|
const onKeydown = handlerFor(app);
|
||||||
const open = chord(TOGGLE);
|
const open = chord(TOGGLE);
|
||||||
onKeydown(open);
|
onKeydown(open);
|
||||||
expect(open.preventDefault).toHaveBeenCalled();
|
expect(open.preventDefault).toHaveBeenCalled();
|
||||||
expect(app._tileGrid.ids).toEqual(['s-a']);
|
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
|
|
||||||
onKeydown(chord(TOGGLE));
|
onKeydown(chord(TOGGLE));
|
||||||
expect(app._tilesOwnTerminal()).toBe(false);
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
@@ -215,7 +216,7 @@ describe('the actions', () => {
|
|||||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with every remembered session gone, it opens on the active session instead', () => {
|
it('with every remembered session gone, it opens on the open sessions in tab order instead', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
app.openTileGrid(['s-b', 's-c']);
|
app.openTileGrid(['s-b', 's-c']);
|
||||||
app.closeTileGrid({ reselect: false });
|
app.closeTileGrid({ reselect: false });
|
||||||
@@ -223,7 +224,8 @@ describe('the actions', () => {
|
|||||||
app.sessions.delete('s-c');
|
app.sessions.delete('s-c');
|
||||||
app.activeSessionId = 's-a';
|
app.activeSessionId = 's-a';
|
||||||
app.toggleTileGrid();
|
app.toggleTileGrid();
|
||||||
expect(app._tileGrid.ids).toEqual(['s-a']);
|
expect(app._tileGrid.ids).toEqual(['s-other', 's-a']);
|
||||||
|
expect(app.activeSessionId).toBe('s-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
|
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
|
||||||
|
|||||||
@@ -65,6 +65,22 @@ describe('opening the grid over an open split', () => {
|
|||||||
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
|
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', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn();
|
||||||
|
app.openTileGrid(['s-c']);
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.activeSessionId = 's-a';
|
||||||
|
const paneB = openSplit(app);
|
||||||
|
|
||||||
|
app.toggleTileGrid();
|
||||||
|
|
||||||
|
expect(paneB.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._splitPane).toBeNull();
|
||||||
|
expect(app._tileGrid.ids).toEqual(['s-c']);
|
||||||
|
expect(app.activeSessionId).toBe('s-c');
|
||||||
|
});
|
||||||
|
|
||||||
it('an explicit open over a split keeps both split sessions first', () => {
|
it('an explicit open over a split keeps both split sessions first', () => {
|
||||||
const app = makeGridApp(IDS);
|
const app = makeGridApp(IDS);
|
||||||
openSplit(app);
|
openSplit(app);
|
||||||
|
|||||||
Reference in New Issue
Block a user