Compare commits

...
Author SHA1 Message Date
Codeman maintainer 421482e121 docs(tiles): the hover card on the Tiles button
The spec's as-built list, the wiki's Tile Grid page and the CLAUDE.md
tile grid paragraph: the button has no native title, its hover card
says the count and what a click and a right-click do, it is the
button's aria-describedby (always present, hidden, kept current), and it
hides in the capture phase on any press, click or right-click so the
count menu never opens beside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:00:13 +02:00
Codeman maintainer a898253089 fix(tiles): a keyboard focus brings the Tiles hover card back after a click
Found live: a click on Tiles hides the card and keeps it hidden while
the pointer rests on the button, until the pointer leaves. With the
pointer left there, tabbing away and back onto the button showed no card
either, so a keyboard user whose mouse happened to sit on the button
never got the Shift+F10 hint.

Leaving the button (blur) now ends that suppression as well: a keyboard
focus that comes back later is a new arrival. A click that opens the
grid or a right-click that opens the menu still shows nothing, since no
pointerenter or focus follows while the pointer stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 17:48:04 +02:00
Codeman maintainer d2a1d14eac feat(tiles): a hover card on the Tiles button says what a right-click does
Owner feedback 1: "give me the hover info to right click over the tile
button to adjust it". The only hint was the native title, which the
browser shows late, small and unstyled.

The Tiles button now has its own hover card, under it in the count
menu's panel style:

- "Tiles · N" with the remembered count (live: a pick in the menu
  changes it while the card is up), "Click: open the grid" ("close the
  grid" while it is open), "Right-click: choose 2, 4 or 6 tiles", and
  when the count does not fit the window what opens instead ("This
  window fits 4 tiles: a click opens 4"; open, what fits). Shown from
  the keyboard it adds "Shift+F10: the same menu from the keyboard".
- Shows 300 ms after a pointer that hovers rests on the button, or after
  a keyboard focus (:focus-visible); never for a touch pointer or a
  device that cannot hover (plus a CSS @media (hover: none) backstop),
  never on a hidden button, never while the count menu is open. A short
  fade on opacity and transform, none under reduced motion; it takes no
  pointer.
- Hides on pointer leave, blur, Escape, a scroll and a resize, and on a
  press, a click or a right-click on the button (capture phase, so it is
  gone before the menu or the grid opens, and it stays gone while the
  pointer rests there). openTileCountMenu hides it too, and the focus
  the menu's Escape puts back on the button brings no card back.
- It replaces the button's native title (two tooltips never stack); the
  aria-label stays and aria-describedby points at the card, which always
  exists and is kept current, its keyboard line included while hidden, so
  screen readers hear the same text without a hover. Text is diffed
  against the last English, as the rest of the grid chrome, so the zh-CN
  translator is not fought on every refresh.
- zh-CN for every line (平铺 · N, 单击, 右键单击, Shift+F10, the fits
  note); no other header button changes (its styles are .tile-hint only).

Tests: tile-grid-hint.test.ts (install and aria wiring, the delay, each
show and hide path, content per state and count, the menu rule, the
translator, the CSS); the count menu and i18n tests read the accessible
name instead of the removed title, and the i18n harvest covers the card
(F10 joins the key names that stay Latin). The vm harness gains
removeAttribute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 17:33:26 +02:00
Codeman maintainer 4bb333e9bc style(header): hover moves the icon, never the button
A global `.btn-icon-header:hover { transform: rotate(45deg) }`, meant for
the settings gear, turned every header icon button on hover, so the folder,
Tiles, Split and the rest swung their rounded hover background into a
diamond. Three buttons had already cancelled it one by one (the font-size
buttons, notifications, the sidebar toggle).

The rule is gone, and with it those three overrides. Hover motion now moves
the icon only:
- the settings gear's icon turns 45 degrees (one tooth, so it lands on the
  same shape);
- the Tiles button's four squares spread apart, each toward its corner;
- the folder cross-fades to an open folder (a second drawing in its SVG,
  `.icon-folder-closed` / `.icon-folder-open`);
- every other icon just takes the hover colour.

Pointer devices only (`@media (hover: hover)`, so a tap cannot leave an icon
stuck mid-motion), and the transitions are off under reduced motion.

Owner request: the Tiles and folder buttons "weirdly turn" on hover.
Checked live on a dark and a light skin (rest, mid, end frames). Pinned by
test/header-icon-hover.test.ts, mutation-checked five ways (the button
rotation back, the open drawing missing, the motion not hover-gated, a
square spreading toward the wrong corner, reduced motion keeping its
transition). Gate: 507 files, 9798 tests passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 06:15:19 +02:00
Codeman maintainer fcec77131c fix(tiles): the count menu closes when the keyboard leaves it
Found live: closing the grid with a click starts the single view's
selection, which focuses its terminal when its replay lands, a few
hundred milliseconds later. A right-click on Tiles in between opened the
count menu with the keyboard in it, and the late focus then moved the
keyboard into the terminal while the menu stayed open (3 of 3 tries), so
the arrows, Enter or Escape meant for the menu went to the session's
PTY instead (an Escape arrived there as an ESC byte).

The menu now closes when the keyboard leaves it for another element, as
any menu does. A focus going nowhere (a click on a button in Safari,
which does not focus it) does not count, so a click on a count still
picks it. Live afterwards: the menu either closes as the terminal takes
the keyboard, or keeps it when the replay landed first; never open with
the keyboard elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 05:13:43 +02:00
Codeman maintainer 4ad283c647 docs(tiles): the count menu, the open/close animation and the paced open
- docs/tile-grid-plan.md: owner decision 10 (right-click Tiles is a
  2 / 4 / 6 count menu, default 6, remembered per device; the session
  picker is gone; decision 8's "picker on right-click" and "exactly the
  stored set" superseded) with the owner's answers on the details; three
  as-built bullets (the count menu, the animation, painting first); the
  Tiles-button bullet rewritten for the count; the entry points, the
  capacity note, the multi-user row and the Escape invariant follow.
- docs/architecture-invariants.md: the Opening paragraph rewritten (the
  count, the stored grid in its cells, the menu owning its Escape, the
  paced connect and focusOnConnect, the motion rules); the z-index list
  names the count menu and the closing grid's still copy.
- CLAUDE.md: the tile grid paragraph and the z-index line.
- Wiki: Tile Grid (the click, the count menu, the animation, reduced
  motion) and Keyboard Shortcuts (right-click Tiles).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 04:53:02 +02:00
Codeman maintainer dbaf328c0a perf(tiles): opening paints the tiles first, one terminal per frame after
Owner answer: "paced connect: in". Measured on the checkpoint build: of
the 140 ms between the click on Tiles and its first frame, about 100 ms
was the six xterms being built inside the click, so nothing moved on
screen for that long and the entrance could not start.

openTileGrid now mounts every tile and lays the grid out as before, then
_connectTilesPaced builds one terminal per animation frame, the focused
tile's first, then reading order. The click paints its empty tiles in
about 20 ms and the entrance plays while the terminals are built. The
time until every tile has painted does not change: the load queue serves
one capture at a time, so only the focused tile's connect is on its path,
one frame later. Each tile still connects once, into its final cell (one
fit, one PTY resize).

- openTileGrid returns before the terminals exist, so a selection that
  focuses a tile whose terminal is not built yet hands the keyboard over
  in _connectTile (focusOnConnect), never when focus: false was asked,
  and to the newly focused tile when focus moved meanwhile.
- _connectTile connects a tile once (entry.connected; a remount after
  Attach resets it), so a re-form or a remount before a tile's turn is
  never connected twice.
- A grid closed or opened again meanwhile stops the old run (a run
  token), and asks for no further frames.

Tests: tile-grid-paced-connect.test.ts (the order, the final cells, the
keyboard, close and reopen, removed and remounted tiles); the tests that
read connect or the terminal's focus right after openTileGrid now run
the queued frames first (flushFrames in the vm harness), every
assertion kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 04:40:15 +02:00
Codeman maintainer 78bfe53dc7 feat(tiles): the grid opens and closes with a short animation
Owner request: "when clicking on the tile button first make this
animation nicer". Recorded before: the click froze the page, six empty
tiles cut in at once, each tile's history then scrolled in visibly as
its load landed, and closing showed an empty single view for about a
quarter of a second before its own replay scrolled in.

The grid's own motion, on by default (not an entrance-animations.js
theme, which are off by default):

- Opening: each tile fades and settles in (opacity, translateY 6px,
  scale .97), 180 ms, 24 ms apart in reading order: the last of six is
  done at 300 ms. Its terminal stays transparent until the load queue
  reports its first capture done, then fades in whole (160 ms), so no
  replay scrolls by; a 15 s backstop shows it should that never come. A
  tile added later enters the same way.
- Closing with the toggle (button, Ctrl+Shift+G; owner answer: only
  these): the close stays synchronous, and a still copy of the tiles
  (clones: no xterm, socket or listener; inert, aria-hidden, no pointer)
  dims at once over the stage, holds until the single view's
  selectSession has replayed its session (at most 700 ms), then fades
  out. No empty single view between the two. A reopen drops a copy
  still showing; a web tab hides it.
- A re-form to another count fades the old grid's copy out at once while
  the new tiles enter.
- The count menu fades in (140 ms).

Every one animates opacity and transform only, so FitAddon measures the
final cell and each tile still sends one PTY resize (#464); nothing
moves, and no copy is made, under prefers-reduced-motion.

Tests (tile-grid-motion.test.ts): the stagger, the reveal and its
backstop, the same fits and connects with and without motion, the held
copy (released on settle, by its cap, removed on the last tile-leave or
its fallback), only the toggle animates, a reopen purges, the zoomed
tile alone, the re-form, reduced motion, and a CSS guard that every new
keyframe touches only opacity and transform. The vm harness gains
style.setProperty, cloneNode, isConnected and lastElementChild.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 04:24:54 +02:00
Codeman maintainer 7debebb7b3 feat(tiles): right-click Tiles is a 2 / 4 / 6 count menu
Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).

- A pick is remembered per device in codeman:tile-count (default 6;
  codeman:tile-grid stays ids only) and opens that many tiles. The click
  and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
  last had, else an open split's two, else tab order, the active one
  included and focused), trimmed from the end (the focused one kept) or
  filled from tab order to the count. A remembered grid comes back with
  its tiles first, in their cells, holes filled first, then tab order
  (owner answer, superseding decision 8's "exactly the stored set"). A
  page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
  change under the cell model's rule (reformTileCells), the focused tile
  always kept, every joining tile mounted and laid out before any of
  them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
  that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
  remembered one stays checked, the keyboard starts on the largest that
  fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
  (the global handler gives it the key first, like the tab-group menu)
  and puts the keyboard back on the Tiles button; Tab and a click
  elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
  modal row); the i18n test harvests the menu now, with a session named
  "6 tiles" as the user-text trap.

Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 04:10:55 +02:00
Codeman maintainer 64298101b0 feat(tiles): pure helpers for a tile count of 2, 4 or 6
The Tiles button's right-click becomes a count menu (owner decision 10):
how many tiles, 2, 4 or 6, default 6, remembered per device. These are
its pure parts in constants.js (window.CodemanTileGrid):

- TILE_GRID_COUNTS / TILE_GRID_COUNT_DEFAULT and sanitizeTileCount: a
  remembered count is one of 2, 4, 6, anything else reads as 6.
- tileGridSetForCount: what the grid opens (or an open grid shows)
  trimmed or filled to N: trimmed from the end with the session to focus
  always kept, filled from the open sessions in tab order; fewer
  sessions than N give fewer tiles; never past the cap.
- tileCellCols: the column count a stored cell list was laid out with
  (stored cells carry no shape of their own).
- reformTileCells: a count change is a shape change: the tiles that
  stay keep their cells, the cell model's rule (fitTileCells) reshapes,
  and the tiles that join fill the empty cells in reading order, holes
  first.

Nothing uses them yet; the menu comes next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 03:53:10 +02:00
Codeman maintainer 45ca9c347b feat(tiles): the grid is cells, and an empty cell can be any cell
Owner feedback 1: "so the empty tab doesnt always have to be the last
one! so I can move freely around and the empty tab can also be tab nr
4 or 3". This replaces the slot refusal of aecada8c.

grid.cells (a session id or null per cell) is now the one source of
truth; grid.ids is a getter deriving the tiles in reading order, so
everything that only wants the tiled sessions (focus neighbour,
cycling, the load queue's order, the picker, closeSession) is
unchanged. The shape still comes from the tile count and the cap
counts tiles, never empty cells.

- A tile dragged onto an empty cell moves there and leaves its own
  cell empty, nothing else moving (_moveTileToCell, through
  _reorderTiles: no remount, reconnect or reload; only a tile whose
  cell size changed fits). A tiled session's tab does the same; a tab
  of a session not tiled yet joins in the cell it is dropped on. Each
  slot knows its cell and reads "Drop a tab or a tile here".
- Move Tile goes to the adjacent cell: into it when empty, a swap when
  a tile is there (tileCellInDirection).
- Removing a tile leaves its cell empty; adding one takes the first
  empty cell. A shape change goes through fitTileCells: each tile keeps
  its row and column when all fit (2x2 growing to 3x2), else the tiles
  pack in reading order.
- Focus never lands on an empty cell: Alt+Shift+Arrows run over the
  cells, Ctrl+Tab and Alt+[ ] over the tiles.
- codeman:tile-grid stays ids only: its ids are the cells with null for
  an empty one. A reload brings the holes back when the shape is the
  same (a session gone since leaves its cell empty), another shape
  packs, the old packed format reads unchanged, and a followed
  #session= link keeps the holes.

Docs: the spec's as-built bullet (rewritten in place), the wiki's Tile
Grid page and Keyboard Shortcuts, the invariants and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 03:08:14 +02:00
Codeman maintainer e702f3c151 feat(tiles): pure helpers for a grid whose empty cells can be anywhere
Owner: an empty cell need not be the last one ("the empty tab can also
be tab nr 4 or 3"). The helpers that let the grid hold cells instead of
a packed list, with no behaviour change on their own:

- fitTileCells: the cells after a shape change. The same shape keeps
  every cell, holes included; a new shape keeps each tile at its row
  and column when all fit (2x2 growing to 3x2: the four tiles stay
  put), else the tiles pack in reading order.
- tileInDirection takes cells: focus never lands on an empty cell.
  Left and right go along the row past a hole and never leave it; up
  and down take the nearest row with a tile, the same column else the
  nearest (lower on a tie). For a packed list this is exactly the old
  rule, short last row included.
- tileCellInDirection: the adjacent cell a Move Tile chord moves into
  or swaps with.
- sanitizeTileGridState reads the stored ids as cells (null for an
  empty one) and returns them as `cells` beside the packed `ids`; a
  dropped id becomes a hole, never a shift. The old packed format reads
  as cells with no hole.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 02:46:23 +02:00
Codeman maintainer aecada8c56 fix(tiles): no tile onto an empty slot, header focus on click, arrow chords skip text fields
The owner's answers on moving tiles:

- "dont move the tile": an empty slot no longer takes a tile. A slot is
  always the last cell, so a move there shifted every tile after it.
  Each drop target now says what it accepts (_acceptTabDrops'
  `accepts`): a tile takes any session but its own, an empty slot only a
  session not tiled yet. A refused drag is still held (dropEffect none,
  no highlight), and dropSessionOnSlot refuses a tiled session too, its
  tab included. A session not tiled yet still joins on a slot.
- A cancelled drag changes nothing, focus included (best practice): the
  header focuses its tile on click, never on press, so a drag that ends
  with Escape or outside leaves focus and the idle alert alone. The body
  keeps press-to-focus, so focus still moves before a press reaches
  xterm. The rename input stops its own clicks.
- A tiled tab dropped on the zoomed tile stays refused (confirmed).
- The Alt+Shift+Arrow focus chords skip a text field too (best
  practice), as the move chords already did: shifted arrows select
  there. Toggle and zoom are not text-editing keys and are unchanged.

Docs: the wiki, the spec's as-built bullet (with the owner's answers),
the invariants and CLAUDE.md say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 01:28:20 +02:00
Codeman maintainer adeb22d7b2 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>
2026-10-08 00:13:32 +02:00
Codeman maintainer 20c2561a45 feat(tiles): Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)
Four rebindable registry chords in the Tiles group move the focused
tile: it trades places with the neighbour the Alt+Shift+Arrow focus
chords pick (tileInDirection), through the same _reorderTiles path as
the header drag, and keeps the focus. Nothing at an edge.

They go through tileShortcutFor()/runTileShortcut() like the other
tile chords, so every xterm key handler swallows them while they
apply: only while the grid is open, a zoomed grid included (a no-op
there, so the keys never reach the CLI), and never in a text field
other than xterm's own textarea, where Ctrl+Shift+Arrows select by
word.

Ctrl+Shift+Arrows because every other two-modifier arrow chord is
taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows
graphics drivers), Ctrl+Alt+Shift moves a window to another workspace
(GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the
browser's back and forward, Alt+Shift focuses tiles. No browser,
GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it
costs a terminal editor's word selection inside a tile while the grid
is open.

The Help modal lists the chords and the header drag; the shortcut
overlay lists the registry. zh-CN entries for every new string.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 23:49:11 +02:00
Codeman maintainer 2b20288ca0 feat(tiles): drag a tile by its header to move it
Owner request: "give me the option to move the tiles around". A tile's
header (its free area, not the buttons or the rename input) is now a
native drag handle: dropped on another tile the two trade places, dropped
on an empty slot it moves there (the last cell; the tiles after it close
up). Escape or a drop anywhere else is the browser's own cancel and
moves nothing.

Every move goes through one path, _reorderTiles: the header drag, a
tab of a tiled session dropped on a tile or a slot, and (next commit)
the Move Tile chords. Nothing is remounted, reconnected or reloaded.
Divider sizes belong to the cells, so a moved tile takes its new
cell's size: each tile whose cell size changed fits once (one PTY
resize, #464) and every other tile is left alone, in place of the
debounced refit of every tile the swap used to schedule.

The drag reuses the tab drop targets (capture phase, stopped before
xterm), carries a type of its own and never text, and is not
draggedTabId, so neither a text field nor the tab strip takes it.
Moving is off while a tile is zoomed (draggable off, and a tab drag
of a tiled session onto the zoomed tile is refused too) and with a
single tile. The handle shows a grab cursor and says it drags in its
tooltip (zh-CN included); the dragged tile is dimmed and the target
highlighted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 23:33:16 +02:00
Codeman maintainer 9209922ea4 fix(deepseek): reject the dsh footer's non-model words instead of requiring a digit
The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`,
`deepseek-reasoner` carry no digit), so a session on the official route with
the model field on showed the logo alone (its bundle row pins a provider
alone, so the config had nothing either). It also still misread a folder name
with a digit when every field before it was off.

Now the captured field is rejected when it is what the field can be when it is
NOT the model, and read otherwise:

- capabilities.modelDetect.rejectWords (registry data, single tokens, compared
  ignoring case; the schema bounds them and requires a screenLine). dsh lists
  every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek
  adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 /
  dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`,
  CJK) can never be one captured field.
- In the shared screen reader, for every CLI: a field equal to the session's
  own working-directory basename is the folder, never the model.

Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are
read; every effort id, `default`, `plan mode`, and the folder name first (with
and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`.
Known gaps, all off by default, are named in stock.ts: a custom mode id drawn
raw, a git branch or a one-word session title first, and the non-compact
footer layout (nothing read there; the route config applies).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 21:16:53 +02:00
Codeman maintainer 2e25bfa9e0 docs: the dsh route config as a displayModel source
- api-reference: the `config` source in the displayModel table, read again at
  every pane start, attach and relaunch rather than restored.
- cli-registry: `modelDetect.configResolver` (a named, read-only, bounded
  reader), the stock `deepseek-route` reader and its rules, and why the dsh
  footer pattern needs a digit.
- deepseek-integration §4: Codeman reads the route for display only, the way
  dsh-TUI resolves it; the catalog check it cannot see.
- architecture-invariants (tile grid), tile-grid-plan "as built" (owner
  feedback 1), the wiki's model row, CLAUDE.md's source order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 20:39:30 +02:00
Codeman maintainer 3104e9945b feat(tiles): a header names a model read from the CLI's config as such
A session header's tooltip (tile and split) says where a model the CLI did
not report came from; the new `config` source reads
"DeepSeek · qwen3.8-27b (from config)", with the zh-CN pattern
"(来自配置)" (the harness and model names pass through). The header's model
text itself is unchanged. Covered in the chrome tooltip test and the i18n
harvester's exercise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 20:18:38 +02:00
Codeman maintainer 21ae48a5c8 fix(deepseek): the dsh footer field after a switched-off model is not the model
dsh-TUI draws the model as its status line's first field only while the
status bar's model field is on (the default). Switched off, the first field
is the reasoning effort (` medium · th-scratch`), else the mode, else the
cwd's basename, and the footer pattern read that as the model, which would
also outrank the route config added in the previous commit.

The captured field must now carry a digit, as a model id does (a version) and
an effort word, a mode name or most folder names do not. A model id without
one (`deepseek-chat`) is not read from the screen and the session falls back
to its route config: silent, never wrong.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 19:54:17 +02:00
Codeman maintainer 661fe3dc13 feat(sessions): a dsh session shows its route config's model while its screen names none
displayModel gains a `config` source, ranked below any report from the running
CLI and above the launch model: custom endpoint, then statusline or screen,
then config, then launch, then nothing. The screen still wins whenever it
names a model, since that is what the running TUI uses.

- Registry data: capabilities.modelDetect gains `configResolver`, a NAMED
  reader (src/model-config-resolvers.ts), like a launcher profile; dsh names
  'deepseek-route' (the reader from the previous commit). `screenLine` becomes
  optional; the schema refuses a modelDetect naming nothing, an unknown
  reader, or screenLines without a screenLine.
- Session: the reader runs from _withPaneLifecycle's finally, so at every pane
  start, attach and relaunch, with the session's own launch config
  (legacyConfigForMode) and env (its clamped overrides, then the server's), so
  a per-session DSH_HOME is the home read. Async; a read that lands after a
  newer one or after the session stopped is dropped; a remote or docker
  session reads nothing locally. A change emits displayModelChanged
  (broadcast and persist). Not restored after a restart: the next attach
  reads it again, and a restored screen value outranks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 19:41:56 +02:00
Codeman maintainer 284f86b740 feat(deepseek): read the model a dsh session's TUI route config pins
A DeepSeek session whose screen names no model (dsh-TUI's status bar model
field off, or not drawn yet) can still name the model its route config pins
(owner request). src/deepseek-route-config.ts resolves it the way dsh and
dsh-TUI 0.10.0-beta.1 do, for the session's profile (else the one the launch
boots) under the session's dsh home:

- dsh composes a profile from patch layers: the bundles, then
  profiles/<profile>/cordis.patch.yml, then $DSH_HOME/cordis.patch.yml (which
  outranks it). A patch's `config` replaces the dsh-tui row's whole config, a
  `name` mismatch skips it, `disabled` turns the row off.
- dsh-TUI takes its route from that config only when it names BOTH provider and
  model (modelRoute.js); anything less falls back to state the config does not
  hold, so the answer is nothing. The bundle row pins a provider alone by
  design and is not read (it resolves outside the dsh home); settings.yaml's
  agent-default-model is the headless default and is never read.
- Any doubt answers nothing: a profile without dsh-TUI, a half-pinned route,
  an unreadable or oversized layer, a symlink out of the dsh home, a mount that
  does not answer, a file beyond a narrow strict YAML subset (no dependency
  added: plain keys, single-line string scalars for the values it needs; tags,
  anchors, aliases, merge keys, multi-line scalars, flow or block-scalar
  config, duplicate keys, typed scalars, a second document, or a nested row
  re-defining dsh-tui all answer null).
- Bounded and read-only: every path is probed with probePathKind() first, read
  async with a 64 KiB cap, and must realpath inside the dsh home. Only the
  model id leaves the module.

The default-profile inventory reuses the resolver's classification through a
new pure deepSeekProfileFromManifest(), read with the same bounded rules.
Not wired to sessions yet; the next commit does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 19:27:45 +02:00
Codeman maintainer c481bf4f47 docs: session headers name the harness and the model (displayModel)
- api-reference: the new `displayModel` session field, its sources in order
  (custom-endpoint, statusline, screen, launch) and that it is untrusted
  display text, persisted and restored when the CLI reported it.
- cli-registry: `capabilities.modelDetect` (one capture group, the last rows
  of the probe's capture, anchored on chrome only that CLI draws), the two
  stock patterns (dsh-TUI, codex) and the fifth config regex.
- architecture-invariants (tile grid): the header painter, the id as data, the
  untrusted model text, no writes for an unchanged session, the truncation
  order, Pane A's strip and its fits through syncTerminalGeometry.
- tile-grid-plan "as built", the wiki's Tile Grid page (logo and model rows,
  Split's strips), and CLAUDE.md's tile grid and CLI registry paragraphs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:32:00 +02:00
Codeman maintainer c6e13e4fcf feat(split): both split panes name their harness and model
"Each session in the split view" (owner request) gets the tile header's
strip: the harness logo, the name and the model, painted by the same
_paintSessionHarness.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:32:00 +02:00
Codeman maintainer 969f273fec feat(tiles): each tile header names its harness and model
The tile header is now `● [logo] name · model ..... ⋯ ⤢ ×` (owner request):

- The logo is PR #532's `run-mode-dot <cliId>` slot, so the logos, the skins
  and the plain dot of an id without a logo stay single-sourced in styles.css.
  The id is data (a class and a catalog lookup), never a branch; the frontend
  id-branching guard now also scans constants.js, terminal-split.js and
  tile-grid.js (the one existing shell branch in tile-grid.js, the attach
  route, is allowlisted with its reason).
- The model is the session's displayModel, as text in a data-i18n-skip span
  inside a box whose tooltip may translate. Unknown means the logo alone.
- The logo's tooltip and accessible name say "<harness> · <model>", plus where
  a model the CLI did not report came from ("set at launch", "custom
  endpoint"; zh-CN patterns for both, the names pass through). The model's box
  is aria-hidden so a screen reader hears the model once.
- One painter, _paintSessionHarness (terminal-split.js, shared with the split
  panes next), diffs against what it last wrote, never the DOM: an unchanged
  session writes nothing on a tab render.
- On a narrow header the model gives way first, then the name: the name does
  not shrink at all and is capped at its box, since any shrink factor takes a
  subpixel from a name that fits and ellipsizes it.

The chrome and zoom tests found header parts by child position; they now look
them up by class, with every assertion kept (the rename tests had been passing
against the new logo node by position). The i18n harvester files the logo's
labels as harness and model names that must stay as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:32:00 +02:00
Codeman maintainer 8392854619 feat(sessions): publish the model each session runs (displayModel)
A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:

- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
  session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
  Claude's statusLine exporter already posts model.display_name on every
  render; the status-telemetry route now records it (only for a CLI with
  capabilities.statusLineTelemetry). A CLI whose registry entry declares the
  new capabilities.modelDetect has its footer read off the pane capture the
  idle/working probe already takes (no extra tmux call), so an in-session
  /model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
  app-wide default, another CLI's <cli>Config.model), read where the registry
  says the model param lives;
- nothing known: no field, never a placeholder.

modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.

An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:31:59 +02:00
Codeman maintainer d044406f8f feat(web): show each CLI's logo in the Run menus instead of a colour dot
The Run menus (toolbar dropdown, phone overview picker, Custom Endpoint rows,
model picker) marked every backend with an 8px colour dot, so telling Codex
from DeepSeek meant reading the label. Each known backend now draws its own
logo in that slot. It is CSS only: every surface already renders
`.run-mode-dot <id>`, so no markup changes.

- Brand-coloured marks (Claude, Gemini, Antigravity, DeepSeek, OMP) paint as a
  background image; monochrome ones (Codex, OpenCode, Pi, Grok, plus Shell and
  web URLs) are masks over the row's text colour, so they follow every skin.
- Logos are inline SVG data URIs (img-src already allows data:), from
  @lobehub/icons-static-svg 1.95.1 (MIT); the OMP mark is omp.sh's own.
- Drops the non-og skin overrides that re-tinted four dots with a
  `background:` shorthand, which would have wiped the logo.
- An id with no logo (a clis.json addition) keeps a dot, now in --text-dim
  instead of being transparent.
- test/run-menu-cli-logos.test.ts pins that every stock agent plus shell/web
  has a logo in exactly one paint group and that nothing resets the slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d00229ee29)
2026-10-07 11:48:47 +02:00
Codeman maintainer 218b03ceb7 test(tiles): the load-queue test drives TerminalTile on the shared fakes
tile-grid-load-queue carried its own FakeSocket, FakeFit and FakeTerminal,
near-copies of terminal-tile-input's. It now imports
test/mocks/terminal-tile-fakes.ts, which gains what only it used:
FakeSocket.drop(code), the terminal's scrollToLine / scrollToTop, and the
replay-pace extension (an opt-in `holdParse` that keeps write callbacks
from running, as on a disposed xterm, and empty writes left out of
`writes`, since the replay queues one only to hear it was parsed).

One definition serves both files with no per-file switch:
terminal-tile-input passes unchanged with the extension in place, the
shared fit resizes to the default 80x24 the tile already has, and
FakeSocket.OPEN is the real value. Every assertion is unchanged.
Mutation-checked through the shared fakes: dropping destroy()'s replay
settle fails the destroy-while-parsing case, a queue that runs two loads
at once fails eleven cases, and a tile that never registers its input
socket fails six in terminal-tile-input.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:40:19 +02:00
Codeman maintainer 8ce2e5acbb test(split): TerminalTile's socket, xterm and fit fakes live in test/mocks
terminal-tile-input defined FakeSocket, FakeFit and FakeTerminal inline;
they move unchanged to test/mocks/terminal-tile-fakes.ts so the grid's
load-queue test can drive a real TerminalTile on the same fakes instead of
its own near-copies. No assertion changed (a tile that never registers its
input socket still fails six cases).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:39:14 +02:00
Codeman maintainer 4bfe239083 docs(tiles): each SSE doc comment sits above its own function
_sseFilterSessionId() and its doc comment landed between
_updateSseSubscription()'s doc comment and that function, so two doc
blocks sat back to back and _updateSseSubscription had none. Each block is
now above its own function; the moved one's em dash became a colon.
Comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:39:07 +02:00
Codeman maintainer 9f9bdbb671 docs(tiles): rewrap removeTile's doc comment
f9709c3a added a clause to removeTile's doc comment without rewrapping it,
leaving one line far past the file's width. Comment only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer a07c663ca9 test(tiles): the grid harness finds a tile's element, and serves the pure helpers
Five tile-grid tests defined the same `tileEl(id)` lookup and three more
inlined it; the harness (test/mocks/tile-grid-vm.ts) now exports it and
they import it. tile-grid-open-set built a second vm context just to read
constants.js, although the harness it already imports has loaded the same
file: it reads windowStub.CodemanTileGrid instead.

No assertion changed. Mutation-checked: tiles without their
data-session-id fail 34 tests across the seven files, and a broken
tileGridOpenSet fails the open-set cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer 4b2e6a7c83 docs(split): comments say what TerminalTile does now
The rename from SplitTerminalPane carried comments over that the PR 1
changes made untrue:

- constants.js buildSplitPickerSessions said TerminalTile._sendResize has
  no detached check (it stands aside like the primary pane) and that Pane
  B sends no `seq` (its input rides the exactly-once queue). The
  conclusions stay: a detached session's window owns its PTY size, and a
  session with no PTY has a pane nothing feeds or reads.
- terminal-split.js said TerminalTile has no "dims unchanged" skip (it has
  _lastSentDims), and its @loadorder still ended at respawn-ui.js rather
  than tile-grid.js.
- terminal-tile.js still called every pane "Pane B", said a shell load
  lands in "a 50000-line xterm" (the scrollback is an option now) and
  that a TUI session always gets a full replay (with boundedLoad, grid
  tiles get the bounded window), and told some reasons as history ("an earlier draft", "used
  to", "It LOOKED intermittent"). Those now give the reason in the
  present tense, and the comments touched lose their em dashes.
  writeChunked's doc comment is left as it is: the perf work rewrites
  that function and owns its comment.

Comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer 17eea3c230 test(tiles): the connect() static guard's end anchor resolves again
terminal-tile-unit slices connect() out of terminal-tile.js up to
'async _loadBuffer()'. The load queue commit (fca7acd0) gave _loadBuffer a
`{ refresh }` parameter, so that anchor stopped matching, indexOf returned
-1 and the slice ran to the end of the file: every check in the test
passed against code outside connect(). The anchor is now
'async _loadBuffer(' and the test asserts both anchors resolve, so a rename
fails it instead of widening it (checked by putting the old anchor back).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer c101b70678 refactor(tiles): each tile's zoom state is painted by _syncTileZoom
_applyTileLayout carried a loop that set every tile's zoomed class and
its ⤢ button's pressed state and label. That loop is now _syncTileZoom,
beside _syncTileSlots and _syncTileDividers, which _applyTileLayout calls
the same way. Same order of writes, same last-English-label compare.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer b9ce850a2c refactor(tiles): the picker's position comes from its stylesheet alone
openTilePicker set `position: fixed` inline, which .tile-picker-menu
already declares; the inline copy (carried over from the split picker,
whose menu has the same rule) is gone. The top/right offsets under the
Tiles button stay inline, since they are measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer fbab0ffbf8 style(tiles): one rule for the grid's two accent buttons, one accent token
.tile-attach-btn and .tile-picker-open repeated the same seven
declarations and the same :disabled opacity; they now share one rule, and
each keeps only what differs (the picker's narrower padding, the
in-flight attach's progress cursor). .tile.focused read --accent-color, an
alias of --accent, while every other grid rule reads --accent; it reads
--accent too.

Computed styles of 22 grid elements (headless Chromium, the default,
daylight-blue and og skins) are identical before and after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:04 +02:00
Codeman maintainer e6eb3dd849 style(tiles): no CSS hides dividers and slots that a zoom never leaves
`.tile-grid--zoomed .tile-divider` and `.tile-grid--zoomed .tile-slot` hid
elements that do not exist while a tile is zoomed: _applyTileLayout toggles
the zoomed class in the same pass that syncs zero dividers and zero slots,
and it is the only place either is created. Both rules are gone.

The dividers test asserted the CSS rule; it now asserts what the user sees
(no divider elements while zoomed, two again on restore), and
tile-grid-entry-points gains the same check for the empty slot of three
tiles in a 2x2. Both fail if the zoom stops removing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer c54867d9f0 refactor(tiles): _tileGridLimit measures the terminal area itself
_tileGridCapacityNow had one caller, _tileGridLimit, and only measured the
area (the grid section, or the single view the grid would replace) for it.
The measurement now sits in _tileGridLimit, whose doc comment says what is
measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 0746cffe6d refactor(tiles): the layout helpers read the minimum tile size directly
computeTileLayout and tileGridCapacity took minTileW / minTileH, defaulted
to TILE_MIN_W / TILE_MIN_H, and no caller or test ever passed them. The
parameters are gone and both read the constants; the spec's signature line
says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 6cbaf3f7b6 refactor(tiles): Ctrl+Tab and Alt+[ / ] cycle tiles through one helper
nextSession and prevSession each carried the same grid branch (cycleTile
from the active session, a human selection, skip the tab walk). It is now
_cycleTileFocus(delta) in tile-grid.js, next to the other focus moves;
both call it optionally, so a page or harness without tile-grid.js walks
the tabs as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer be7328c8eb refactor(tiles): the picker's Open and "Open group as tiles" share _replaceTileGrid
Both put a new set of sessions on the grid in place of the open one: choose
the focus (the session in focus if the set holds it, else the first), close
an open grid forgotten, drop activeSessionId so re-parking snapshots nothing,
then open. They now call one _replaceTileGrid(ids), which carries the
reason for the order once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 1202e9baa2 fix(tiles): "Open group as tiles" keeps the focused session focused
With the grid already open, openGroupAsTiles closed it (which sets
activeSessionId to null, so re-parking does not snapshot the parked
terminal) and only then chose the focus, so the active session was never
"in the group" and the group's first session always took focus. The
picker's Open chooses before closing. Now both do: the focused session
keeps focus when the group holds it, otherwise the group's first session
gets it.

tile-grid-entry-points covers both cases, with the grid open and closed;
the open-grid case failed before this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer a24b548389 refactor(tiles): one helper drops a tile's queued loads and destroys it
closeTileGrid, removeTile, dropSessionOnTile and _remountTile each spelled
out `grid.queue?.drop(tile); tile.destroy()`. They now call
_destroyTerminalTile(tile), which keeps that order (the waiting loads are
resolved and the loading state cleared before the tile goes) and says why
once. The tile's element stays the caller's to remove.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 6af38abc76 refactor(tiles): removeTile has no auto option, its refocus is always the app's
removeTile took `auto` for the neighbour it focuses, but no caller ever
passed anything but true: a tile leaving is never a human picking its
neighbour. The option is gone (the refocus passes `auto: true` itself, and
the doc comment says so), and the four call sites that spelled out the
defaults (the header's ×, remove-tile, a stopped socket, a popped-out
session) are plain removeTile(id). `refocus: false` callers are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 55b526bc1e docs(tiles): comments say 6 tiles and name the zoom button
The grid holds at most TILE_GRID_MAX (6) tiles, but the file header of
tile-grid.js, the comment over its section in index.html and the grid
block in styles.css still said 1 to 9. The tile header descriptions in
_buildTileHeader, .tile-header and the chrome test listed `⋯ ×` without
the zoom button, and the glyph-size rule still spoke of four glyphs and
the plus that owner decision 9 removed. Comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer 4558536676 refactor(tiles): the picker's outside-click close loses its left-click leftovers
The Tiles picker opens on right-click now. Three pieces only served the
left-click picker: stopPropagation on the opening event, the tick of delay
before the outside-click listener went in (so the opening click could not
close it), and the exception for clicks on the Tiles button. A right-click
fires no click event, and a left click on the button runs toggleTileGrid,
which closes the picker before the click reaches the document. The
preventDefault that keeps the browser menu away stays.

The outside-click close had no test: tile-grid-open-set now checks that a
click inside the picker leaves it open and one elsewhere closes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer b965c3d346 refactor(split): a tile's Ctrl+C copies through the primary pane's copy helpers
TerminalTile carried its own copy of the smart-copy branch (clean the
selection with this session's gutter, copy, clear, toast). PR 1 gave
cleanedTerminalSelection and copyTerminalSelection a `{ terminal, sessionId }`
target for exactly this, and nothing passed it. The tile now calls both with
its own terminal and session, and its copy code is gone.

Two things change for a tile, both to the primary pane's rule: a clipboard
write that fails keeps the selection (nothing was copied, so it stays for a
retry) instead of clearing it, and focus returns to the tile's xterm after
the copy, which matters when the execCommand fallback focused a temporary
textarea. Pinned in terminal-tile-input with the write failing and
succeeding, and the no-selection Ctrl+C / Ctrl+Shift+C split.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:37:03 +02:00
Codeman maintainer ec5e4aa3e1 perf(tiles): no snapshot of the session the grid parks when it becomes a tile
Opening the grid runs _cleanupPreviousSession() once to park the main
terminal, and for a non-shell session that serialized the terminal
(1000 lines of scrollback) into the snapshot cache and up to 256 KB of
localStorage. Closing the grid drops the main terminal's snapshot of
every tiled id (stale by then), so when the parked session is itself a
tile, which it is unless the grid opens on a set without it, that copy
was always thrown away. openTileGrid now passes skipSnapshot in exactly
that case; a parked session that stays out of the grid (Open group as
tiles from another session) keeps its snapshot as before.

Measured: the same serialize on the main terminal's buffer costs 32 to
42 ms per grid open (n=6, 35 KB) plus the localStorage write; shells
never took one, so the A/B runs (shells) show no difference. Snapshot
serializes per grid open with a non-shell session active and tiled:
1 -> 0.

Tests: the grid passes skipSnapshot only when the parked session is
tiled; the real _cleanupPreviousSession skips the serialize only when
asked; mutation-checked both ways.

Scope: PR 2 (tile-grid.js, the app.js seam).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 08:57:42 +02:00
Codeman maintainer 7990249e2d docs(tiles): the replay pace, one refit per resize, the SSE filter, the line bound
The invariants for this performance pass: a tile's replay holds the load
queue only while xterm parses it, and destroy() settles a replay in
progress; the main terminal's resize timer refits the split's Pane B
only, leaving grid tiles to the grid's observer; the page's SSE filter
names TILE_GRID_SSE_FILTER while tiles own the terminal; grid tiles send
lines= on their full captures. Plus an "As built" note in the spec, whose
parking section still says the subscription stays [activeSessionId].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 08:46:08 +02:00
Codeman maintainer 6d72b38db4 perf(capture): a grid tile's full capture reads no more history than it keeps
GET /api/sessions/:id/terminal?full=1 captured the whole tmux history
(capture-pane -S -<history limit>, 100,000 lines by default) and cut it to
`tail` only afterwards, all of it synchronous on the server's event loop.
A grid tile keeps TILE_SCROLLBACK lines plus its screen, so the rest was
captured to be thrown away, once per tile on every grid open, restore and
deploy reconnect. The route now takes an optional `lines=<n>` (an integer
of at least 1, clamped to the configured history limit) and passes it as
the capture's history bound (the existing historyLimitLines, so -S -<n>);
absent or malformed, the limit itself, so every existing caller gets the
same capture as before. Only full captures read it: the visible-frame path
(a shell tile's `tail=` load) reads no history and is untouched. The
capture still ends with its RELATIVE cursor move back to the caret, still
counts as a full capture (isFullCapture: the line-deleting transforms stay
off) and still reports captureCols/captureRows.

Grid tiles (boundedLoad) send lines=<scrollback + rows> on every full
capture of theirs: a TUI load and a shell history pull. The split's Pane B
asks for everything, as before.

Measured:
- A real haiku Claude pane on tileperf (about 3k lines of history):
  bounded captures (lines=50, 500, 2000, 100000) against the unbounded
  one, 4 PASS 0 FAIL: each a line-aligned suffix of it, ending in the
  same relative cursor move (ESC[4A CR ESC[2C), same source
  (mux-full-history) and capture geometry. Capture time there 72 ms both
  ways, that history being shorter than the tile's bound. As a grid tile
  (it sent lines=10047) its screen matched the pane row for row, 47 of
  47 at the pane's own 77x47, caret on the composer.
- Six tiles restoring with Claude-style loads (full=1&tail=1MiB forced
  on shells with about 19k lines of tmux history each, above the tile's
  bound; n=3+3 interleaved, load 4.2 to 7.2): capture per tile med
  219 ms [194 to 294] -> 155 ms [127 to 211]; server event-loop delay in
  the capture window, max med 262 -> 201 ms; all painted 4.5 -> 3.6 s.
  At checkpoint 1 a 30k-line history cost 713 ms per capture (event loop
  blocked up to 765 ms each); the bound caps that at the tile's size.

Tests: the route passes lines= through, clamps it, ignores every malformed
form and leaves the visible-frame capture exactly as it was; a bounded
capture keeps its rows and ends in the cursor restore; grid tiles send it
on full captures and the split's Pane B does not. Mutation-checked six ways
(lines ignored, no clamp, a lenient parse, lines on the visible path, the
tile sending none, Pane B sending it). Documented in docs/api-reference.md
(/api/v1 is public).

Scope: PR 2 (the grid's loads; server route plus terminal-tile.js).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 07:44:56 +02:00
Codeman maintainer 50e0d22def perf(tiles): no SSE terminal stream for the focused tile while tiles own the terminal
With the grid open the page's SSE filter still named the focused tile's
session, so the server streamed that session's output over SSE as well.
The main terminal is parked (its socket closed, _wsReady false), so every
frame was JSON.parsed and then dropped by the park guard; the tile has the
same output over its own socket. The filter now names TILE_GRID_SSE_FILTER
(constants.js, a fixed id no session takes) while tiles own the terminal,
at both places that set it: the live re-subscribe every tile focus runs
(_updateSseSubscription) and the connect URL an SSE reconnect rebuilds
(connectSSE), through one helper, _sseFilterSessionId(). Leaving the grid
gives the filter back to the session shown (selectSession re-subscribes).

Why it is safe, server side: the filter is read in exactly one place,
SseStreamManager.flushSessionTerminalBatch. The connect route parses it,
POST /api/events/subscribe replaces it (updateClientFilter); broadcast(),
the multi-user ownership check (canDeliver), the heartbeat, the order and
tab-layout frames and the shutdown notice never read it, and no push,
viewing or acknowledgement logic does. The only page consumer of
session:terminal is _onSSETerminal -> _onSessionTerminal, a no-op while
tiles own the terminal.

Measured at checkpoint 1 (6 tiles, focused tile a printing shell): 16 to
18 frames/s, 2.2 to 2.4 KB/s parsed and dropped -> 0.

Live, this code (6 tiles, shells printing), SSE terminal frames per 5 s:
0 with the grid open; 0 after an SSE reconnect with the grid open (connect
URL sessions=tile-grid); 86 in the single view after closing the grid and
86 after a reload into it (that connect URL names no session, as before;
selectSession's re-subscribe names the shown one). Just before this commit:
18 frames/s, 2.4 KB/s. A tile focus runs no connectSSE and no handleInit;
it posts the grid id.

Tests: the page subscribes with the grid id on open and on every tile
focus, gives the session back on close and on a reload into the single
view, and connectSSE asks the same helper. Server, live, multi-user: the
id is taken on the connect query and on a re-subscribe, and then withholds
terminal output while session:updated and hook events still reach their
owner (and only their owner). Mutation-checked five ways (helper ignoring
the grid, connectSSE on the raw id, the server dropping non-UUID ids on
subscribe and on connect, the filter gating every event).

Scope: PR 2 (constants.js, the app.js SSE seam).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 07:11:08 +02:00
Codeman maintainer d7f6047529 fix(tiles): a tile focus no longer leaves a glow listener on its tab
_selectTiledSession added a once animationend listener to the focused
tile's tab on every focus. On every skin but OG the glow is `animation:
none`, so animationend never fires: the listeners piled up on the tab (and
the class stayed). It now glows a tab only when it is not glowing already,
so a tab holds at most one; on OG the animation ends, the class goes and
the next focus glows again.

Measured (50 tile focus changes, daylight-blue): animationend listeners on
the tabs 0 -> 49 before, 0 -> 6 (one per tab) after. Over the leak run's
20 grid open/close cycles the page's listener count grew 1003 -> 1042;
this is the part CDP could attribute.

Live, this code: 50 tile focus changes leave 6 animationend listeners on
the tabs, one per tab.

The single view's copy of the same block (app.js selectSession) has the
same leak on every non-OG skin; it is a separate, pre-existing copy and is
left alone here.

Test: ten focus changes leave one listener; after animationend the next
focus glows again; mutation-checked.

Scope: PR 2 (tile-grid.js).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 06:57:54 +02:00
Codeman maintainer 02387b5b16 perf(i18n): an xterm row record is decided by its rows container, once
xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
a pane changes, and with a grid of tiles that is every record the i18n
observer gets (about 4,600 a second for six printing tiles, all of them
row rewrites). Each paid one closest() over the whole skip selector list
(the per-record skip of 21beacf7, which stays). Every row of one terminal
shares its parent, so that parent's own shouldSkip() verdict is now kept
once it says skip: same verdict, one closest() per terminal instead of one
per record. A rows container outside any skipped surface keeps the full
check, so nothing that was translated stops being translated.

Measured (6 printing tiles, 30 s profiles, n=3 interleaved A/B, load 5.8
to 9.5, equivalent class-check variant): observer 391 to 445 ms -> 47 to
52 ms per 30 s in English, 473 -> 58 ms in zh-CN; main-thread script time
-0.35 s per 30 s (-14%). Frame share unchanged within noise.

Live, this code (6 printing tiles, 30 s profiles, interleaved against the
file at 00440c02, load 4.4 to 6.6): observer 417 to 430 ms -> 56 to 58 ms
per 30 s; script time 2.63 to 2.79 s -> 2.24 s in the undisturbed window.
The other window of this code was disturbed by CPU contention on the box
(every rendering cost 3 to 4 times higher, xterm's own included, 351 frames
in 30 s) and is not counted; its observer time was 56 ms all the same.

Test: 60 row rewrites in a terminal cost one closest(), the rows stay
untranslated, and a stray `.xterm-rows` outside any skip surface is still
translated (the verdict closest() gives); mutation-checked both ways.

Scope: PR 2 (i18n, on top of 21beacf7).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 06:45:16 +02:00
Codeman maintainer 409fd658f2 perf(resize): a window resize fits each grid tile once, not twice
A window resize reached every grid tile twice: the grid's own
ResizeObserver refits them (tile-grid.js _scheduleTileGridRefit, 150 ms
trailing), then the main terminal's trailing resize timer (terminal-ui.js
throttledResize, 300 ms) ran _forEachTile(fit) over them again. The second
pass re-measured six panes and sent nothing (_lastSentDims dedupes the PTY
side). The timer now refits the split's Pane B only ({ grid: false }); grid
tiles exist only while the grid owns the terminal, and then its observer
already covers them.

Measured (6 tiles, 20-step window resize and back, headless, tileperf):
fit() 12 -> 6 per resize burst; PTY resizes 6 -> 6; browser layouts
unchanged within noise (85/75 -> 92/71), so this removes wasted calls only.

Live, this code (6 tiles, tileperf): a 20-step window resize, and the resize
back, each ran fit() 6 times and sent 6 PTY resizes (12 and 6 before).

Test: the timer's one _forEachTile call passes { grid: false } (it lives
inside initTerminal, so read from source like the #464 geometry tests);
mutation-checked.

Scope: PR 2. The line is in terminal-ui.js (a PR 1 seam), but on PR 1 alone
_forEachTile reaches only the split's Pane B: the double refit needs the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 06:30:09 +02:00
Codeman maintainer 5650be5200 perf(tiles): replay a capture at xterm's own pace, not one slice a frame
A tile's replay (writeChunked) wrote its capture 32 KB per animation
frame, so a 1 MiB load took about a second of frames, and in the grid the
load queue's slot was held across all of it: tile N+1's capture waited for
tile N's last frame. xterm 6 already parses its write queue in 12 ms
slices and yields between them, so the slices now all go in at once (up
to a 1 MiB window, since xterm's queue throws past 50 MB and Pane B's
unbounded full=1 capture can reach the server's 32 MB) and the replay
resolves on the callback of an empty write queued behind them, i.e. once
xterm has parsed the last slice. The single-flight flag is still held for
the whole replay. A disposed xterm never runs that callback, so destroy()
now settles a replay in progress: a removed tile can no longer hold its
flag or the grid's one load queue. Queued up front, the capture also stays
in one piece during a refresh: live output written meanwhile lands after
it, not between two of its slices.

Measured (tileperf, 6 printing shells with 1 MiB histories, headless,
n=3 interleaved A/B against the starting file, load 8.6 to 11.8):
- grid fresh open, 6 tiles, all painted: 5.10 s -> 2.57 s (-50%);
  restore after reload: 6.49 s -> 3.86 s (-41%); per-tile replay
  669 to 734 ms -> 298 to 321 ms (median).
- Same work in half the time: frames over 20 ms 26% -> 40% of the
  (shorter) load window, about 86 -> 62 slow frames in all; longest long
  task on restore 304 -> 227 ms; server event-loop delay unchanged
  (max 111 to 122 -> 122 to 134 ms, one capture in flight throughout).
- Split Pane B (the other TerminalTile) with the main terminal on WebGL
  and its long-task guard armed: load 1.6 to 3.8 s -> 0.8 to 1.7 s over
  15 loads each; 0 long tasks of 200 ms or more either way, the guard
  never tripped. With an unbounded full=1 capture (about 21k lines):
  2.5 to 3.4 s -> 1.9 to 2.6 s, 0 long tasks of 200 ms or more.
- At checkpoint 1 (equivalent patch, n=3 to 6): fresh 6.4 -> 2.7 s,
  restore 8.9 -> 4.2 s, TUI-style reconnect 11.2 to 11.8 -> 6.2 s.

Tests: the replay queues every slice at once and holds the flag until
xterm has parsed it; a replay larger than the window goes one window at a
time; a pane destroyed mid-parse settles at once; in the grid, a tile
destroyed while xterm still parses its replay releases the queue and the
next tile loads (fake xterm whose callbacks never run). The rAF-driven
tests now hold the parse callbacks instead. All mutation-checked (no
settle in destroy, settle before the parse, no window). Browser
split-pane-terminal: same 1 failed / 2 passed as at the starting HEAD
(the failure is in the test's own setup, before connect).

Scope: PR 1 (terminal-tile.js writeChunked and destroy(); Pane B replays
the same way). Moving it onto PR 1 needs its two call sites adapted
(PR 1 has no _runLoad yet) and leaves the tile-grid-load-queue.test.ts
hunk with PR 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 06:00:41 +02:00
Codeman maintainer 00440c02e1 docs(tiles): no + in the tile header, owner decision 9
The spec records decision 9 and an as-built entry replacing the "+ / New
session in this case" one, and marks the target picture, the header line
and the + bullet as built without it. CLAUDE.md's header list, the
invariants' z-index line (the + menu's layer) and the wiki's Tile Grid
page (the header string, its table and the cap sentence) drop the +.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:49:12 +02:00
Codeman maintainer caf5248e22 feat(tiles): no + in the tile header (owner decision 9)
Owner: "remove the + button from these views". The tile header is now
● name ... ⋯ ⤢ ×. Gone with it, because nothing else used them: the +
menu (openTileAddMenu, closeTileAddMenu and their hooks in closeTileGrid
and the global Escape handler), its "New session in this case" entry and
runInCaseForTiles, the .tile-add-empty rules, the four i18n entries only
the menu showed, and buildTilePickerSessions' exclude argument (only the
menu passed it).

Every other way of adding tiles stays and needed nothing from the menu:
the Tiles button and its right-click picker, Ctrl/Cmd+click on a tab,
dragging a tab onto a tile or an empty slot, "Open group as tiles", and
Run joining the open grid (_joinTileGridFromRun). The picker list, the
cap helper _tileGridLimit and the user-text skip on names are shared and
kept.

Tests: the + menu cases (picker, cap, auto-join, the zh-CN harvest) are
removed; tile-grid-chrome pins the header as exactly ⋯ ⤢ × with no add
menu or runInCaseForTiles left on the app.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:48:40 +02:00
Codeman maintainer 5da25f782b feat(i18n): the Run button family, and the Help modal and shortcut overlay leftovers, in Chinese
Owner request: translate "Run SH" and the rest of the Run family, plus
the two leftovers from the last report.

Run: one pattern turns "Run <code>" (Run CC, Run SH, Run OC, Run CX ...,
and any registry CLI's shortBadge) into "运行 <code>"; the mode codes and
product names stay, and exact entries still win ("Run Shell" was already
运行 Shell, "Run OMP" 运行 OMP, "Run PI" takes the "Run Pi" entry). New
entries: "Terminal / Shell" (the run menu's shell item) and "Send Enter"
(the phone toolbar's Enter button title). The phone overview's Run button
already showed 运行 beside a mode word kept as typed.

Help modal and shortcut overlay: "Tabs", "Toggle Session Sidebar", both
"Copy Selection" rows, "Focus Tabs", and "Wheel" (滚轮, a mouse input like
Click). Key names stay English: the Help modal's Home key, and every key
the overlay renders, now carry data-i18n-skip, because "Home" is also a
dictionary word (the Home button) and showed as 主页 in the key column.

The invariants' paneExit section gains the badge's translation rule
(from the previous commit): its updates compare with the remembered
English, never the DOM.

Tests: i18n-exit-run-help covers every Run label _applyRunMode can show
(its hard-coded ones and Run <shortBadge> for every stock CLI), the
toolbar titles, the Help modal through the real translator in JSDOM (no
English outside the key column, the Home key kept while the word Home
elsewhere still translates, Wheel translated), every shortcut registry
group and label, and that the overlay's keys are skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:11:01 +02:00
Codeman maintainer 66da91f78b feat(i18n): the tab's exited-agent badge in Chinese (zh-CN)
Owner request: translate the EXITED badge. The badge carried
data-i18n-skip on purpose, because its in-place update compared the DOM
with the English label: a translated badge would never have matched, and
every incremental tab pass would have written English back for the
translator to redo. The tab's accessible name (which carries the exit,
the badge being aria-hidden) was set unconditionally on every pass, the
same trap once it is translated.

Now the badge is left to the translator. applyPaneExitBadge remembers the
last English badge text (data-label) and accessible name
(data-aria-source), both also seeded by the full render, and compares
with those, never the DOM. The tab strip's incremental path updates tabs
in place (no row-HTML comparison), so nothing else re-renders on a
translated badge.

i18n.js: "exited" -> 已退出, patterns for "exited (N)" -> 已退出(N) and
"exited (signal N)" -> 已退出(信号 N), and for the accessible name
"<name> session, agent exited ..." -> "<name> 会话,智能体已退出 ...", the
session name passed through untranslated. The header strip, the session
sidebar and the vertical rail all host the same tab markup, so this
covers all three. English reads exactly as before.

Tests: session-pane-exit-ui pins the new markup, the remembered English
and that a translated badge and accessible name survive an unchanged
pass; i18n-exit-run-help runs every paneExitLabel form (and the
accessible name, with names that are dictionary words) through the real
translator in both languages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:07:27 +02:00
Codeman maintainer dfb9f32e23 fix(tiles): a tile's + menu items get their disabled reason in Chinese
Found live in zh-CN: the + menu's session items stayed titled "The grid
holds at most 6 tiles". Each item carried data-i18n-skip on the whole
button to keep the session name as typed, and the translator skips an
element's attributes along with its text. Only the name is skipped now (a
child span, as the picker does), so the title is translated.

The zh-CN coverage test classified a title inside a skipped subtree as
user text, which is how this got past it; such a label is now a failure
of its own ("no UI label sits inside a skipped subtree").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:20:22 +02:00
Codeman maintainer c848e7cf27 feat(i18n): the tile grid in Chinese (zh-CN)
Owner request: with App Settings language set to 简体中文, the grid reads
fully in Chinese. Every string the grid puts on screen gets its own
ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles
button (both states, with the right-click hint), the Tiles and Split
chips, the grid region, the Help modal's Tiles rows, the shortcut
registry's Tiles group and labels (overlay and App Settings list, and
"not bound"), "Open group as tiles", the picker, a tile's +, the header
buttons, the Attach overlay (not attached, attaching, exited, ended, the
hint), the empty slot, the dividers, the Split button while tiles are
open, and the toasts. Strings with a count, an exit code or a duration
are translateDynamic patterns: the cap texts (both wordings, with and
without ": the new session opens on its own"), "This window fits N
tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the
crash-restart confirm (the existing confirm wrapper runs it through t();
the session name passes through untranslated, in the single view too),
and the tile header tooltip ("idle 3m"), which requires the duration so
bare state words stay out of the table (they collide with other
surfaces, see mobile-overview.js).

Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体,
案例, as the table already has them. Key names stay; Click, Right-click
(mouse actions) and Arrows in the Help modal's key column are translated.
English reads exactly as before (only additions to the table).

test/tile-grid-i18n.test.ts drives the real tile code through every
state that writes text, harvests each string and requires Chinese with
no Latin word left beyond key names and durations, and the same English
in en; plus the markup through the real translator in JSDOM, and session
and group names (also when they equal a UI word) staying untranslated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:15:43 +02:00
Codeman maintainer 6c5b4a7a25 fix(tiles): a translated tile label is not rewritten on every refresh
Three refreshes compared the DOM with the English source: the tile
header's tooltip, the Attach overlay's text and the zoom button's title.
With App Settings language set to 简体中文 the i18n observer writes the
translation into the DOM, so the comparison never matched again and every
chrome refresh (each session:updated, several a second with busy tiles)
wrote the English back for the observer to translate once more. Each now
remembers the last English value on the tile entry and compares with that.
English mode behaves exactly as before.

Also: tileShortcutFor's comment still called the inert chord a default
pending the owner's answer; it is owner decision 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:09:35 +02:00
Codeman maintainer be04d3e5e0 docs(tiles): the wiki's Ctrl/Cmd+click entry says what opens with the grid closed
With the grid closed, Ctrl/Cmd+click on a tab opens what the Tiles button
would show (decision 8) plus that session; the wiki only said it opens
the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 00:10:38 +02:00
Codeman maintainer dd01ea9927 fix(settings): App Settings search finds Split and Tiles by what they do
Owner feedback: the Header buttons group's keywords named neither Split
nor Tiles. Both are now in the group's data-search. The filter
(_filterSettings) matches each chip by its own data-search and its label,
and never by the wrapper's keywords (which would light up every header
chip for "split"), so the two chips also get their own: "tiles tile grid
side by side several sessions" and "split pane side by side two
sessions". Before, only the label words matched; "tile grid" found
nothing. Pinned by running the real filter over the real markup (JSDOM).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:28:47 +02:00
Codeman maintainer 39d87e363f style(tiles): bigger tile header buttons, the app header's icon size
Owner feedback: the tile header's ⋯ ⤢ + × read as tiny next to the
session name. The buttons inherited the header's 12px font. They are now
26px click targets (min-width, so a wider glyph still fits) with a 16px
glyph, the same as the app header's own icon buttons (.btn-icon-header);
the thin ellipsis and cross get 19px so all four read at one visual
size. The header grows from 24 to 28px to hold them, the inline rename
input to 22px. Checked live at DSF 1 on a dark (daylight-blue) and a
light (paper-gray) skin, focused and unfocused tiles, and a zoomed tile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:27:08 +02:00
Codeman maintainer b7fafb1c16 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>
2026-10-06 23:23:55 +02:00
Codeman maintainer d2e72143e8 feat(tiles): the grid holds at most 6 tiles (owner decision 7)
Six was tested smooth on the owner's desktop; nine missed the headless
frame bar and is untested on real hardware. TILE_GRID_MAX (constants.js)
is now 6 and stays the one cap every limit reads; the layout table gets
its own bound, TILE_LAYOUT_MAX = 9, so the 7 to 9 layouts keep working
(unreachable) and going back to nine is that one line.

Every way in stops at the cap: opening, addTile, a tile's +, a session
Run makes, Ctrl/Cmd+click, the picker, "Open group as tiles", and a stored
grid with more ids (it comes back as its first six, focus kept only if it
survives, a dropped zoom cleared, row fractions that no longer match the
3x2 reset). The limits now go through one helper, _tileGridLimit(), whose
texts say which limit binds: "Up to 6 tiles" / "The grid holds at most 6
tiles" when it is the cap, "This window fits N" when it is the window.

Docs: decision 7 and an as-built entry in the spec, CLAUDE.md, the
invariants, the wiki's Tile Grid and Dashboard pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:18:29 +02:00
Codeman maintainer 21beacf700 perf(i18n): skip a whole mutation inside a skipped surface, not each node
xterm's DOM renderer replaces terminal rows every frame (the split pane's
Pane B, every tile of the grid), and the translator's MutationObserver
walked each added row and ran closest(SKIP_SELECTOR) for every text node
and element in it, only to find each one inside .xterm and skip it. A CPU
profile of six printing tiles put about 2.2 s of 40 s there (closest,
translateNode, tree walks).

Now one shouldSkip(mutation.target) per record decides it: every node a
record adds or edits sits under that target, so both translators would
return on their own closest() check anyway, and the output is identical.

Measured in headless Chromium, six tiles printing 20 lines/s each, three
interleaved 40 s pairs at the same machine load: frames over 20 ms fell
from 9.1/11.5/11.8% to 7.3/8.1/8.0%; nine tiles 9.5% to 7.2%. Pinned in
i18n-branding.test: a burst of terminal rows causes no tree walk, terminal
text stays untranslated, application DOM beside it still translates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:29:36 +02:00
Codeman maintainer 0d1b91188d docs(tiles): Ctrl+Shift+G inert while the Tiles setting is off is the owner's decision
The spec listed it as the default applied while the owner's answer was
pending. The owner has decided: with showTileGridButton off the chord is
inert and passes through like any unbound key; on, it toggles the grid.
Recorded as decision 6 in the spec (with a line under Gating), and as an
owner decision in CLAUDE.md and the invariants.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:47:13 +02:00
Codeman maintainer fa9d5879b3 fix(tiles): a tile that joined before its pane existed resends its size
A session Run makes while the grid is open joins as a tile right away, so
the tile connects and sends its size before Run starts the pane. The
server only records a resize for a session with no PTY and spawns the pane
at 120x40, and Run's own resize step measures the parked main terminal
(display: none, so nothing). Measured live for Shell and Claude: a 97x17
tile over a 120x40 pane, for good (#464).

The chrome refresh now remembers each tile's last-seen pid and calls
TerminalTile.paneStarted() when it appears or changes. paneStarted()
forgets the sent size and sends it; a hidden tile (a zoomed neighbour)
sends nothing and keeps it forgotten, so its next fit() sends it, which a
plain fit({ force: true }) would lose. Keyed on the sessions map, so a
handleInit after an SSE drop counts too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 19:46:04 +02:00
Codeman maintainer f1e5b82ecc docs(tiles): the tile grid in CLAUDE.md, the invariants, the wiki and the Help modal
- CLAUDE.md: a Tile grid paragraph beside the split-pane one (parking, the
  one load queue, the selection and close rules, chords, dividers, auto-join,
  the exited-agent case), tile-grid.js (7.6) in the load order, the
  desktop-gated header markers and the Tiles picker in the z-index stack.
- docs/architecture-invariants.md#tile-grid: the mechanisms and the reason
  behind each rule; the split section now says where a waiting grid load
  differs and that every capture carries a deadline.
- docs/wiki/Tile-Grid.md: the user manual page (turning it on, the ways in, a
  tile's header, keys, leaving, persistence, Split), linked from the sidebar,
  The Dashboard, Keyboard Shortcuts and Settings Reference.
- The Help modal lists the tile chords (pinned in help-modal-shortcuts.test).
- docs/tile-grid-plan.md: status updated, and an "as built" list of where PR 2
  went another way than the spec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 19:34:25 +02:00
Codeman maintainer c206d10e3a feat(tiles): sessions Run from this tab join the open grid; + offers a new session in the tile's case
Every Run path makes each session it created visible through
_ensureCreatedSessionVisible and then selects the first one, a human
selection that used to leave the grid for the single view. That helper now
hands the new session to _joinTileGridFromRun: with the grid open it joins the
next free slot, so Run's selection focuses its tile. No Attach overlay flashes
on it while Run starts its pane. Sessions created elsewhere (agents, other
devices, cron) arrive only by session:created and never join; a grid already
holding what the window fits does not take it, and a hint says the new
session opens on its own.

A tile's + adds "New session in this case": the normal Run (current run mode)
for the case the tile's session belongs to, with the toolbar's case put back
afterwards; the session it creates joins the grid like any Run from this tab.
Disabled for a session outside every case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 19:31:16 +02:00
Codeman maintainer 5a58d272ea feat(tiles): the per-device Tiles setting, and Ctrl+Shift+G follows it
showTileGridButton gets the full per-device treatment Split has: a header
chip in App Settings beside Split, its load and save lines, OFF by default
(and in the handheld defaults), a member of the displayKeys merge policy,
stripped from the settings PUT and never declared in the .strict()
SettingsUpdateSchema (sending it would 400 the whole save).

The setting also gates the Ctrl+Shift+G toggle (the applied default while the
owner's answer is pending; one line in tileShortcutFor to change): OFF, the
chord is inert and reaches the terminal like any unbound key; ON, it opens and
closes the grid where one can open. A grid that is open however it was opened
(Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords,
the toggle that closes it included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 19:28:44 +02:00
Codeman maintainer 6bb16fdad6 feat(tiles): the grid survives a reload (per device, ids only)
The grid is stored in localStorage `codeman:tile-grid` as
{ v: 1, open, ids, focused, zoomed, colFr, rowFr }: ids, focus, a zoom the
user chose (an automatic one is worked out again from the window) and the
divider fractions, never content. It is written as it changes (layout, focus,
zoom, divider drags); closing the grid keeps it remembered as open: false for
one-click return, and the last tile leaving forgets it. That stored state is
now the only "remembered" grid, so the Tiles toggle, the picker's preselection
and Ctrl/Cmd+click all bring back the grid this device last had, across
reloads. Never written or read in a solo window.

The restore runs INSIDE handleInit, in place of its single-view
selectSession(restoreId, { auto: true }), so with a stored open grid the main
terminal never loads on that page load (its first select would pull a
whole-history capture only to be parked). The stored ids are sanitized against
the session list (deleted, detached and duplicate ids dropped, anything that
is not a v1 object ignored), and the fractions and zoom go back on. A window
too narrow for the grid keeps the single view and the stored grid waits; a
#session= link on load wins and leaves the grid remembered but closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 19:26:58 +02:00
Codeman maintainer d331db1141 fix(tiles): Attach reads the response envelope; an exited agent gets no Attach
Found live: the attach and shell routes report a refusal in the envelope of
a 200 ({success: false}), and Attach read only res.ok, so a refused attach
remounted the tile as if it had worked. It now reads the envelope.

The refusal in question: an agent that exited in a live pane (paneExit, e.g.
a shell ended with `exit 3`) still has the pane's tmux client running, so
both routes refuse to start anything ("Session already has a running
process"), and the single view has no restart for it either. Its tile now
shows the exit with a pointer to Close session instead of an Attach button
that cannot work. A session with no PTY attached, or one whose socket closed
because it exited (4009), still gets Attach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 18:09:18 +02:00
Codeman maintainer dbff114dda feat(tiles): drag a tab onto a tile, Ctrl/Cmd+click a tab, "Open group as tiles"
Three more ways into the grid:

- Drag a session tab from the strip onto a tile: a session not yet tiled
  replaces that tile in place (the replaced session keeps running); one
  already tiled swaps places with it. A layout that is not full (3 tiles in a
  2x2, 5 in a 3x2) shows its empty cells as slots, and a tab dropped on one
  joins the grid there. Tiles and slots handle the drag in the capture phase
  and stop it, because its payload is the session id as text and xterm's
  helper textarea would type it into the PTY; a drag that is not a tab (a
  file) is left alone. The dropped session takes focus (a human selection).
  A sorted or grouped rail does not offer tab dragging, so neither does this.
- Ctrl/Cmd+click on a tab puts that session in the grid and focuses it,
  opening the grid on what the Tiles toggle would bring back if it was
  closed; on a window too narrow for the grid it stays an ordinary click.
- "Open group as tiles" in the grouped rail's group menu makes the group's
  live sessions (as many as the window fits) the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 18:02:55 +02:00
Codeman maintainer 65e8271fbe feat(tiles): draggable column and row dividers
The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.

Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:59:16 +02:00
Codeman maintainer b4618bb853 feat(tiles): Tiles header button with a session picker, and a tile's + menu
A Tiles button beside Split in the header, opt-in through the per-device
showTileGridButton setting (read in applyHeaderVisibilitySettings; the
settings checkbox, displayKeys membership and schema exclusion follow with
persistence) and hard-gated like Split: hidden by its --hidden marker, a JS
width check with a live media listener, a @media (max-width: 1179px) backstop
and never in a solo window. While the grid is open the button closes it and
reads as pressed.

Closed, it opens a picker: a checkbox per open session in tab order (never one
popped out to its own window; one with no PTY is offered, its tile shows the
Attach overlay), names as text, preselected with the grid this tab last left,
else the active session and an open split's two. Boxes past what the window
can fit are disabled with the count shown, and Open opens the grid on the
checked sessions, focusing the active one if checked. Escape and an outside
click close it; its close method is idempotent and the global Escape handler
calls it.

A tile's + lists the open sessions not yet tiled; picking one adds it and
focuses it (a human selection). A grid that already holds what the window fits
disables the entries. "New session in this case" waits for auto-join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:55:22 +02:00
Codeman maintainer de1b48a63f feat(tiles): Attach overlay for a tile whose session is not attached or has exited
A tile has no live terminal when its session has no PTY attached (pid null,
e.g. restored after a server restart), when the agent exited in a live pane
(paneExit), or when the server closed the tile's socket because the session
exited (4009, which used to leave only the "session ended" marker). Its body
now says which, with an Attach button, in an overlay laid over the terminal
so the body and its xterm keep their size.

Attach is the single view's own re-attach: POST /interactive (or /shell for a
shell) with NO body, at most one in flight per session, since the route has
no in-flight guard of its own. A tripped PTY-exit breaker goes through the
same confirm before clearBreaker: true, and nothing automatic ever sends it.
On success the tile is remounted onto the new pane (a socket stopped for good
cannot reconnect), keeping the keyboard if it had it; the overlay stays away
while the server catches up, and a failed attach says so and keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:51:47 +02:00
Codeman maintainer 6fedbd1b09 feat(tiles): zoom a tile (button, Alt+Shift+Enter), and auto-zoom when the window is too small
⤢ in a tile's header, or Alt+Shift+Enter (registry entry zoom-tile, applies
only while the grid is open and is swallowed before the Shift+Enter newline
gate), makes that tile fill the grid like tmux zoom. The other tiles stay
connected but hidden, so they measure nothing and send no resize; pressing it
again restores the grid and refits every tile, since the hidden ones have a
stale size. Zooming a tile that is not focused focuses it first (a human
selection).

As in tmux, moving focus to another tile restores the grid, and so does
removing the zoomed tile or adding one while a tile is zoomed by hand.

When the grid area cannot fit the tiles' minimum size, the grid zooms the
focused tile itself with a hint; that zoom follows focus and lifts once the
window fits again. A zoom the user chose is left alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:49:23 +02:00
Codeman maintainer 83d0caa209 feat(tiles): tile header (status dot, name, menu, remove) and the in-tiles tab marker
Every tile gets a fixed-height header above its body: `● name ......... ⋯ ×`.

- The dot is the six-state classifier the tab rows and both home screens
  share (_sidebarRichRow), with the existing .home-sessions-dot--* classes;
  hovering the header says the state and for how long ("working 3m"). A
  tile whose session waits on a permission prompt or question gets a pulsing
  red border (box-shadow only, never layout; still under reduced motion).
- The name is text with data-i18n-skip; a double-click renames it through
  the tab rename's own write queue (Enter or leaving the field commits,
  Escape cancels, an IME composition owns Enter), and an in-flight name shows
  as already applied, as on the tab.
- ⋯ is the tab rail's session menu (options, new window, close session with
  its confirm); × removes the tile ONLY, the session keeps running. Neither
  button focuses a tile that is not focused.
- The header is fixed at 24px so nothing in it can resize the body, and with
  it the xterm and its PTY (#464).

Every tab render refreshes the headers, so they follow status and name
changes. Tabs of tiled sessions carry .in-tiles (both render paths), and the
tab strip re-renders when tiles come and go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:45:28 +02:00
Codeman maintainer 13b2989886 test(tiles): park-guards runs on the shared grid harness
tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written
before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced
the wrong element when a child was no longer listed (fixed in the shared copy
only). It now uses the shared harness, which gains what the guards need: a
settable clock behind performance.now, the PerformanceObserver callbacks the
code under test registers, and localFit on the fake tile. Same 35 cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:41:00 +02:00
Codeman maintainer 88447e6c2b fix(tiles): no black band under a tile's last row
xterm paints its viewport black, and a tile's rows rarely fill it exactly, so
every tile showed a black strip between its last row and its bottom edge. The
main terminal's container already makes the viewport transparent; tiles get
the same rule, so the gap shows the terminal background.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:36:06 +02:00
Codeman maintainer 6adf750c50 feat(tiles): tile chords in the shortcut registry, and the grid never beside a split
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable):

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:31:29 +02:00
Codeman maintainer 1a04a75c3d feat(tiles): selections with the grid open focus tiles, and never collapse it by themselves
selectSession gets the tile branch, right after its "already active" early
return: a tiled session is focused in its tile (_selectTiledSession: an
activeSessionId change, the shared _refreshSessionPanels and xterm.focus(),
no cleanup, replay, resize or socket of the parked main terminal). Decision 1:
a USER-initiated pick of a session that is not tiled leaves the grid for the
single view (the grid is remembered), and so does an explicit leaveTiles; an
app-driven pick (auto) never collapses it. A followed #session= link passes
leaveTiles (navigation).

App-driven paths pick a tile instead of the first sessionOrder entry:

- closeSession on the focused tile focuses the neighbouring tile (next in grid
  order, else previous), captured before the await like wasActive, since the
  delete broadcast may already have removed the tile; the last tile closes the
  grid and falls back to the normal pick.
- A tiled session deleted elsewhere loses its tile and a neighbour takes focus
  with auto (the last one lands on the welcome screen as before); a close from
  this tab only drops the tile and leaves the follow-up to closeSession.
- A tiled session popped out to its own window leaves the grid.

Focus rules: pressing a tile is a human selection (pointerdown, never
preventDefault); Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles; only a
human selection acknowledges an idle alert. Home leaves the grid (remembered);
killing every session closes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:24:52 +02:00
Codeman maintainer 4341d3dca8 feat(tiles): the grid controller, and the main terminal parked while it is open
tile-grid.js (load order 7.6) adds the grid to CodemanApp: openTileGrid,
closeTileGrid, addTile, removeTile and _selectTiledSession, over a
<section class="tile-grid"> that is a SIBLING of .terminal-wrap and takes its
place under .main.tiles-active. Every tile is a TerminalTile with the grid's
one load queue, TILE_SCROLLBACK, a bounded load and its own per-device font
size (codeman-tile-font-size; Ctrl +/- sizes the tiles while the grid is open).
Layout comes from computeTileLayout; one ResizeObserver on the section refits
each tile (xterm and PTY together) on the trailing edge.

Opening parks the main terminal: _cleanupPreviousSession runs once (its
snapshot is right at that moment, and it closes the main socket), and
activeSessionId always names the focused tile's session, so the panels follow
focus. With the main socket closed, every main-terminal path that would write
the focused tile's output into the hidden xterm, fetch a capture for it,
resize it or reopen its socket now stands aside through _tilesOwnTerminal():
the SSE terminal, clear and refresh handlers, the dropped-output recovery,
the completion/error writelns, retryConnection and handleInit (both re-arm the
tiles instead; handleInit keeps live tiles and drops dead ones), sendResize,
throttledResize, the history re-pull, and the WebGL long-task observer, which
watches the whole page and must not count tile renders toward the main
terminal's sticky WebGL disable. The header connection state comes from the
tile sockets.

Closing destroys every tile, invalidates the main terminal's cached content
(snapshot, codeman-xs key, buffer cache) for every tiled id, since it predates
the grid, and replays the focused session fresh in the single view.
_focusedPane() answers with the focused tile and _forEachTile reaches every
grid tile. A tile whose socket stops for good is removed (4003, 4004, 4010) or
keeps its "session ended" marker (4009).

No entry point yet: the grid is opened from the shortcut registry in a later
commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:20:05 +02:00
Codeman maintainer fca7acd05f feat(tiles): one load queue for every capture a grid tile fetches
GET /api/sessions/:id/terminal runs synchronous tmux calls on the server, so
N tiles loading at once would stall every WebSocket and SSE stream back to
back (and after a deploy restart all N reopen within the same second).

TerminalTile takes the options PR 1 deferred to the grid:

- scheduleLoad(tile, kind, run): every capture the tile fetches (initial
  load, reconnect refresh, server {t:'r'} refresh, shell history pull) runs
  when its owner says so. Absent (the split's Pane B), a load runs at once.
- scrollback (the grid passes TILE_SCROLLBACK) and fontSize.
- boundedLoad: a TUI tile loads the bounded full=1&tail= window, never its
  whole history.

TileLoadQueue (terminal-tile.js, DOM-free) is that one queue: concurrency 1,
a history pull ahead of background refreshes, then the owner's rank (the grid
ranks the focused tile first, then reading order). A destroyed tile's waiting
loads are dropped unrun, and destroy() aborts the running fetch so the queue
moves on.

Also, for Pane B as well: the load now has a deadline covering the body
(CodemanFetchDeadline), so a capture that never answers cannot hold the
single-flight flag (or the queue) forever; a refresh clears the screen at its
turn rather than when it is asked for; and a close while a load only waits in
the queue writes the disconnected marker at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:05:56 +02:00
Codeman maintainer 59eb509d47 feat(tiles): pure layout and state helpers for the tile grid
window.CodemanTileGrid (constants.js), the grid's pure half:

- computeTileLayout: columns x rows by tile count (1x1, 2x1, 3x1 on a grid
  area at least 1800px wide else 2x2, 2x2, 3x2, 3x3), capped at 9, and
  whether every cell clears the minimum tile size (480x240).
- tileGridCapacity: how many tiles a grid area can hold.
- sanitizeTileGridState: a stored grid (ids only) made safe to apply;
  unknown, deleted, detached and duplicate ids are dropped, focus and zoom
  must name a kept tile, track fractions must be sane.
- tileNeighbor / tileInDirection / cycleTile: which tile takes focus when one
  leaves, on a directional chord, and on Ctrl+Tab or Alt+[ ].
- TILE_SCROLLBACK (10,000 lines, not the primary pane's 50,000) and the tile
  font default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 16:00:35 +02:00
Codeman maintainer 526d396492 refactor(select): extract the deferred panel refresh into _refreshSessionPanels
The block selectSession runs in an idle callback once the terminal content is
on screen (respawn banner and countdown, action log, task panel, Ralph state,
CLI info, project insights, subagent window visibility, file browser) moves
verbatim into its own method. The tile grid's focus change needs the same
refresh without the rest of selectSession, and one copy keeps the two from
drifting. No behavior change: the stale-generation guard moves with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 15:58:20 +02:00
84 changed files with 16943 additions and 547 deletions
+7 -5
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -445,6 +445,20 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
slot, because the routes release the waiter when the client disconnects, but a
client that opens many concurrent waits against one session will still hit the cap.
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -470,6 +484,32 @@ also pure decoration: it confers no permission, and a child is unaffected by its
parent exiting. It appears on session state as `parentSessionId` (absent when
unresolved) and survives a server restart.
## Session model (`displayModel`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
session runs as far as the server knows it, for the web UI's session headers:
```json
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
```
`source` is where it came from, strongest first:
| `source` | Meaning |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
Between `statusline` and `screen` the newest report wins. The field is absent when no
model is known (a shell, a CLI that reports none and was launched without one). `model`
is display text from a pane or a CLI report: control characters are stripped and it is at
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
persisted and restored after a server restart until the next report replaces it; a
`config` value is read again at every pane start, attach and relaunch instead.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -49,6 +49,8 @@ interface CliEntry {
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
// — how this CLI's pane shows work, work it started in the background, and a turn
// that ended waiting for workers it will resume from
// .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -57,10 +59,14 @@ interface CliEntry {
### Regexes that come from config
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), and codex's ` <model> <effort> · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
+12
View File
@@ -201,6 +201,18 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
profile. That is also how you point dsh at a local or third-party provider.
Codeman does READ the route, for display only: a session header names the model
the TUI's status line draws, and while it draws none (the status bar's model
field switched off, or not painted yet) the model the session's route config
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
The reader never writes, follows no symlink out of the dsh home, and returns the
model id alone. The TUI can still reject a pinned route against its provider's
model catalog at startup; the status line, when on, then shows what it chose.
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
all flow through). Provider keys with *other* names are deliberately not: a dsh
+236 -15
View File
@@ -1,10 +1,185 @@
# Tile Grid: Design Spec
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`; PR 2 (the grid) implemented on `feat/tile-grid`, both local only. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktrees `claudeman-tiles`, `claudeman-tilegrid`)
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
## As built: where PR 2 differs from this spec
The design below stands; these are the places the built grid deliberately went another way,
or settled a question the spec left open. The invariants as built are in
`docs/architecture-invariants.md#tile-grid`.
- **An agent that exited in a live pane (`paneExit`) gets no Attach button.** Both attach
routes (`/interactive`, `/shell`) refuse while the pane's tmux client still runs ("Session
already has a running process") and report that in the envelope of a 200, so the
edge-case row below cannot work without a server change. The tile shows the exit and
points at Close session. A session with no PTY (`pid === null`) and a socket closed with
4009 do get Attach. Restarting an exited agent in place is a follow-up.
- **`Ctrl+Shift+G` follows `showTileGridButton`** (decided by the owner, decision 6): with
the setting off the toggle chord is inert. A grid opened another
way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
- **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the
grid gap is 0) and every tile and every empty slot is placed explicitly in its cell
(`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes.
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
(window too small for the minimum tile) follows focus instead.
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
its last frame.
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
first build's 12px glyphs read as tiny next to the name), the size of the app header's own
icon buttons; the header grew from 24 to 28px to hold them.
- **The grid is translated for 简体中文 (zh-CN)** (owner request): 平铺 for the feature, 窗格 for
one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`,
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
the open sessions in tab order, the active one always included and focused, and
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
(from tab order) to the count. A remembered grid comes back with its tiles first, in
their cells, then sessions in tab order, to the count in total: the count is a shape
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
column when all fit, else they pack in reading order) and the added tiles fill the empty
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
remembered grid still wins over an open split: the split closes and its sessions are not
seeded first. A page-load restore brings back exactly the stored grid, whatever the
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
session among them and focused (owner answer: N, not N+1).
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
to right click over the tile button to adjust it"). It replaces the button's native title:
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
"Right-click: choose 2, 4 or 6 tiles", what opens when the count does not fit the window,
and Shift+F10 when the keyboard brought it. It shows 300 ms after a hovering pointer rests
on the button or after a `:focus-visible` focus, never for touch or a device without
hover, never with the count menu open; it hides on leave, blur, Escape, scroll, resize and
any press, click or right-click on the button (capture phase, so the menu never opens
beside it). The card always exists, hidden and current, as the button's
`aria-describedby`.
- **Right-click on Tiles is a 2 / 4 / 6 count menu** (owner decision 10; the session
picker is gone). Three counts with their shapes (the grid's own 2x1, 2x2, 3x2 drawn as
cells), the remembered one checked. A count the window cannot fit is greyed out with the
reason ("This window fits N tiles"); a remembered count that does not fit stays checked
but greyed, the keyboard starts on the largest that fits, and a click opens what fits.
Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move
over the counts that fit, Enter or Space picks, Escape closes it alone (the global
Escape handler gives it the key first, like the tab-group menu) and puts the keyboard
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
element close it (the single view a close starts focuses its terminal when its replay
lands; a menu left open behind that would send its keys there). A pick is remembered per
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
stays, the others leave from the end or join from tab order, filling empty cells first,
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
- **Header icons move their icon on hover, never the button** (owner request: the Tiles
and folder buttons "weirdly turn" on hover; make a nicer hover). A global
`.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the settings gear)
turned every header icon button, swinging its hover background into a diamond. Now
only the gear's ICON turns (45 degrees, one tooth), the Tiles button's four squares
spread apart, and the folder cross-fades to an open folder (`.icon-folder-closed` /
`.icon-folder-open` in its SVG); every other icon just takes the hover colour. Pointer
devices only (`@media (hover: hover)`), transitions off under reduced motion. Pinned by
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
load queue reports its first capture done, then fades in whole (160 ms), so no replay
scrolls by. Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these), a
still copy of the tiles (`_ghostTileGrid`: clones, no xterm, socket or listener; inert,
`aria-hidden`, no pointer) dims at once over the stage (so the click is answered) and
stays until the single view's `selectSession` has replayed its session, at most 700 ms,
then fades: no empty single view between the two. A re-form fades the old grid's copy at
once. The count menu fades in (140 ms). Every one animates opacity and transform only, so
FitAddon measures the final cell and each tile still sends one PTY resize; under
`prefers-reduced-motion` nothing moves and no copy is made. A web tab hides the copy.
- **Opening paints the frames first** (owner answer: "paced connect: in"). The six
terminals used to be built inside the click (about 100 of its 140 ms before the first
frame). `_connectTilesPaced` builds one per animation frame, the focused tile's first,
so the click paints its empty tiles in about 20 ms and the entrance plays while they are
built. The time until all tiles have painted is unchanged: the load queue serves one
capture at a time, so only the focused tile's connect is on its path, one frame later.
`openTileGrid` therefore returns before the terminals exist: a selection that focuses a
tile whose terminal is not built yet hands the keyboard over in `_connectTile`
(`focusOnConnect`), never when `focus: false` was asked.
- **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is
the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable,
so going back to nine is that one line. A stored grid with more ids comes back as its first
six. Where this spec says nine, read six.
- **Each header names the harness and the model** (owner request): the tile header is
`● [logo] name · model ……… ⋯ ⤢ ×`. The logo is PR #532's `run-mode-dot <cliId>` slot
(the id is data), the model the session's `displayModel` (custom endpoint, else what the
CLI itself reports: claude's statusline, a footer read with `capabilities.modelDetect`
for dsh and codex; else what the CLI's config pins, dsh-TUI's route (owner feedback 1:
a dsh session with its status bar's model field off shows `qwen3.8-27b (from config)`);
else the launch model; else nothing, the logo alone). Both split
panes carry the same strip: Pane B's header, and Pane A's while the split is open. Pane
B's close is a tile button (26px).
- **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The
+ menu and its "New session in this case" are gone; tiles are added from the Tiles
button (and its right-click count menu), Ctrl/Cmd+click on a tab, a dragged tab, "Open
group as tiles" and Run joining the open grid. Where this spec describes a `+`, it no
longer exists.
- **No SSE terminal stream while tiles own the terminal** (performance pass): the filter
names a fixed id no session takes (`TILE_GRID_SSE_FILTER`), not `[activeSessionId]` as
"Parking the main terminal" below says; the server's filter gates only terminal
batches, so lifecycle and hook events are unaffected, and leaving the grid
re-subscribes the shown session.
- **Tiles move** (owner request: "give me the option to move the tiles around"; not a
numbered decision). The grid is CELLS, not a packed list (owner: "the empty tab doesnt
always have to be the last one ... it can also be tab nr 4 or 3"): `grid.cells` holds a
session id or `null` per cell and is the one source of truth, `grid.ids` the tiles in
reading order derived from it. The shape still comes from the tile count (the layout
table), the cap counts tiles, never empty cells, and an empty cell can be any cell (in
practice one at most: a 3x2 holds 5 or 6 tiles, a 2x2 3 or 4). A tile's header, its free
area (not the buttons, not the rename input), drags it: onto another tile the two trade
places, onto an empty cell it moves there and leaves its own cell empty, nothing else
moving; a tiled session's tab does the same, and a tab of a session not tiled yet joins in
the cell it is dropped on. It is a native drag through the tab drop targets (capture
phase, stopped before xterm), carrying a type of its own and never text, and it is not
`draggedTabId`, so neither a text field nor the tab strip takes it; Escape or a drop
anywhere else cancels with nothing changed, focus included: the header focuses its tile on
click, not on press (owner's answer: best practice; the body keeps press-to-focus, so
focus moves before a press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile
Left/Right/Up/Down, registry, rebindable) move the focused tile to the adjacent cell: into
it when empty, trading places when a tile is there, never jumping a cell; focus stays on
it. Every move goes through `_reorderTiles`: no remount, reconnect or reload; divider
sizes belong to the cells, so only a tile whose cell size changed fits (one PTY resize,
#464). Removing a tile leaves its cell empty where it was, and adding one takes the first
empty cell (or the one a tab was dropped on), while the shape stays; a shape change
(`fitTileCells`, constants.js) keeps each tile's row and column when all fit and otherwise
packs the tiles in reading order, which differs from plain packing only when a 2x2 grows
to a 3x2 (the four tiles stay put). Focus never lands on an empty cell: Alt+Shift+Arrows
go along the row past one, or to the nearest row with a tile (the same column, else the
nearest), and Ctrl+Tab and Alt+[ / ] cycle the tiles only. `codeman:tile-grid` stays ids
only: its `ids` are the cells, `null` for an empty one (a build before cells drops the
nulls and reads them packed); a reload brings the holes back when the shape is the same, a
session gone by then leaves its cell empty, another shape packs, and the old packed format
reads unchanged. A fresh grid (Ctrl/Cmd+click with the grid closed, "Open group as
tiles") opens packed; only the toggle and the page-load restore bring holes back (the
toggle through `reformTileCells` when the count changes the set). Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so
their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as
the owner confirmed) and with a single tile. Both arrow chord families, focus and move,
skip a text field, where shifted arrows select (owner's answer: best practice). Default
keys: every other two-modifier arrow chord is taken (Ctrl+Alt switches workspaces,
Ctrl+Alt+Shift moves a window to another workspace in GNOME, Alt is back/forward,
Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by the browsers, GNOME, KDE,
macOS and Claude Code, and costs only a terminal editor's word selection inside a tile
while the grid is open.
- **A tile that joins before its pane exists resends its size when the pid appears**
(`TerminalTile.paneStarted()`): the server drops a resize for a session with no PTY and
spawns at 120x40, and Run's own resize measures the parked main terminal. Applying a
pre-spawn resize at spawn time on the server would make this unnecessary (follow-up).
## Problem
Codeman's terminal area shows exactly one session at a time. The split pane
@@ -17,7 +192,8 @@ closed and reopened.
The goal is a dashboard of agents: four, six or nine live Claude sessions on
one monitor, each readable and typeable, with its state visible at a glance.
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×` (as built:
no `+`, owner decision 9).
## Goal (v1)
@@ -108,9 +284,10 @@ work also fixes gaps the split pane has today.
### Entry points
- **Header Tiles button** (its own button, beside Split). Opens a picker with
checkboxes over open sessions, ordered like the tab strip. When the grid is
open, the button toggles it closed.
- **Header Tiles button** (its own button, beside Split). As built (decision 8)
a click opens the grid at once, the same as the toggle shortcut; right-click
is the 2 / 4 / 6 count menu (decision 10; the session picker it replaced is
gone). When the grid is open, a click closes it.
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
closed).
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
@@ -136,8 +313,8 @@ Automatic by tile count, computed by a pure helper:
| 7-9 | 3x3 |
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
roughly 60 columns at the default tile font), so the picker disables additions
the window cannot fit.
roughly 60 columns at the default tile font), so the count menu greys out the
counts the window cannot fit.
Column and row dividers are draggable (generalizing the split divider): the
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
@@ -146,7 +323,8 @@ animation frame, and sends one resize per affected tile at pointer-up.
### Tile header
`● name ……… ⋯ ⤢ + ×`
`● name ……… ⋯ ⤢ + ×` (as built: `● [logo] name · model ……… ⋯ ⤢ ×`, owner decision 9 and
the harness/model request; see "As built")
- **●** status dot from the existing six-state classifier
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
@@ -165,7 +343,8 @@ animation frame, and sends one resize per affected tile at pointer-up.
it again (or the shortcut) restores the grid.
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
session in this case", which runs the normal quick-start for the tile's case
and drops the result into the next slot.
and drops the result into the next slot. (Built, then removed by owner
decision 9.)
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
@@ -227,6 +406,9 @@ against the live list without rebuilding tiles that are still alive.
keeps the stored grid.
- Hidden in solo windows (`body.solo-mode`).
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
- The toggle chord follows the setting (decision 6): with `showTileGridButton`
off, `Ctrl+Shift+G` is inert and reaches the terminal like any unbound key; on,
it toggles the grid. A grid opened another way keeps all its chords.
## Components
@@ -337,8 +519,9 @@ xterm. Nothing may outlive a removed tile (24-hour sessions rule).
### 2. `TileGrid` controller and layout helper
- `computeTileLayout({ count, width, height, minTileW, minTileH })` and
`tileGridCapacity(...)`: pure, in `constants.js`, exported on
- `computeTileLayout({ count, width, height })` and
`tileGridCapacity({ width, height })` (against the minimum tile size,
`TILE_MIN_W` x `TILE_MIN_H`): pure, in `constants.js`, exported on
`window.CodemanTileGrid` beside the existing helper namespaces.
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
@@ -562,7 +745,7 @@ and share one tile class:
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
| Multi-user mode | The grid only ever opens visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
| Solo window | Tiles unavailable |
## Server
@@ -613,8 +796,9 @@ Separate follow-up PRs worth doing (see "Follow-ups").
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
- **Palette chords** are swallowed in every xterm key handler.
- **Escape**: the picker's close method returns early when the picker is not
open (the global Escape handler calls every close method).
- **Escape**: the count menu's close method returns early when the menu is not
open, and an open menu owns the Escape (it closes alone and the keyboard goes
back to the Tiles button, like the tab-group menu).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
- **No secrets in localStorage**: the stored grid holds ids only.
- **Memory**: everything a tile creates is released in `destroy()`.
@@ -831,6 +1015,43 @@ exits green. Use the browser runner for those files and read the file count.
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
killed sessions with no confirm); it stays bindable in App Settings →
Shortcuts.
6. **`Ctrl+Shift+G` with the Tiles setting off.** Decided by the owner: the
chord is inert while `showTileGridButton` is off (it passes through like any
unbound key) and toggles the grid while it is on, so one setting governs both
the button and the chord.
7. **The tile cap.** Decided by the owner: at most 6 tiles for now. Six was
tested and is smooth on the owner's desktop; nine missed the headless frame
bar (p95 33 ms at 6 and 9 tiles under load, 16.8 ms at 4) and is untested on
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
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). Superseded in part by decision 10: right-click is now the count
menu, and a remembered grid is filled to the count instead of opening
exactly as stored.
9. **No + in the tile header.** Decided by the owner ("remove the + button from
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
session in this case" went with it. Tiles are added from the Tiles button
and its right-click count menu, Ctrl/Cmd+click on a tab, a dragged tab,
"Open group as tiles" and Run joining the open grid.
10. **Right-click Tiles is a 2 / 4 / 6 count menu.** Decided by the owner
("give me then the option to choose only HOW many tiles, 2,4,6 default is 6
so the menu is easier"; asked where it lives: "Click opens 6"): a click
still opens the grid at once, with the remembered count (default 6); the
right-click menu offers 2, 4 and 6, remembered per device; the session
picker is gone, and decision 8's "picker on right-click" is superseded. The
owner's answers on the details: the count wins over a remembered grid's
size (its tiles first, in their cells, holes filled first, then tab order);
Ctrl/Cmd+click with the grid closed opens the count in total, that session
focused; shrinking keeps the focused tile; only the toggle animates the
close; a remembered count larger than the window stays checked but greyed
and a click opens what fits; the close keeps its dimmed still until the
single view has painted (at most 700 ms); paced connect is in.
## Code anchors
+16
View File
@@ -39,6 +39,22 @@ from its tab, or bind a key to it in App Settings → Shortcuts.
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
## Tile grid
| Shortcut | Action |
| --------------------------- | ------------------------------------------------------------ |
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
default. See [Tile Grid](Tile-Grid).
## Everything else
| Shortcut | Action |
+4 -2
View File
@@ -57,7 +57,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
@@ -71,7 +71,9 @@ window; off lists every file by its full path.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go.
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
[Tile Grid](Tile-Grid).
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
+1
View File
@@ -118,6 +118,7 @@ The right side of the header. Almost all of these are off until you enable them
| Cron ⏰ | Off | Scheduled jobs. |
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
+136
View File
@@ -0,0 +1,136 @@
# Tile Grid
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
its agent is working, idle, or waiting on you.
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
never offered in a popped-out session window.
## Turning it on
**App Settings → Header & Panels → Tiles.** This is a per-device setting, off by default,
so turning it on at your desk never puts the button on your phone. It shows a **Tiles**
button in the header, beside Split, and enables `Ctrl+Shift+G`.
## Opening a grid
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
(six until you choose; fewer if the window is too small or you have fewer sessions open).
You get the grid you last had, its tiles where they were, topped up with your open
sessions in tab order; if there is none, an open split's two first; otherwise your open
sessions in tab order, with the session you are on focused. With the grid open, the same
button closes it.
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
count it opens and what a click and a right-click do.
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
and is what the next click opens. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
order. A count the window is too small for is greyed out, with the reason.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
the grid closed it opens what the Tiles button would show, with that session among them
(still the count you chose in total). On macOS use
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
another tile swaps the two.
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
- **Run**: a session you start from this browser tab's Run button while the grid is open
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
window is too small for six; the count menu says which limit applies.
Opening, the tiles fade in one after another and each terminal appears once its history
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
on screen, dimmed, until the single session behind them has loaded, then fade away. With
reduced motion turned on in your system settings, the grid opens and closes at once.
## A tile
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
| Part | What it does |
| ------ | ------------------------------------------------------------------------------------------------ |
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
| name | Double-click to rename the session. |
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. |
| `⋯` | The session menu: options, open in a new window, close the session. |
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
voice and image paste. Tabs of tiled sessions carry a small underline.
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
one on its own until the window is big enough again.
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
whose agent exited inside its pane says so instead; close that session from `⋯`.
## Moving tiles
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
header focuses its tile when you click it, not when you press it.
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
down: into the empty slot if that is the place, else trading places with the tile there. It
keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
the empty slot included, is saved with the grid and comes back on reload.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
## Keys
| Shortcut | Action |
| ------------------------ | ---------------------------------------------------------- |
| `Ctrl+Shift+G` | Open or close the grid. |
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
| `Ctrl+L` | Clear the focused tile. |
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
## Leaving the grid
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
session finder) shows that session on its own, the normal single view. The grid is
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
same. Narrowing the window below the desktop width also returns to the single view.
The grid is saved on this device and comes back when you reload the page, with its focus,
zoom and column widths. A session that was closed in the meantime is simply left out.
Split shows the same logo, name and model above both of its panes.
The grid and Split are never open together: opening the grid turns an open split into two
tiles, and Split is unavailable while the grid is open.
## Read next
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
+1
View File
@@ -11,6 +11,7 @@
**Using it**
- [The Dashboard](The-Dashboard)
- [Tile Grid](Tile-Grid)
- [Agent CLIs](Agent-CLIs)
- [Custom Model Endpoints](Custom-Model-Endpoints)
- [Working With Files](Working-With-Files)
+13
View File
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
return null;
}
}
/**
* How many capture groups a regex source declares (named ones included), or -1 when it
* does not compile. Matching the empty string against `source|` always succeeds through
* the empty alternative, and the match array then has one slot per group.
*/
export function countCaptureGroups(source: string): number {
try {
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
} catch {
return -1;
}
}
+39 -2
View File
@@ -13,9 +13,9 @@
*/
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -370,6 +370,43 @@ const capabilitiesSchema = z
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
.strict(),
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
// over the foot of a pane capture every time a session settles. Exactly one capture
// group (the model), checked here so a pattern without one fails at LOAD time instead
// of silently never naming a model.
modelDetect: z
.object({
screenLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
)
.optional(),
// Bounded hard, like watchingLines: every row it adds is one more row the agent
// itself may be able to write.
screenLines: z.number().int().min(1).max(4).optional(),
// Single tokens, bounded: each is compared against one captured field.
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
})
.strict()
// Typos rather than configurations, refused at LOAD time like watchingLines.
.refine(
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
'modelDetect declares nothing to read'
)
.refine(
(v) => v.screenLines === undefined || v.screenLine !== undefined,
'screenLines has nothing to bound without a screenLine'
)
.refine(
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
'rejectWords has nothing to filter without a screenLine'
)
.optional(),
privilegedParams: z
.array(
z
+39
View File
@@ -622,6 +622,16 @@ const CODEX: CliEntry = {
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
// cwd). It is the pane's LAST row, below the composer, so the transcript never
// reaches it, and the effort word right after the model is codex's own format: an
// open slash-command popup or a bare line of prose does not have that shape. A
// footer without an effort word (a model with no reasoning setting) is not read,
// and the session keeps its last known or launch model.
modelDetect: {
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -1222,6 +1232,35 @@ const DEEPSEEK: CliEntry = {
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Model is NOT a session field for dsh — it is a profile composition entry.
model: { source: 'none' },
// So the screen is where the model is known: dsh-TUI resolves the route itself
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
// lib/types/modelRoute.js) and its status line draws "the route requests actually
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
// The border anchors it: nothing the agent writes can sit below the composer, and a
// suggestion popup there starts with `/` or `+`, never a model id.
// ⚠ The first field is the model only while the status bar's model field is on (the
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
// then the reasoning effort (` medium · th-config`, measured live), then the session
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
// and a field equal to the session's folder name is refused by the shared reader.
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
// one-word session title as the first field; and the non-compact layout, whose
// left/right justification never ends a field with ` · `, so nothing is read there
// and the session shows its route config.
modelDetect: {
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
screenLines: 3,
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
// With the status bar's model field off (or before it paints), the route the
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
configResolver: 'deepseek-route',
},
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
// launcher's own default, `workspace-write`, which already asks. Clamping to
// `read-only` instead would break the workspace rather than protect it.
+38
View File
@@ -93,6 +93,9 @@ export interface CliVariant {
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
export type ModelConfigResolverName = 'deepseek-route';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
@@ -462,6 +465,41 @@ export interface CliCapabilities {
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
/**
* Where this CLI draws the model it is running, so a session header can name it
* (`SessionState.displayModel`, src/session-display-model.ts).
*
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
* extra tmux call and re-reads the footer at every turn transition, so an in-session
* `/model` switch is followed.
*
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
* on chrome only this CLI draws (the row under its own composer, an effort word in its
* own footer format), never on a shape the agent could print in its transcript. Measured
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
* the session shows its launch model, if any.
*
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
* model the CLI's own config pins, the way that CLI resolves it for the session, for
* while the screen names none (its status line switched off, or not drawn yet). Read
* once per pane start, attach or relaunch, bounded and read-only; the screen still
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
* per-CLI behaviour stays data here and code in one module.
*/
modelDetect?: {
screenLine?: string;
screenLines?: number;
/**
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
* field is switched off shows the next field there), compared lower-cased. A field
* equal to the session's own working-directory basename is never the model either,
* for every CLI; that rule is the shared reader's, not data.
*/
rejectWords?: string[];
configResolver?: ModelConfigResolverName;
};
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
+464
View File
@@ -0,0 +1,464 @@
/**
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
* use, read from its route config, for a session header whose screen names no model
* yet (the status bar's model field switched off, or not drawn yet). See
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
* whenever it names a model, since it is what the running TUI actually uses.
*
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
*
* A profile is a stack of loader patch layers over an empty root, in this order
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
* does not match the row's is skipped; every other key REPLACES the row's field
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
* row defines all of it.
*
* dsh-TUI then takes its model route from that config only when it names BOTH
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
* own default: neither is in the config, so neither is answered here. The bundle's
* own row pins `provider: deepseek-official` alone, by design, so only the two user
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
* HEADLESS default, not the TUI's, and is never read.
*
* ## Answer nothing rather than a guess
*
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
* big, a mount that does not answer), and a file this reader does not fully
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
* dependency): a top-level block sequence of patch items, plain keys, single-line
* plain or quoted string scalars for the values it needs, and null for anything else
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
* scalar YAML would type as a number, boolean or null, a second document, a nested
* row redefining dsh-TUI).
*
* ## Never block, never write, never leak
*
* Every path is probed with the bounded `probePathKind()` before it is touched, read
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
* Nothing is written. Only the model id leaves this module: never the provider, a
* base URL, a key or any other config value.
*
* Tests: `test/deepseek-route-config.test.ts`.
*
* @module deepseek-route-config
*/
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve, sep } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
import {
deepSeekProfileFromManifest,
isProfileDirName,
resolveDefaultDeepSeekProfile,
type DeepSeekProfile,
} from './utils/deepseek-cli-resolver.js';
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
/** The loader row dsh-TUI's own config lives on. */
export const DSH_TUI_ROW_ID = 'dsh-tui';
/** Largest file read: a patch layer is a few dozen lines. */
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/** How many profile directories the default-profile inventory looks at. */
const MAX_PROFILES = 64;
/** What one patch item does to the dsh-TUI row. */
export interface DshTuiRowPatch {
/** `name` on the patch; a mismatch makes dsh skip it. */
name?: string;
/** `disabled` on the patch, when present. */
disabled?: boolean;
/**
* `config` on the patch, when present: the provider and model it names (absent when
* it does not name one), or `{}` for a config that is empty or not a mapping.
*/
config?: { provider?: string; model?: string };
}
/** Thrown inside the parser for anything it does not fully understand. */
class Ambiguous extends Error {}
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
/**
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
* beyond this reader.
*/
function stripComment(line: string): string {
let quote: '"' | "'" | null = null;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote === "'") {
if (c === "'") {
if (line[i + 1] === "'") i++;
else quote = null;
}
} else if (quote === '"') {
if (c === '\\') i++;
else if (c === '"') quote = null;
} else if (c === "'" || c === '"') {
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
const prev = line.slice(0, i).trimEnd();
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
return line.slice(0, i).trimEnd();
}
}
if (quote) throw new Ambiguous('unterminated quote');
return line.trimEnd();
}
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
function indentOf(line: string): number {
const m = /^[ \t]*/.exec(line)![0];
if (m.includes('\t')) throw new Ambiguous('tab indentation');
return m.length;
}
/**
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
*/
function stringScalar(raw: string): string {
const v = raw.trim();
if (v.startsWith("'")) {
const m = /^'((?:[^']|'')*)'$/.exec(v);
if (!m) throw new Ambiguous('quoted scalar');
return m[1].replace(/''/g, "'");
}
if (v.startsWith('"')) {
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
if (!m) throw new Ambiguous('quoted scalar');
return m[1].replace(/\\(["\\/])/g, '$1');
}
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
if (
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
v
)
) {
throw new Ambiguous('numeric scalar');
}
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
return v;
}
/** `true`/`false` as YAML spells them, or a throw. */
function boolScalar(raw: string): boolean {
const v = raw.trim();
if (/^(?:true|True|TRUE)$/.test(v)) return true;
if (/^(?:false|False|FALSE)$/.test(v)) return false;
throw new Ambiguous('not a boolean');
}
interface Line {
indent: number;
text: string;
}
/**
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
* with its inline value and the lines nested under it. Throws on a line that is not a
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
* would not boot).
*/
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
let current: { inline: string | undefined; nested: Line[] } | null = null;
for (const line of lines) {
if (line.indent > indent) {
if (!current) throw new Ambiguous('nested line with no key');
current.nested.push(line);
continue;
}
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
const m = KEY_LINE.exec(line.text);
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
keys.set(m[1], current);
}
return keys;
}
/**
* Whether an item this reader cannot follow is certainly about another row: a plain
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
* mention of the dsh-TUI row anywhere in it.
*/
function isUnrelatedItem(item: Line[]): boolean {
const indent = item[0].indent;
const top = item.filter((l) => l.indent === indent);
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
try {
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
} catch {
return false;
}
}
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
if (entry.inline !== undefined) {
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
const v = entry.inline.trim();
// An empty flow mapping or a null names no route; anything else inline (a tag, a
// non-empty flow mapping, a block scalar) is beyond this reader.
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
throw new Ambiguous('inline config');
}
if (!entry.nested.length) return {};
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
const out: { provider?: string; model?: string } = {};
for (const field of ['provider', 'model'] as const) {
const value = keys.get(field);
if (!value) continue;
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
out[field] = stringScalar(value.inline);
}
return out;
}
/**
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
* file is beyond this reader's subset, or when it could re-insert the row.
*
* @param text the file's content
*/
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
try {
const lines: Line[] = [];
let sawContent = false;
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
const stripped = stripComment(rawLine);
if (stripped.trim() === '') continue;
const indent = indentOf(stripped);
const body = stripped.slice(indent);
if (indent === 0 && body === '---') {
if (sawContent) throw new Ambiguous('a second document');
continue;
}
sawContent = true;
lines.push({ indent, text: body });
}
if (lines.length === 0) return [];
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
// Split the top-level block sequence into items.
const items: Line[][] = [];
for (const line of lines) {
if (line.indent === 0) {
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
if (!m) throw new Ambiguous('not a top-level sequence');
const item: Line[] = [];
// `- key: value`: the key sits at its real column, which its siblings below share.
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
items.push(item);
continue;
}
if (items.length === 0) throw new Ambiguous('indented content before the first item');
items[items.length - 1].push(line);
}
const patches: DshTuiRowPatch[] = [];
for (const item of items) {
if (item.length === 0) throw new Ambiguous('empty item');
// The first key's column is the item's indent; every key shares it.
let keys: ReturnType<typeof mappingKeys>;
try {
keys = mappingKeys(item, item[0].indent);
} catch (err) {
// An item this reader cannot follow is harmless only when it provably is about
// another row: its own id names one, and no line in it names the dsh-TUI row.
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
continue;
}
const id = keys.get('id');
if (keys.has('insert')) {
// An insert that could bring a second dsh-TUI row is beyond this reader.
const body = item.map((l) => l.text).join('\n');
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
continue;
}
if (!id) continue; // dsh warns and skips a non-insert patch without an id
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
// Another row; but a group row's config is a list of rows, and one of them could
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
continue;
}
const patch: DshTuiRowPatch = {};
const name = keys.get('name');
if (name) {
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
patch.name = stringScalar(name.inline);
}
const disabled = keys.get('disabled');
if (disabled) {
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
patch.disabled = boolScalar(disabled.inline);
}
const config = keys.get('config');
if (config) patch.config = configOf(config);
patches.push(patch);
}
return patches;
} catch (err) {
if (err instanceof Ambiguous) return null;
throw err;
}
}
/**
* The model dsh-TUI's route config pins, given what the user layers do to its row in
* application order (profile layer first, then the home layer). Null unless the last
* `config` that applies names both a provider and a model, and the row is not
* disabled. Pure.
*
* @param layers each layer's patches for the row, or null for a layer that could not be read
*/
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
let config: { provider?: string; model?: string } | undefined;
let disabled = false;
for (const layer of layers) {
if (layer === null) return null;
for (const patch of layer) {
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
if (patch.disabled !== undefined) disabled = patch.disabled;
if (patch.config !== undefined) config = patch.config;
}
}
if (disabled || !config?.provider || !config.model) return null;
return config.model;
}
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
/**
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
* that does not answer is refused, never waited on), its real path checked against
* the dsh home (a symlink out of it is refused), size-capped.
*/
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
const kind = await probePathKind(path);
if (kind === 'absent') return { state: 'absent' };
if (kind !== 'file') return { state: 'refused' };
try {
const real = await fs.realpath(path);
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
const stat = await fs.stat(real);
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
return { state: 'read', text: await fs.readFile(real, 'utf8') };
} catch {
return { state: 'refused' };
}
}
/**
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
*/
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
const value = env('DSH_HOME')?.trim();
if (!value) return join(homedir(), '.dsh');
return isAbsolute(value) ? resolve(value) : null;
}
/**
* The profiles under a dsh home, read with the same bounded rules as the route files.
* Used to name the profile a session boots when it named none, the way the launch's
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
*/
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
const profilesDir = join(home, 'profiles');
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
let realHome: string;
let names: string[];
try {
realHome = await fs.realpath(home);
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
.map((e) => e.name)
.sort((a, b) => a.localeCompare(b))
.slice(0, MAX_PROFILES);
} catch {
return null;
}
const profiles: DeepSeekProfile[] = [];
for (const name of names) {
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
if (manifest.state !== 'read') continue;
const profile = deepSeekProfileFromManifest(name, manifest.text);
if (profile) profiles.push(profile);
}
return profiles;
}
/** What the reader needs to know about one session. */
export interface DeepSeekRouteContext {
/** The session's `deepSeekConfig.profile`, if any. */
profile?: unknown;
/** The session's dsh home (see {@link effectiveDshHome}). */
home: string | null;
/** The server's own dsh home, which names the default profile (as the launch does). */
serverHome: string | null;
}
/**
* The model the session's dsh-TUI route config pins, or null when it pins none or the
* answer is in any doubt. Read-only and bounded; see the module comment.
*/
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
const { home } = ctx;
if (!home) return null;
// An invalid name reads as unset at launch, so the default applies there too.
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
if (!profile) {
if (!ctx.serverHome) return null;
const listed = await listProfilesBounded(ctx.serverHome);
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
if (!profile) return null;
}
if ((await probePathKind(home)) !== 'directory') return null;
let realHome: string;
try {
realHome = await fs.realpath(home);
} catch {
return null;
}
const profileDir = join(home, 'profiles', profile);
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
if (manifest.state !== 'read') return null;
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
const layers: Array<DshTuiRowPatch[] | null> = [];
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
const read = await readHomeFile(file, realHome);
if (read.state === 'refused') return null;
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
}
return resolveDshTuiRouteModel(layers);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* @fileoverview The config readers a CLI's registry entry may name for the model its
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
* config/cli-registry/profiles.ts).
*
* A reader answers the model the CLI's own config pins for one session, or null when
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
* the model id alone, never another config value.
*
* @module model-config-resolvers
*/
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
/** What a reader gets to know about the session. */
export interface ModelConfigContext {
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
config: Record<string, unknown> | undefined;
/** The environment the session's CLI runs with (its own overrides, then the server's). */
env: (key: string) => string | undefined;
}
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
// launch names from the server's own dsh home) read under the session's dsh home.
'deepseek-route': (ctx) =>
readDeepSeekRouteModel({
profile: ctx.config?.profile,
home: effectiveDshHome(ctx.env),
serverHome: effectiveDshHome((key) => process.env[key]),
}),
};
/**
* The model the named reader resolves for a session, or null.
*
* @param name a `configResolver` from the registry (schema-checked at load)
* @param ctx what the reader may know about the session
*/
export async function resolveConfigModel(
name: ModelConfigResolverName,
ctx: ModelConfigContext
): Promise<string | null> {
const resolver = RESOLVERS[name];
return resolver ? resolver(ctx) : null;
}
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview Which model a session is running, as far as the server can know it
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
*
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
*
* ## Sources, strongest first
*
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
* statusLine exporter posts `model.display_name` on every render (it follows an
* in-session `/model`); a CLI whose registry entry declares
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
* probe already takes.
* 3. **config**: the model the CLI's own config pins for this session, read by the
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
* a report from the running CLI, so any report outranks it.
* 4. **launch**: the model the session was launched with (claude's `--model` or the
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
* asked for, not what was reported, so it only shows when nothing reported.
*
* Nothing known means no field at all: the header shows the harness logo alone, never a
* placeholder or a guess.
*
* ## Untrusted text
*
* A screen-read model is pane text, and a statusline payload is a POST body: both are
* stripped of escape sequences and control characters, whitespace-collapsed and capped
* here, and the browser renders the result with `textContent`.
*
* Tests: `test/session-display-model.test.ts`.
*
* @module session-display-model
*/
import type { DisplayModel, DisplayModelSource } from './types/session.js';
import { stripAnsi } from './utils/index.js';
import { getCli } from './config/cli-registry/index.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
/** Longest model name published (the header truncates long before this). */
export const MAX_DISPLAY_MODEL_CHARS = 64;
/** A report from the running CLI itself: the sources a restart may restore. */
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
export interface ReportedModel {
model: string;
source: ReportedModelSource;
}
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
/**
* A model name fit to publish, or undefined when nothing printable is left.
*
* @param raw anything; only a string can yield a name
*/
export function sanitizeModelName(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
if (!clean) return undefined;
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
}
/** What a footer field can show that is never the model. */
export interface ScreenModelRejects {
/** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */
rejectWords?: readonly string[];
/** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */
cwdBasename?: string;
}
/**
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
*
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
* can anchor on the row above), which keeps the search below the transcript: the
* pattern itself must still anchor on chrome only that CLI draws.
*
* A footer whose model field is switched off shows its NEXT field where the model
* was, so the captured field is not taken when it is one of the CLI's declared
* non-model words (an effort level, a mode) or the session's own folder name, which a
* footer field equal to is the folder, never the model, whatever the CLI. Anything
* else the pattern captures is read as the model.
*
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
* @param tailRows how many non-blank rows from the bottom the pattern sees
* @param rejects fields that are never the model (see {@link ScreenModelRejects})
* @returns the model, or undefined when the frame shows none
*/
export function readScreenModel(
paneText: string | null | undefined,
pattern: RegExp,
tailRows: number = 1,
rejects: ScreenModelRejects = {}
): string | undefined {
if (!paneText) return undefined;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd())
.filter((row) => row !== '');
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
// stale lastIndex would make the same frame match every other call.
pattern.lastIndex = 0;
const match = pattern.exec(window);
if (!match) return undefined;
const field = match[1] ?? '';
if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined;
if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined;
return sanitizeModelName(field);
}
/**
* The model a session was launched with, read the way its spawn reads it: where the
* model param lives is registry data (`capabilities.model` names the param, the entry's
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
* dsh) has none.
*
* @param mode the session's CLI id
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
*/
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
const entry = getCli(mode);
const model = entry?.capabilities.model;
if (!entry || !model || model.source === 'none') return undefined;
const param = model.param ?? 'model';
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
const value = legacyConfigForMode(mode, bag)?.[key];
return typeof value === 'string' ? value : undefined;
}
/**
* The persisted `displayModel` of a previous run, when it was a report from the CLI
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
* launch answer is not restored, since the session derives those again by itself.
*/
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
if (!saved || typeof saved !== 'object') return undefined;
const { model, source } = saved as { model?: unknown; source?: unknown };
if (source !== 'statusline' && source !== 'screen') return undefined;
const name = sanitizeModelName(model);
return name ? { model: name, source } : undefined;
}
/**
* The model a session header shows, and where it came from.
*
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
* @param input.reported the newest report from the CLI itself
* @param input.configModel the model the CLI's config pins for the session
* @param input.launchModel the model the session was launched with
*/
export function resolveDisplayModel(input: {
customModelId?: string;
reported?: ReportedModel | null;
configModel?: string | null;
launchModel?: string;
}): DisplayModel | undefined {
const custom = sanitizeModelName(input.customModelId);
if (custom) return { model: custom, source: 'custom-endpoint' };
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
if (reported && input.reported) return { model: reported, source: input.reported.source };
const config = sanitizeModelName(input.configModel);
if (config) return { model: config, source: 'config' };
const launch = sanitizeModelName(input.launchModel);
if (launch) return { model: launch, source: 'launch' };
return undefined;
}
+162 -1
View File
@@ -29,6 +29,7 @@
*/
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
@@ -137,7 +138,18 @@ import {
sanitizeAttachmentHistory,
upsertAttachmentHistory as upsertAttachmentHistoryList,
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem } from './types/session.js';
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
import { resolveConfigModel } from './model-config-resolvers.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
import {
launchModelFor,
readScreenModel,
resolveDisplayModel,
restoredReportedModel,
sanitizeModelName,
type ReportedModel,
type ReportedModelSource,
} from './session-display-model.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
@@ -550,6 +562,27 @@ export class Session extends EventEmitter {
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
/**
* The newest model the running CLI reported for itself (its statusline, or its own
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
* restart, so an idle session keeps naming its model until the next report.
*/
private _reportedModel: ReportedModel | null = null;
/**
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
* read at each pane start, attach or relaunch; null when it pins none. Below any
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
*/
private _configModel: string | null = null;
/** Bumped per config read, so a read that lands after a newer one is dropped. */
private _configModelGen = 0;
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
private _modelLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
private _modelLineRows = 1;
/** Resolved with the pattern above: the fields it shows that are never the model. */
private _modelRejectWords: readonly string[] = [];
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -807,6 +840,8 @@ export class Session extends EventEmitter {
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
paneExit?: PaneExit;
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
displayModel?: DisplayModel;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
@@ -974,6 +1009,7 @@ export class Session extends EventEmitter {
// replaces it with a first-hand reading. NOT the stats collector, which a
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1267,6 +1303,9 @@ export class Session extends EventEmitter {
} finally {
this._paneLifecycleOps--;
this._paneStartedAt = Date.now();
// A start, attach or relaunch is when the CLI read its config, so it is when
// the model that config pins is read here too.
this._refreshConfigModel();
}
}
@@ -1858,6 +1897,7 @@ export class Session extends EventEmitter {
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
advisorModel: this._advisorModel,
customModel: this.customModel,
displayModel: this.displayModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
// by the constructor: a Codeman restart starts with a fresh breaker so boot
@@ -3134,9 +3174,130 @@ export class Session extends EventEmitter {
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
this._readWatching(text);
this._readScreenModel(text);
return this._lastPaneProbeWorking;
}
/**
* Read the model the CLI's own footer names off the same capture, for a CLI whose
* registry entry declares `capabilities.modelDetect`.
*
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
* two are not symmetric: background work ends and its badge must go, while a model does
* not stop running because something was drawn over the row that names it.
*/
private _readScreenModel(paneText: string | null): void {
if (paneText === null) return;
const pattern = this._modelLinePattern();
if (!pattern) return;
const model = readScreenModel(paneText, pattern, this._modelLineRows, {
rejectWords: this._modelRejectWords,
// A footer field equal to the folder this session runs in is the folder, never the
// model: the generic half of the rule, for every CLI.
cwdBasename: basename(this.workingDir),
});
if (model) this.noteReportedModel('screen', model);
}
/**
* The regex reading this CLI's model off its footer, or null for a CLI that declares
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
* for a pattern it refuses), like the working- and watching-line patterns.
*/
private _modelLinePattern(): RegExp | null {
if (this._modelLineRe === undefined) {
const detect = getCli(this.mode)?.capabilities.modelDetect;
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
this._modelLineRows = detect?.screenLines ?? 1;
this._modelRejectWords = detect?.rejectWords ?? [];
}
return this._modelLineRe;
}
/**
* Record a model the running CLI reported for itself: its statusline (claude's
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
* wins whatever its source. An empty or unprintable report changes nothing.
*
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
*/
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
const model = sanitizeModelName(raw);
if (!model) return false;
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
this._reportedModel = { model, source };
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
this.emit('displayModelChanged');
return true;
}
/**
* The model this session runs as far as the server knows, and where that came from:
* the custom endpoint's model, else the newest report from the CLI, else the launch
* model (src/session-display-model.ts). Undefined when none is known.
*/
get displayModel(): DisplayModel | undefined {
return resolveDisplayModel({
customModelId: this._customModel?.modelId,
reported: this._reportedModel,
configModel: this._configModel,
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
});
}
/**
* The same option bag the spawn reads its launch params from: `model` at the top for
* claude (the `--model` or app-wide default it was created with; inert for every other
* CLI, which is why it is not handed over for them), each other CLI's own
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
*/
private _launchOptionBag(): Record<string, unknown> {
return {
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
};
}
/**
* Read the model this session's CLI config pins, with the reader its registry entry
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
* bounded (the reader probes before it reads); a read that lands after a newer one,
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
* config on another machine or in its container, so nothing local is read for it.
*/
private _refreshConfigModel(): void {
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
if (!name || this._remote || this._docker) return;
const gen = ++this._configModelGen;
const overrides = this._envOverrides;
resolveConfigModel(name, {
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
// The session's own env first (already clamped for a non-granted owner), then the
// server's: what the pane's CLI inherits.
env: (key) => overrides?.[key] ?? process.env[key],
}).then(
(model) => {
if (gen !== this._configModelGen || this._isStopped) return;
// Sanitized where it is published (resolveDisplayModel), like every source.
const next = model || null;
if (next === this._configModel) return;
this._configModel = next;
this.emit('displayModelChanged');
},
() => {
/* A reader answers null on doubt and never throws; a throw changes nothing. */
}
);
}
/**
* Read the background-work chip off the same capture the working probe just took.
*
+28
View File
@@ -641,6 +641,24 @@ export interface CustomModelSelection {
label?: string;
}
/**
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
* - `config`: what the CLI's own config pins for this session
* (`capabilities.modelDetect.configResolver`), while its screen names none.
* - `launch`: what the session was launched with; nothing has reported since.
*/
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
/** The model a session runs as far as the server knows, for a session header. */
export interface DisplayModel {
/** Display text: sanitized (no control characters) and at most 64 characters. */
model: string;
source: DisplayModelSource;
}
/**
* The full custom-model selection a session keeps: the public selection plus the
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
@@ -847,6 +865,16 @@ export interface SessionState {
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
*/
customModel?: CustomModelSelection;
/**
* The model this session runs, as far as the server knows it, and where that came from
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
* from the CLI itself (statusline or its own footer), else the model its config pins,
* else the launch model. Absent when
* none is known; a session header then shows the harness alone. Untrusted display text
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
* value is restored after a restart until the next report replaces it.
*/
displayModel?: DisplayModel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/**
+19 -2
View File
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
/** Profile directory names that are not profiles. */
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
export function isProfileDirName(name: string): boolean {
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
}
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
const haystack = [name, ...bundles].join(' ');
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
*/
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
try {
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
} catch {
return null;
}
}
/**
* A profile from its directory name and the text of its `package.json`, or null when
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
* same classification as the inventory below.
*/
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
try {
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
const rawBundles = parsed?.dsh?.profile?.bundles;
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
let entries: string[];
try {
entries = readdirSync(profilesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
.map((e) => e.name);
} catch {
return [];
+342 -94
View File
@@ -381,8 +381,17 @@ function applyPaneExitBadge(tab, paneExit) {
tab.classList.toggle('tab-agent-exited', !!label);
// The tab's aria-label overrides its contents for the accessible name, and the
// badge is aria-hidden like its siblings, so the exit has to ride the label.
// Compared with the last English label set (data-aria-source, seeded by the
// full render too), never the attribute: in zh-CN the translator rewrites it,
// and writing English back on every pass would have it translate again.
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label));
if (name) {
const aria = paneExitAriaLabel(name, label);
if (tab.dataset.ariaSource !== aria) {
tab.dataset.ariaSource = aria;
tab.setAttribute('aria-label', aria);
}
}
if (!label) {
existing?.remove();
return;
@@ -391,16 +400,19 @@ function applyPaneExitBadge(tab, paneExit) {
const badge = document.createElement('span');
badge.className = 'tab-exited-badge';
badge.setAttribute('aria-hidden', 'true');
// Generated status text, like the status pills: it carries data-i18n-skip
// rather than a dictionary entry. Without it the translator would rewrite
// the badge and the next render pass would rewrite it back, because the
// comparison below is against the English string.
badge.setAttribute('data-i18n-skip', '');
// Translated like any other text (i18n.js has "exited" and its exit-code
// forms). The comparison below is with the last English label (data-label),
// never the DOM, which holds the translation in zh-CN: a DOM compare would
// write the English back on every pass for the translator to redo.
badge.dataset.label = label;
badge.textContent = label;
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
return;
}
if (existing.textContent !== label) existing.textContent = label;
if (existing.dataset.label !== label) {
existing.dataset.label = label;
existing.textContent = label;
}
}
const DEFAULT_SHORTCUTS = [
@@ -525,6 +537,106 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
// states (the focus chords only while the grid is open), and outside them the
// chord must reach the terminal untouched. Every xterm key handler swallows a
// chord that applies, so it never reaches a PTY. Defaults: Ctrl+Shift+G makes
// xterm emit nothing (a shifted Ctrl letter) and overrides only the browser's
// find-previous; Alt+Shift+Arrows are bound by no CLI Codeman runs. The
// arrow chords (focus, move) never apply in a text field, where shifted
// arrows select (tileShortcutFor).
{
id: 'toggle-tile-grid',
group: 'Tiles',
label: 'Toggle Tile Grid',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }],
action: 'toggleTileGrid',
},
{
id: 'focus-tile-left',
group: 'Tiles',
label: 'Focus Tile Left',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowLeft' }],
action: 'focusTileLeft',
},
{
id: 'focus-tile-right',
group: 'Tiles',
label: 'Focus Tile Right',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowRight' }],
action: 'focusTileRight',
},
{
id: 'focus-tile-up',
group: 'Tiles',
label: 'Focus Tile Up',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowUp' }],
action: 'focusTileUp',
},
{
id: 'focus-tile-down',
group: 'Tiles',
label: 'Focus Tile Down',
bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }],
action: 'focusTileDown',
},
// Move the focused tile: it trades places with the neighbour the focus
// chords would pick. Ctrl+Shift+Arrows because every other two-modifier
// arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and
// rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows
// move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords
// belong to the desktop, Alt+Arrows are the browser's back and forward, and
// Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and
// no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ }
// for moving tabs); what it costs is word selection, so the chords skip a
// text field (tileShortcutFor) and give up only a terminal editor's
// word selection (nano, micro, emacs) inside a tile while the grid is open.
{
id: 'move-tile-left',
group: 'Tiles',
label: 'Move Tile Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }],
action: 'moveTileLeft',
},
{
id: 'move-tile-right',
group: 'Tiles',
label: 'Move Tile Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }],
action: 'moveTileRight',
},
{
id: 'move-tile-up',
group: 'Tiles',
label: 'Move Tile Up',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }],
action: 'moveTileUp',
},
{
id: 'move-tile-down',
group: 'Tiles',
label: 'Move Tile Down',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }],
action: 'moveTileDown',
},
{
id: 'zoom-tile',
group: 'Tiles',
label: 'Zoom Focused Tile',
// Toggles. Alt+Shift+Enter would otherwise reach the CLI as ESC CR (Alt+Enter
// is a newline in Claude Code); it is swallowed only while the grid is open.
bindings: [{ modifiers: ['alt', 'shift'], key: 'Enter' }],
action: 'zoomTile',
},
{
id: 'remove-tile',
group: 'Tiles',
label: 'Remove Focused Tile',
// Unbound by default; the session keeps running either way.
bindings: [],
action: 'removeTile',
},
{
id: 'previous-next-session',
group: 'Session',
@@ -1197,6 +1309,11 @@ class CodemanApp {
try {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
// ⚠️ The observer sees EVERY long task on the page. While the tile grid
// owns the terminal the main terminal is parked and draws nothing; the
// long tasks are tile renders and replays (DOM renderers), and counting
// them would write the sticky 7-day WebGL disable for no WebGL reason.
if (this._tilesOwnTerminal?.()) return;
const now = performance.now();
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
@@ -1300,6 +1417,12 @@ class CodemanApp {
this._tabLayoutDragKeydown(e);
return;
}
// So does the Tiles count menu: it closes alone and gives the keyboard
// back to the Tiles button (tile-grid.js).
if (this._tileCountMenu) {
this.closeTileCountMenu({ refocus: true });
return;
}
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
@@ -1357,6 +1480,15 @@ class CodemanApp {
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
// so outside the grid Alt+Shift+Arrows still reach the terminal.
const tileShortcut = this.tileShortcutFor?.(e);
if (tileShortcut) {
e.preventDefault();
this.runTileShortcut(tileShortcut);
return;
}
// Match against the shortcut registry so user rebinds and per-shortcut
// disables (App Settings → Shortcuts) take effect. Every dispatchable
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
@@ -1390,18 +1522,31 @@ class CodemanApp {
// SSE Connection
// ═══════════════════════════════════════════════════════════════
/**
* The session id the SSE filter names for `sessionId`: itself, or while the
* tile grid owns the terminal the grid's fixed filter (TILE_GRID_SSE_FILTER,
* constants.js), which no session matches. Both places that set the filter
* ask here: the live re-subscribe below and the connect URL (connectSSE),
* which an SSE reconnect rebuilds with the grid still open.
*/
_sseFilterSessionId(sessionId) {
if (this._tilesOwnTerminal?.()) return window.CodemanTileGrid?.TILE_GRID_SSE_FILTER || sessionId;
return sessionId;
}
/**
* POST a live subscription update so the server filters terminal events
* to the given session(s) for this client. Fire-and-forget — failures
* to the given session(s) for this client. Fire-and-forget: failures
* are non-fatal because we'll still get every event we don't want
* (just at higher cost), and the next reconnect carries the filter via
* the SSE query string.
*/
_updateSseSubscription(sessionId) {
try {
const filterId = this._sseFilterSessionId(sessionId);
const body = JSON.stringify({
clientId: this._clientId,
sessions: sessionId ? [sessionId] : null,
sessions: filterId ? [filterId] : null,
});
fetch('/api/events/subscribe', {
method: 'POST',
@@ -1466,7 +1611,9 @@ class CodemanApp {
return false;
}
this._retireUrlSession();
this.selectSession(id, { auto: true });
// Following a link is navigation: a tiled id focuses its tile, any other
// leaves the tile grid for the single view (the grid is remembered).
this.selectSession(id, { auto: true, leaveTiles: true });
return true;
}
@@ -1605,6 +1752,9 @@ class CodemanApp {
* Full re-renders re-apply the class from this.detachedSessions. */
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
// A popped-out session's window owns its PTY size now, so it leaves the
// tile grid (one place per session in this browser tab).
if (on && this._tileGrid?.has(id)) this.removeTile(id);
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
@@ -1792,7 +1942,8 @@ class CodemanApp {
// session we're rendering. Lifecycle/metadata events are sent globally
// regardless of filter (server side).
const _sseParams = new URLSearchParams({ clientId: this._clientId });
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
const _sseFilterId = this._sseFilterSessionId(this.activeSessionId);
if (_sseFilterId) _sseParams.set('sessions', _sseFilterId);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
// Store all event listeners for cleanup on reconnect.
@@ -2202,6 +2353,10 @@ class CodemanApp {
}
_onSessionTerminal(data) {
// Tile grid open: the main terminal is parked and its socket closed, so the
// SSE fallback would write the focused tile's output into a hidden xterm.
// The tiles carry their own output over their own sockets.
if (this._tilesOwnTerminal?.()) return;
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
@@ -2251,6 +2406,8 @@ class CodemanApp {
*/
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
if (!sessionId || this._clientDropRecoveryTimer) return;
// Nothing of the main terminal's to recover while the tile grid owns it.
if (this._tilesOwnTerminal?.()) return;
// Behind the debounce guard: one line per window, not per dropped frame.
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
this._clientDropRecoveryTimer = setTimeout(async () => {
@@ -2928,6 +3085,10 @@ class CodemanApp {
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
// Tile grid open: the main terminal is parked, so this would fetch a capture
// for a hidden xterm. Each tile refreshes itself through the grid's queue.
// `false`, never undefined: the drop recovery reads the result.
if (this._tilesOwnTerminal?.()) return false;
const sessionId = this.activeSessionId;
if (event?.id && event.id !== sessionId) return false;
if (!sessionId || !this.terminal) return false;
@@ -3030,6 +3191,8 @@ class CodemanApp {
}
async _onSessionClearTerminal(data) {
// The tiles get the clear over their own sockets; the parked main terminal must not refetch.
if (this._tilesOwnTerminal?.()) return;
if (data.id === this.activeSessionId) {
// Skip if selectSession is already loading the buffer — clearTerminal arriving
// during buffer load would clear the terminal mid-write, causing visible flicker
@@ -3075,14 +3238,15 @@ class CodemanApp {
_onSessionCompletion(data) {
this.totalCost += data.cost || 0;
this.updateCost();
if (data.id === this.activeSessionId) {
// Not into the parked main terminal while the tile grid owns the screen.
if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) {
this.terminal.writeln('');
this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`);
}
}
_onSessionError(data) {
if (data.id === this.activeSessionId) {
if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) {
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
}
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
@@ -4034,9 +4198,12 @@ class CodemanApp {
}
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
// While the tile grid owns the terminal the main socket is parked on
// purpose, so the state comes from the tiles' sockets: all open is
// connected, any still coming back is reconnecting.
if (this.activeSessionId) {
let cls, label, detail;
switch (this._wsState) {
switch (this._tilesOwnTerminal?.() ? this._tileGridSocketState() : this._wsState) {
case 'connected':
cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket';
break;
@@ -4217,8 +4384,11 @@ class CodemanApp {
this._updateConnectionLossUi();
this.connectSSE();
// The terminal socket does not always come back on its own (planWsReconnect
// 'give-up'), so the same button re-arms it.
if (this.activeSessionId && this._wsState !== 'connected') {
// 'give-up'), so the same button re-arms it. With the tile grid open the
// main socket is parked on purpose: re-arm the tiles' sockets instead.
if (this._tilesOwnTerminal?.()) {
for (const { tile } of this._tileGrid.tiles.values()) tile.reconnectNow();
} else if (this.activeSessionId && this._wsState !== 'connected') {
this._wsReconnectAttempts = 0;
this._connectWs(this.activeSessionId);
}
@@ -4561,8 +4731,16 @@ class CodemanApp {
return;
}
// Tile grid open (tile-grid.js): tiles whose sessions are gone are removed,
// the live ones are kept as they are and reconnect now, and the main
// terminal stays parked. Before the link below, which may leave the grid.
const tilesOpen = this._reconcileTileGrid?.() === true;
// A `#session=<id>` link wins over restoring the last active tab.
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
// And over a tile grid stored open: it stays remembered, closed, rather
// than reappearing unexplained on the next reload.
if (!tilesOpen) this._closeStoredTileGrid?.();
this.activeSessionId = null;
this._selectUrlSession();
return;
@@ -4570,6 +4748,10 @@ class CodemanApp {
// Not listed yet: its wait starts now that the list has loaded, and the
// last active tab is restored meanwhile.
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
// The grid holds the focused session: nothing below may reconnect or reload
// the parked main terminal (its keepTerminal branch would reopen the main
// socket onto a session a tile already shows).
if (tilesOpen) return;
const previousActiveId = this.activeSessionId;
if (this.sessionOrder.length === 0) {
@@ -4594,6 +4776,11 @@ class CodemanApp {
this._splitPane?.reconnectNow?.();
} else {
this.activeSessionId = null;
// A tile grid stored open on this device (tile-grid.js) comes back IN
// PLACE of the single-view restore below, so the main terminal never
// loads (its first select would pull a whole-history capture only to
// be parked a moment later).
if (this._restoreTileGrid?.()) return;
// `auto`: the app is restoring a session on load, not a human opening
// one, so a pending idle alert on that tab stays armed until it is
// actually tapped (see the userInitiated note in selectSession).
@@ -5550,6 +5737,9 @@ class CodemanApp {
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
// Tile grid membership (tile-grid.js): a tiled session's tab says so.
tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));
// Inject tab-number badge if missing (added after initial render)
if (!tab.querySelector('.tab-number')) {
const idx = this.sessionOrder.indexOf(id);
@@ -5903,7 +6093,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5911,7 +6101,7 @@ class CodemanApp {
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -6327,6 +6517,10 @@ class CodemanApp {
handleSessionTabClick(event, sessionId) {
event?.preventDefault?.();
// Ctrl/Cmd+click puts the session in the tile grid (opening it if needed)
// instead of switching to it; on a window too narrow for the grid it is an
// ordinary click.
if ((event?.ctrlKey || event?.metaKey) && this.addSessionToTiles?.(sessionId)) return;
// On touch with the keyboard hidden, blur the tapped tab so switching
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
// in selectSession via _shouldFocusTerminalForTabSwitch().
@@ -6803,6 +6997,7 @@ class CodemanApp {
const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false;
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
@@ -7438,7 +7633,9 @@ class CodemanApp {
}
}
_cleanupPreviousSession(newSessionId) {
// `skipSnapshot`: the tile grid opening on the session it parks
// (tile-grid.js openTileGrid), whose snapshot closing the grid discards.
_cleanupPreviousSession(newSessionId, { skipSnapshot = false } = {}) {
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
// exact view on switch-back rather than replaying codex's byte stream, which
@@ -7449,6 +7646,7 @@ class CodemanApp {
// quota for them. Unknown/undefined mode still snapshots, matching restore.
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
if (
!skipSnapshot &&
this.activeSessionId &&
outgoingSession?.mode !== 'shell' &&
this._serializeAddon &&
@@ -7665,6 +7863,8 @@ class CodemanApp {
const sessionId = this.activeSessionId;
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
if (this.detachedSessions?.has(sessionId)) return;
// The parked main terminal has no history to pull while tiles own the screen.
if (this._tilesOwnTerminal?.()) return;
const session = this.sessions.get(sessionId);
// A shell's full capture can be many megabytes, and replaying all of it from
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
@@ -7938,6 +8138,20 @@ class CodemanApp {
if (userInitiated) this.markIdleAlertSeen(sessionId);
return;
}
// Tile grid open (tile-grid.js): a tiled session is FOCUSED in its tile and
// never loaded into the parked main terminal. Decision 1: only a USER-
// initiated pick of a session that is not tiled (or an explicit
// `leaveTiles`, a followed link) leaves the grid for the single view, the
// grid remembered for one-click return. An app-driven pick (`auto`) never
// collapses it.
if (this._tileGrid?.open) {
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
if (options?.auto === true && !options?.leaveTiles) return;
this.closeTileGrid({ keepStored: true, reselect: false });
// The parked terminal still holds what it showed before the grid opened;
// with no active id, the switch below snapshots none of it.
this.activeSessionId = null;
}
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
@@ -8513,78 +8727,7 @@ class CodemanApp {
// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16);
idleCb(() => {
// Guard against stale generation — user may have switched tabs again
if (selectGen !== this._selectGeneration) return;
// Update respawn banner
if (this.respawnStatus[sessionId]) {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open
const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) {
this.renderTaskPanel();
}
// Update ralph state panel for this session
const curSession = this.sessions.get(sessionId);
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: curSession.ralphLoop,
todos: curSession.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// Update project insights panel for this session
this.renderProjectInsightsPanel();
// Update subagent window visibility for active session
this.updateSubagentWindowVisibility();
// Load file browser if enabled
const settings = this.loadAppSettingsFromStorage();
if (settings.showFileBrowser) {
const fileBrowserPanel = this.$('fileBrowserPanel');
if (fileBrowserPanel) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(sessionId);
// Attach drag listeners if not already attached
if (!this.fileBrowserDragListeners) {
const header = fileBrowserPanel.querySelector('.file-browser-header');
if (header) {
const onFirstDrag = () => {
if (!fileBrowserPanel.style.left) {
const rect = fileBrowserPanel.getBoundingClientRect();
fileBrowserPanel.style.left = `${rect.left}px`;
fileBrowserPanel.style.top = `${rect.top}px`;
fileBrowserPanel.style.right = 'auto';
}
};
header.addEventListener('mousedown', onFirstDrag);
header.addEventListener('touchstart', onFirstDrag, { passive: true });
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
}
}
});
idleCb(() => this._refreshSessionPanels(sessionId, selectGen));
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
this._connectWs(sessionId);
@@ -8707,6 +8850,90 @@ class CodemanApp {
}
}
/**
* The panels that follow the active session (respawn banner and countdown,
* action log, task panel, Ralph state, CLI info, project insights, subagent
* window visibility, file browser). Run deferred, after the terminal content
* is on screen, by selectSession and by the tile grid's focus change
* (tile-grid.js _selectTiledSession), so both share one copy.
*
* @param {string} sessionId - the session that just became active
* @param {number} selectGen - the `_selectGeneration` of that selection; a
* newer one (the user switched again) makes this a no-op
*/
_refreshSessionPanels(sessionId, selectGen) {
// A newer selection won: the user switched tabs again.
if (selectGen !== this._selectGeneration) return;
// Update respawn banner
if (this.respawnStatus[sessionId]) {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open
const taskPanel = document.getElementById('taskPanel');
if (taskPanel && taskPanel.classList.contains('open')) {
this.renderTaskPanel();
}
// Update ralph state panel for this session
const curSession = this.sessions.get(sessionId);
if (curSession && (curSession.ralphLoop || curSession.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: curSession.ralphLoop,
todos: curSession.ralphTodos
});
}
this.renderRalphStatePanel();
// Update CLI info bar (mobile - shows Claude version/model)
this.updateCliInfoDisplay();
// Update project insights panel for this session
this.renderProjectInsightsPanel();
// Update subagent window visibility for active session
this.updateSubagentWindowVisibility();
// Load file browser if enabled
const settings = this.loadAppSettingsFromStorage();
if (settings.showFileBrowser) {
const fileBrowserPanel = this.$('fileBrowserPanel');
if (fileBrowserPanel) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(sessionId);
// Attach drag listeners if not already attached
if (!this.fileBrowserDragListeners) {
const header = fileBrowserPanel.querySelector('.file-browser-header');
if (header) {
const onFirstDrag = () => {
if (!fileBrowserPanel.style.left) {
const rect = fileBrowserPanel.getBoundingClientRect();
fileBrowserPanel.style.left = `${rect.left}px`;
fileBrowserPanel.style.top = `${rect.top}px`;
fileBrowserPanel.style.right = 'auto';
}
};
header.addEventListener('mousedown', onFirstDrag);
header.addEventListener('touchstart', onFirstDrag, { passive: true });
this.fileBrowserDragListeners = this.makeWindowDraggable(fileBrowserPanel, header);
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
}
}
}
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
@@ -8780,12 +9007,24 @@ class CodemanApp {
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
// Captured here for the same reason as wasActive: the SSE delete can remove
// the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
// The last tile leaving closes the grid (no reselect): the pick below runs.
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
if (wasActive) {
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
const target = grid.has(tileNeighborId) ? tileNeighborId : grid.ids[0];
this._selectTiledSession(target, { auto: true });
} else if (wasActive) {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
// Next tab in the user's own order, skipping ids the cleanup has not
@@ -8870,6 +9109,8 @@ class CodemanApp {
}
nextSession() {
// With the tile grid open, Ctrl+Tab and Alt+[ / Alt+] cycle through the tiles.
if (this._cycleTileFocus?.(1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
@@ -8878,6 +9119,7 @@ class CodemanApp {
}
prevSession() {
if (this._cycleTileFocus?.(-1)) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
@@ -8893,6 +9135,8 @@ class CodemanApp {
// Going Home is choosing something else, so a `#session=<id>` link still
// waiting for its session must not take the screen later.
this._retireUrlSession();
// Home is a choice to leave the grid too; it is remembered for Tiles.
this.closeTileGrid?.({ keepStored: true, reselect: false });
// Deselect active session and show welcome screen
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
@@ -8943,6 +9187,8 @@ class CodemanApp {
try {
await this._apiDelete('/api/sessions');
// Every tiled session is gone: nothing left to remember or reselect.
this.closeTileGrid?.({ keepStored: false, reselect: false });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
@@ -9139,14 +9385,16 @@ class CodemanApp {
groups[g].push(shortcut);
}
const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
// Key names, never translated: "Home" is also a dictionary word (the Home
// button), so without the skip zh-CN showed the key as 主页.
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd data-i18n-skip>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return '';
// An action with no key (Close Session by default) is still listed, so the
// overlay says so instead of showing an empty key column.
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
return `<kbd data-i18n-skip>${escapeHtml(parts.join('+'))}</kbd>`;
}).join(' / ');
};
list.innerHTML = Object.entries(groups).map(([group, items]) =>
+520 -10
View File
@@ -1612,27 +1612,459 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
const result = [];
for (const id of sessionOrder) {
if (id === excludeId) continue;
// A detached (popped-out) session's own window already yields its PTY
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
// Pane B's TerminalTile._sendResize() has no such check, so letting
// one into the picker put its detached window and Pane B in a fight over
// the same PTY's dimensions.
// A detached (popped-out) session's own window owns its PTY size (see
// sendResize's detachedElsewhere guard in terminal-ui.js;
// TerminalTile._sendResize() stands aside the same way), so Pane B could
// only show it at a size it cannot set.
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
// A session with no PTY attached (exited CLI, a crash-looped session
// whose breaker tripped, a restore that failed to re-attach) has nothing
// reading its tmux pane. TerminalTile never does selectSession()'s
// re-attach POST, so its socket would open onto a pane nothing feeds:
// no terminal events, and Session.write() silently drops every keystroke
// with no ack either way (Pane B sends no `seq`), so the loss is
// invisible — the healthy socket never trips the disconnect banner.
// reading its tmux pane, and the split never does selectSession()'s
// re-attach POST: Pane B would open a healthy-looking socket onto a pane
// that nothing feeds and nothing reads.
if (session.pid === null) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
// ── Tile grid (tile-grid.js) ───────────────────────────────────────────────
//
// Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md):
// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile.
// Desktop only, behind the same 1180px gate as the split pane.
/**
* Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in
* docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
* the headless frame bar and is untested on hardware. Everything that limits
* the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX,
* so raising the cap is this one line.
*/
const TILE_GRID_MAX = 6;
/** The largest count the layout table covers (3x3). Never a cap by itself. */
const TILE_LAYOUT_MAX = 9;
// The smallest tile worth showing: about 60 columns and a dozen rows at the
// default tile font. Bounds how many tiles a window can hold.
const TILE_MIN_W = 480;
const TILE_MIN_H = 240;
// Three tiles go side by side (3x1) only when each still gets ~600px;
// otherwise they take three cells of a 2x2.
const TILE_GRID_WIDE_3X1 = 1800;
// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM
// renderers at 50k lines each is a real memory cost, and a tile's load is a
// bounded 1 MiB window anyway, so more scrollback only fills with live output.
const TILE_SCROLLBACK = 10000;
// Tiles have their own per-device font size (a tile is a fraction of the screen).
const TILE_FONT_SIZE_DEFAULT = 13;
// What the page's SSE filter names while tiles own the terminal: a value no
// session id takes (ids are UUIDs), so the server, whose filter gates only
// session:terminal batches, sends none. The tiles carry their own output over
// their own sockets, and the parked main terminal only parsed those frames to
// drop them (16 to 18 a second for one busy shell). Every other event still
// arrives (test/sse-tile-grid-filter.test.ts pins the server's side of this).
const TILE_GRID_SSE_FILTER = 'tile-grid';
/**
* Columns and rows for `count` tiles, by count (the spec's table), and whether
* that layout gives every cell at least the minimum tile size (TILE_MIN_W x
* TILE_MIN_H) in a grid area of `width` x `height` px.
*
* @param {{count: number, width?: number, height?: number}} p
* @returns {{cols: number, rows: number, fits: boolean}}
*/
function computeTileLayout({ count, width = Infinity, height = Infinity }) {
const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX);
let cols;
let rows;
if (n === 0) return { cols: 0, rows: 0, fits: true };
if (n === 1) { cols = 1; rows = 1; }
else if (n === 2) { cols = 2; rows = 1; }
else if (n === 3) {
if (width >= TILE_GRID_WIDE_3X1) { cols = 3; rows = 1; }
else { cols = 2; rows = 2; }
}
else if (n === 4) { cols = 2; rows = 2; }
else if (n <= 6) { cols = 3; rows = 2; }
else { cols = 3; rows = 3; }
const fits = width / cols >= TILE_MIN_W && height / rows >= TILE_MIN_H;
return { cols, rows, fits };
}
/**
* How many tiles a grid area can hold: the largest count up to TILE_GRID_MAX
* whose layout, and every smaller count's layout, fits. 0 when not even one
* tile fits.
*
* @param {{width: number, height: number}} p
* @returns {number}
*/
function tileGridCapacity({ width, height }) {
let capacity = 0;
for (let n = 1; n <= TILE_GRID_MAX; n++) {
if (!computeTileLayout({ count: n, width, height }).fits) break;
capacity = n;
}
return capacity;
}
/**
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
* deleted, detached and duplicate ids are dropped, the list is capped at
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
* (or its JSON) gives null.
*
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
* with no hole. `ids` comes back packed (the tiles in reading order, what
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
*
* @param {unknown} raw - the parsed value, or the stored JSON string
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
*/
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
let value = raw;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { return null; }
}
if (!value || typeof value !== 'object' || Array.isArray(value) || value.v !== 1) return null;
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
const ids = [];
const cells = [];
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
const keep =
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
ids.length < TILE_GRID_MAX;
if (keep) ids.push(id);
// A malformed entry (not a string, not null) is a hole too.
cells.push(keep ? id : null);
}
const fractions = (fr) => {
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
};
return {
v: 1,
open: value.open === true && ids.length > 0,
ids,
cells,
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
colFr: fractions(value.colFr),
rowFr: fractions(value.rowFr),
};
}
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
* least `minPx` (or half the pair, if the pair cannot give both the minimum).
* Every other track keeps its size. Computed from the fractions the drag
* STARTED with and the pointer's total travel, so a drag never drifts.
*
* @param {number[]} fr - the fractions when the drag started
* @param {number} index - the divider: between track `index` and `index + 1`
* @param {number} deltaPx - pointer travel since the drag started
* @param {number} totalPx - the size the tracks share (dividers and padding excluded)
* @param {number} minPx - the smallest a track may get
* @returns {number[]} new fractions, same length
*/
function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
const out = fr.slice();
if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
const sum = fr.reduce((a, b) => a + b, 0);
if (!(sum > 0)) return out;
const a = (fr[index] / sum) * totalPx;
const b = (fr[index + 1] / sum) * totalPx;
const pair = a + b;
const lo = Math.min(minPx, pair / 2);
const hi = pair - lo;
const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
out[index] = (nextA / totalPx) * sum;
out[index + 1] = ((pair - nextA) / totalPx) * sum;
return out;
}
/**
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay.
*
* @param {Map<string, {name?: string}>} sessions
* @param {string[]} sessionOrder
* @param {{has(id: string): boolean}} [detachedIds]
* @returns {Array<{id: string, label: string}>}
*/
function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
const result = [];
for (const id of sessionOrder) {
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
result.push({ id, label: session.name || 'Session' });
}
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 (buildTilePickerSessions: no
* detached ones), up to `limit`, the active session always among them and focused
* (when it sits past the limit, the first `limit - 1` others come with it).
* Null when there is nothing to open.
*
* @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] };
}
/**
* The tile counts the Tiles button's right-click menu offers, and the count a
* click opens until one is picked (owner decision 10 in docs/tile-grid-plan.md).
*/
const TILE_GRID_COUNTS = [2, 4, 6];
const TILE_GRID_COUNT_DEFAULT = 6;
/** A remembered tile count made safe: one of TILE_GRID_COUNTS, else the default. */
function sanitizeTileCount(raw) {
const n = Number(raw);
return TILE_GRID_COUNTS.includes(n) ? n : TILE_GRID_COUNT_DEFAULT;
}
/**
* `base` (what the grid would open, or what an open grid shows, in its order)
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
*
* @param {string[]} base
* @param {string[]} all
* @param {number} n - at most TILE_GRID_MAX
* @param {string|null} [keepId]
* @returns {string[]}
*/
function tileGridSetForCount(base, all, n, keepId = null) {
const max = Math.max(1, Math.min(Math.floor(Number(n) || 0), TILE_GRID_MAX));
const ids = [];
for (const id of [...(base || []), ...(all || [])]) {
if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
}
// Every `base` id comes before every filler, so a trim never drops a base id
// in favour of one.
let out = ids.slice(0, max);
if (keepId && ids.includes(keepId) && !out.includes(keepId)) out = [...out.slice(0, max - 1), keepId];
return out;
}
/**
* Which tile takes focus when `id` leaves the grid: the next one in grid
* order, else the previous one, else null.
*
* @param {string[]} ids - the grid's tiles, in reading order
* @param {string} id - the tile that is leaving
* @returns {string|null}
*/
function tileNeighbor(ids, id) {
const i = ids.indexOf(id);
if (i === -1) return ids[0] ?? null;
return ids[i + 1] ?? ids[i - 1] ?? null;
}
/**
* The tile a directional focus chord moves to, in a row-major grid of `cols`
* columns whose empty cells are `null` (a hole can be any cell) or simply
* missing at the end. Focus never lands on a hole. Left and right go along
* the row, past any hole, and never leave it. Up and down take the nearest
* row in that direction that has a tile: the tile in the same column, else
* the one in the nearest column (the lower column on a tie), so moving down
* onto a short or holed last row lands on its nearest tile. Null when there
* is no tile in that direction.
*
* @param {(string|null)[]} cells - the grid's cells in reading order (or its packed tiles)
* @param {string} focusedId - the tile the keyboard is in
* @param {'left'|'right'|'up'|'down'} direction
* @param {number} cols - the layout's column count
* @returns {string|null}
*/
function tileInDirection(cells, focusedId, direction, cols) {
const i = focusedId ? cells.indexOf(focusedId) : -1;
if (i === -1 || !(cols >= 1)) return null;
const rows = Math.ceil(cells.length / cols);
const row = Math.floor(i / cols);
const col = i % cols;
const at = (r, c) => cells[r * cols + c] || null;
if (direction === 'left' || direction === 'right') {
const step = direction === 'left' ? -1 : 1;
for (let c = col + step; c >= 0 && c < cols; c += step) {
if (at(row, c)) return at(row, c);
}
return null;
}
if (direction !== 'up' && direction !== 'down') return null;
const step = direction === 'up' ? -1 : 1;
for (let r = row + step; r >= 0 && r < rows; r += step) {
let best = null;
let bestDistance = Infinity;
for (let c = 0; c < cols; c++) {
const id = at(r, c);
if (id && Math.abs(c - col) < bestDistance) {
best = id;
bestDistance = Math.abs(c - col);
}
}
if (best) return best;
}
return null;
}
/**
* The cell next to cell `index` in that direction (Move Tile: a tile moves
* into an empty neighbour cell, or swaps with a tiled one), or -1 at the
* edge. Adjacent only: a move never jumps over a cell.
*
* @param {number} index - the cell, in reading order
* @param {'left'|'right'|'up'|'down'} direction
* @param {number} cols - the layout's column count
* @param {number} cellCount - cols x rows
* @returns {number}
*/
function tileCellInDirection(index, direction, cols, cellCount) {
if (!(cols >= 1) || index < 0 || index >= cellCount) return -1;
const col = index % cols;
let j = -1;
if (direction === 'left') j = col > 0 ? index - 1 : -1;
else if (direction === 'right') j = col < cols - 1 ? index + 1 : -1;
else if (direction === 'up') j = index - cols;
else if (direction === 'down') j = index + cols;
return j >= 0 && j < cellCount ? j : -1;
}
/**
* The grid's cells after its shape changed (or to fill one for the first
* time): `cols` x `rows` cells, `null` for an empty one. The same shape keeps
* every cell as it is. A new shape keeps each tile at its row and column when
* every tile still fits there (2x2 growing to 3x2: the four tiles stay put),
* and otherwise packs the tiles in reading order from the first cell, holes
* collapsed (positions do not map between shapes). `oldCols` 0 (no layout
* yet) always packs.
*
* @param {(string|null)[]} cells - the current cells, laid out `oldCols` wide
* @param {number} oldCols - the column count they were laid out with
* @param {number} cols
* @param {number} rows
* @returns {(string|null)[]}
*/
function fitTileCells(cells, oldCols, cols, rows) {
const size = Math.max(0, cols * rows);
if (oldCols === cols && cells.length === size) return cells.slice();
const out = new Array(size).fill(null);
const placed = cells.map((id, k) => (id ? { id, row: Math.floor(k / oldCols), col: k % oldCols } : null));
const keep = oldCols >= 1 && placed.every((p) => !p || (p.row < rows && p.col < cols));
if (keep) {
for (const p of placed) if (p) out[p.row * cols + p.col] = p.id;
return out;
}
cells.filter(Boolean).slice(0, size).forEach((id, k) => {
out[k] = id;
});
return out;
}
/**
* How many columns a grid of `length` cells was laid out with: stored cells
* carry no shape of their own, and the layout table gives each cell count one
* shape (computeTileLayout: 1x1, 2x1, 3x1, 2x2, 3x2, 3x3). 0 for any other
* length (fitTileCells then packs).
*
* @param {number} length
* @returns {number}
*/
function tileCellCols(length) {
// Every cell count is some count's shape on a wide grid area (2x2, the
// narrow 3-tile shape, is also the 4-tile one).
for (let n = 1; n <= TILE_LAYOUT_MAX; n++) {
const { cols, rows } = computeTileLayout({ count: n });
if (cols * rows === length) return cols;
}
return 0;
}
/**
* The cells of a grid re-formed to another set of tiles (a count picked in the
* Tiles menu, or the Tiles button bringing back a remembered grid with more or
* fewer tiles): the tiles in `keep` stay in their cells and every other cell
* empties, then the cell model's shape rule (fitTileCells: each tile keeps its
* row and column when all fit, else they pack in reading order), then the
* tiles in `add` fill the empty cells in reading order, holes first.
*
* @param {(string|null)[]} cells - the cells now, laid out `oldCols` wide
* @param {number} oldCols
* @param {string[]} keep - tiles that stay
* @param {string[]} add - tiles that join, in the order they fill
* @param {number} cols - the new shape
* @param {number} rows
* @returns {(string|null)[]}
*/
function reformTileCells(cells, oldCols, keep, add, cols, rows) {
const kept = (cells || []).map((id) => (id && keep.includes(id) ? id : null));
const out = fitTileCells(kept, oldCols, cols, rows);
for (const id of add) {
if (!id || out.includes(id)) continue;
const k = out.indexOf(null);
// Not for a shape made for the count; a full grid takes no more.
if (k === -1) break;
out[k] = id;
}
return out;
}
/**
* The tile Ctrl+Tab / Alt+] (delta 1) or Alt+[ (delta -1) moves to while the
* grid is open: tiles cycle in reading order and wrap.
*
* @param {string[]} ids
* @param {string} focusedId
* @param {number} delta - +1 or -1
* @returns {string|null}
*/
function cycleTile(ids, focusedId, delta) {
if (ids.length === 0) return null;
const i = ids.indexOf(focusedId);
if (i === -1) return ids[0];
return ids[(i + delta + ids.length) % ids.length];
}
// ── Renderer liveness ──────────────────────────────────────────────────────
//
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
@@ -1869,7 +2301,59 @@ function sessionIdFromFragment(hash) {
return id && id.trim() ? id.trim() : null;
}
/** Longest model name a session header shows (the server caps it as well). */
const SESSION_MODEL_MAX_CHARS = 64;
/** A CLI registry id (src/config/cli-registry/schema.ts); anything else is not a class name. */
const CLI_ID_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
/**
* What a session's header says about its harness: the CLI id (the
* `run-mode-dot <id>` logo class), the registry's label for it, the model the
* session runs when the server knows it (`SessionState.displayModel`), and the
* tooltip naming both.
*
* The id is data: the label comes from the injected CLI catalog and falls back
* to the id, so a CLI added through clis.json still gets a name. The model is
* untrusted text (read off a pane, or a CLI's own report): control characters
* are dropped and the length capped here too, and callers render it with
* textContent. The tooltip says where a model that is not the CLI's own report
* came from, so it never claims more than the server knows: one the session
* was launched with may have been switched since, one read from the CLI's
* config is what it is configured to run, and a custom endpoint's model is the
* endpoint's, whatever the CLI calls it.
*
* @param {object} session - a session from app.sessions
* @param {Array<{id: string, label?: string}>} [catalog] - window.__codemanCliCatalog
* @returns {{id: string, label: string, model: string, title: string}}
*/
function describeSessionHarness(session, catalog) {
const id = typeof session?.mode === 'string' && CLI_ID_PATTERN.test(session.mode) ? session.mode : '';
const entry = id && Array.isArray(catalog) ? catalog.find((cli) => cli?.id === id) : null;
const label = (typeof entry?.label === 'string' && entry.label.trim()) || id;
const raw = session?.displayModel?.model;
const model =
typeof raw === 'string'
? raw
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
.trim()
.slice(0, SESSION_MODEL_MAX_CHARS)
: '';
const source = session?.displayModel?.source;
const qualifier = !model
? ''
: source === 'launch'
? ' (set at launch)'
: source === 'custom-endpoint'
? ' (custom endpoint)'
: source === 'config'
? ' (from config)'
: '';
const title = [label, model].filter(Boolean).join(' \u00B7 ') + qualifier;
return { id, label, model, title };
}
if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
@@ -1879,6 +2363,32 @@ if (typeof window !== 'undefined') {
buildSplitPickerSessions,
SPLIT_PANE_MIN_WIDTH,
};
window.CodemanTileGrid = {
computeTileLayout,
tileGridCapacity,
sanitizeTileGridState,
buildTilePickerSessions,
dragTrackFractions,
tileNeighbor,
tileInDirection,
tileCellInDirection,
fitTileCells,
cycleTile,
tileGridOpenSet,
sanitizeTileCount,
tileGridSetForCount,
tileCellCols,
reformTileCells,
TILE_GRID_COUNTS,
TILE_GRID_COUNT_DEFAULT,
TILE_GRID_MAX,
TILE_LAYOUT_MAX,
TILE_MIN_W,
TILE_MIN_H,
TILE_SCROLLBACK,
TILE_FONT_SIZE_DEFAULT,
TILE_GRID_SSE_FILTER,
};
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
window.CodemanFetchDeadline = {
terminalFetchDeadlineMs,
+174
View File
@@ -96,6 +96,80 @@
'Split: close the second session': '分屏:关闭第二个会话',
'Close split': '关闭分屏',
'No other sessions to split with': '没有其他可用于分屏的会话',
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
// Counts, exit codes and durations are patterns in translateDynamic.
Tiles: '平铺',
Split: '分屏',
'Tiled sessions': '平铺的会话',
'Tiles: show several sessions side by side (right-click for how many)':
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
'How many tiles': '窗格数量',
// The Tiles button's hover card (the count and the fits note are patterns).
'Click: open the grid': '单击:打开平铺网格',
'Click: close the grid': '单击:关闭平铺网格',
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
'Toggle Tile Grid': '切换平铺网格',
'Focus Tile Left': '聚焦左侧窗格',
'Focus Tile Right': '聚焦右侧窗格',
'Focus Tile Up': '聚焦上方窗格',
'Focus Tile Down': '聚焦下方窗格',
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
'Move Tile Left': '向左移动窗格',
'Move Tile Right': '向右移动窗格',
'Move Tile Up': '向上移动窗格',
'Move Tile Down': '向下移动窗格',
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
'a tab': '标签页',
'the Tiles button': '平铺按钮',
Click: '单击',
'Right-click': '右键单击',
Arrows: '方向键',
'not bound': '未绑定',
'Open group as tiles': '以平铺方式打开分组',
'No sessions to show as tiles': '没有可平铺显示的会话',
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
'Zoom this tile': '放大此窗格',
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格',
'Resize tile columns': '调整窗格列宽',
'Resize tile rows': '调整窗格行高',
Attach: '附加',
'Attaching…': '正在附加…',
'Not attached': '未附加',
'The session ended': '会话已结束',
'The agent exited': '智能体已退出',
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
'Could not attach the session': '无法附加会话',
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
// its exit-code forms and the tab's accessible name are patterns.
exited: '已退出',
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
// are a pattern; mode codes and product names stay), and the toolbar beside it.
'Terminal / Shell': '终端 / Shell',
'Send Enter': '发送回车',
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
// input like Click (单击).
Tabs: '标签页',
'Toggle Session Sidebar': '切换会话侧边栏',
'Copy Selection': '复制选中内容',
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
'Focus Tabs': '聚焦标签页',
Wheel: '滚轮',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
@@ -951,6 +1025,17 @@
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
}
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
const TILE_STATE_ZH = {
'needs you': '需要你',
error: '错误',
waiting: '等待中',
working: '工作中',
idle: '空闲',
done: '已完成',
exited: '已退出',
};
function translateDynamic(source) {
const patterns = [
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
@@ -975,6 +1060,71 @@
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
],
// Tile grid: counts, exit codes and durations pass through.
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
[
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
],
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
[
/^The grid already holds what this window fits \((\d+)\)$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
],
[
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
],
[
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
],
[
/^The window is too small for (\d+) tiles: showing the focused one$/,
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
// and model names pass through untranslated.
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
// The tab's exited-agent badge, and the tab's accessible name carrying it.
// The session name is user text: it passes through untranslated.
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
[
/^(.+) session, agent exited \(signal (\d+)\)$/,
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
],
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
// A session name is user text: it passes through untranslated.
[
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
],
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
// is required: bare state words stay out of the table, they collide with
// state strings on other surfaces (see mobile-overview.js).
[
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
],
// The same while tiles can move, with the drag hint on a second line.
// Anchored on the hint, so a bare state word is safe here.
[
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
(_m, state, duration) =>
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
@@ -1033,6 +1183,23 @@
return !element || Boolean(element.closest(SKIP_SELECTOR));
}
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
// a pane changes: thousands of mutation records a second with a grid of tiles,
// each paying a closest() over the whole skip list. All rows of one terminal
// share that parent, so its own shouldSkip() verdict is kept once it says
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
// its `.xterm`. A rows container that is not skipped is never kept: its rows
// go through the full check below like any other node.
const skippedRows = new WeakSet();
function isSkippedRow(node) {
const rows = node.parentNode;
if (!rows?.classList?.contains('xterm-rows')) return false;
if (skippedRows.has(rows)) return true;
if (!shouldSkip(rows)) return false;
skippedRows.add(rows);
return true;
}
function shouldSkipText(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
@@ -1128,6 +1295,13 @@
observer = new MutationObserver((mutations) => {
if (applying) return;
for (const mutation of mutations) {
// A change inside a skipped surface cannot need translating: every
// node it adds or edits sits under the same skip ancestor, so both
// translators would return on their own closest() check anyway. One
// check per record instead of one per text node and attribute matters
// for xterm's DOM renderer, which replaces rows every frame (the split
// pane, every tile of the grid).
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
if (mutation.type === 'characterData') translateNode(mutation.target);
if (mutation.type === 'attributes') translateAttributes(mutation.target);
for (const added of mutation.addedNodes) translateNode(added);
+25 -4
View File
@@ -189,9 +189,10 @@
<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="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</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-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 class="icon-folder-closed" 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"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></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-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" 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>
<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;">
@@ -435,6 +436,12 @@
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- Tile grid (tile-grid.js): 1 to 6 sessions side by side, each a
TerminalTile. A SIBLING of .terminal-wrap, never a parent: while
.main.tiles-active is set the main terminal is parked (hidden) and
this section takes its place. -->
<section class="tile-grid" id="tileGrid" aria-label="Tiled sessions"></section>
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
terminal while a web tab is active. Frames stay mounted while hidden so
switching tabs does not reload (and re-authenticate) a dashboard. -->
@@ -821,11 +828,23 @@
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
<div><kbd data-i18n-skip>Home</kbd></div><div>Focus First Tab</div>
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
</div>
</section>
<section class="shortcut-section">
<h4>Tiles</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</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>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
</div>
</section>
<section class="shortcut-section">
<h4>Terminal</h4>
<div class="shortcuts-grid">
@@ -1903,7 +1922,7 @@
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
<div class="set-group-body">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
@@ -1913,7 +1932,8 @@
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>File Viewer</span></label>
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>Multi-monitor</span></label>
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>Split</span></label>
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>Split</span></label>
<label class="set-chip" data-search="tiles tile grid side by side several sessions" data-preview="header" data-preview-order="11.6"><input type="checkbox" id="appSettingsShowTileGridButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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><span>Tiles</span></label>
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
</div>
@@ -3920,6 +3940,7 @@
<script defer src="terminal-ui.js"></script>
<script defer src="terminal-tile.js"></script>
<script defer src="terminal-split.js"></script>
<script defer src="tile-grid.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
+4
View File
@@ -573,6 +573,10 @@ Object.assign(CodemanApp.prototype, {
}
if (session?.id) this._onSessionCreated(session);
// A session this tab's Run created joins an open tile grid (tile-grid.js),
// so Run's selectSession() below focuses its tile instead of leaving the
// grid. Only here: sessions created elsewhere arrive by session:created.
this._joinTileGridFromRun?.(sessionId);
// session:created normally uses the debounced renderer. The direct POST path
// needs the tab in the DOM before selectSession() marks it active.
this._renderSessionTabsImmediate?.();
+11
View File
@@ -430,6 +430,7 @@ Object.assign(CodemanApp.prototype, {
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Phone overview home screen: only meaningful under 600px, so the row is
@@ -2492,6 +2493,7 @@ Object.assign(CodemanApp.prototype, {
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
@@ -2730,6 +2732,9 @@ Object.assign(CodemanApp.prototype, {
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
// (moving it into displayKeys alone is not the strip; this is).
showSplitButton: _ssp,
// Same as Split: a per-device header button (and the Tiles chord), absent
// from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT.
showTileGridButton: _stg,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Copy-on-select. Per-device (clipboard access differs by device and by
@@ -3420,6 +3425,7 @@ Object.assign(CodemanApp.prototype, {
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
showSplitButton: false,
showTileGridButton: false,
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
// OFF so the phone header stays minimal and the mobile-header-buttons
// policy guard keeps passing.
@@ -3632,6 +3638,10 @@ Object.assign(CodemanApp.prototype, {
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
this._applySplitButtonVisibility?.(showSplitButton);
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
@@ -4053,6 +4063,7 @@ Object.assign(CodemanApp.prototype, {
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
+865 -54
View File
File diff suppressed because one or more lines are too long
+139 -18
View File
@@ -8,7 +8,7 @@
*
* @dependency terminal-tile.js (window.TerminalTile)
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
*/
Object.assign(CodemanApp.prototype, {
@@ -46,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
// the old exact-node check below) bubbled straight through to
// `document` and self-closed the menu it just opened.
event?.stopPropagation();
// The tile grid and the split are never open together (tile-grid.js); the
// Split button shows as unavailable meanwhile.
if (this._tilesOwnTerminal?.()) return;
if (this._splitPane) {
this.closeSplitPane();
return;
@@ -139,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
// see _applySplitButtonVisibility's comment for why both a JS check and
// a CSS backstop exist.
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
// Never beside the tile grid: the main terminal is parked while it is open.
if (this._tilesOwnTerminal?.()) return;
// No active session means there is no `.terminal-wrap` to split against
// (the welcome overlay is showing) — without this, a split opened from
// the home screen still created the container and connected Pane B, just
@@ -179,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
const paneB = document.createElement('div');
paneB.className = 'terminal-pane-b';
paneB.innerHTML = `
<div class="terminal-pane-b-header">
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button>
</div>
<div class="terminal-pane-b-container"></div>
`;
// Pane B's header: the harness logo, the name and the model, as on a grid
// tile, and the close button at a tile button's size.
const headerB = this._buildSplitPaneHeader();
const close = document.createElement('button');
close.type = 'button';
close.className = 'tile-btn tile-remove terminal-pane-b-close';
close.title = 'Close split';
close.setAttribute('aria-label', 'Close split');
close.textContent = '\u00D7';
close.addEventListener('click', () => this.closeSplitPane());
headerB.el.appendChild(close);
const bodyB = document.createElement('div');
bodyB.className = 'terminal-pane-b-container';
paneB.append(headerB.el, bodyB);
// Pane A is the main terminal, which has no header of its own: while the
// split is open it gets the same strip, so each session in the split view
// names its harness and model. It takes height from the main terminal,
// which the opening resize below fits through syncTerminalGeometry (#464);
// closeSplitPane gives it back.
const headerA = this._buildSplitPaneHeader();
headerA.el.classList.add('terminal-pane-a-header');
this._splitHeaders = { a: headerA, b: headerB };
parent.insertBefore(container, wrap);
wrap.insertBefore(headerA.el, wrap.firstChild);
container.appendChild(wrap);
wrap.style.flexBasis = '50%';
container.appendChild(divider);
container.appendChild(paneB);
paneB.style.flexBasis = '50%';
this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions,
@@ -204,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
initial load — live output still arrives once/if the socket connects. */
});
this._splitSessionId = sessionId;
this._renderSplitChrome();
// Pane A just went from full width to 50%, but nothing has told its
// session's PTY/tmux window about it yet — the passive ResizeObserver in
@@ -231,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
this._splitPane.destroy();
this._splitPane = null;
this._splitSessionId = null;
// Before the refit below, so the main terminal gets its full height back.
this._splitHeaders?.a.el.remove();
this._splitHeaders = null;
this._updateSplitButtonState(false);
const container = document.querySelector('.terminal-split-container');
@@ -241,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
parent.insertBefore(wrap, container);
container.remove();
if (this.fitAddon) this.fitAddon.fit();
// Pane A takes the whole width back and, with its header strip gone, its
// whole height: through syncTerminalGeometry (#464), never a bare
// fitAddon.fit(). sendResize fits that way as its first step.
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
// while activeSessionId is still the id the server just removed, so a
// resize from here would be aimed at a session that no longer exists;
// the promoted session gets its own resize from selectSession().
if (!options.skipPrimaryResize) {
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
}
// the promoted session gets its own resize from selectSession(), and the
// terminal is only refitted here.
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
},
// A click on .btn-split does one of two things — open the picker, or
@@ -256,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
// nothing on the button said which. `.split-open` + aria-pressed give it
// the same active-state language as the codebase's other toggle buttons
// (keyboard-accessory's Ctrl key, the voice-input mic).
/**
* A split pane's header strip: the harness logo, the session name and the
* model, the three a grid tile's header shows. Built from nodes (the name
* and the model are untrusted text) and painted by _renderSplitChrome.
*/
_buildSplitPaneHeader() {
const el = document.createElement('div');
el.className = 'terminal-pane-b-header';
const harness = document.createElement('span');
harness.className = 'split-harness run-mode-dot';
harness.setAttribute('role', 'img');
const title = document.createElement('span');
title.className = 'split-title';
const name = document.createElement('span');
// `.session-name` is one of the translator's skipped surfaces (user text).
name.className = 'session-name';
// As on a tile: the name inside is never translated, the tooltip may be,
// and screen readers hear the model once, in the logo's accessible name.
const model = document.createElement('span');
model.className = 'split-model';
model.setAttribute('aria-hidden', 'true');
model.hidden = true;
const modelName = document.createElement('span');
modelName.setAttribute('data-i18n-skip', '');
model.appendChild(modelName);
title.append(name, model);
el.append(harness, title);
return { el, harness, name, model, modelName };
},
/**
* Both split headers from their sessions: Pane A shows the active session,
* Pane B its own. Runs after every tab render, so a rename or a model
* change reaches them; unchanged values write nothing.
*/
_renderSplitChrome() {
const headers = this._splitHeaders;
if (!headers || !this._splitPane) return;
for (const [parts, id] of [
[headers.a, this.activeSessionId],
[headers.b, this._splitSessionId],
]) {
const session = id ? this.sessions.get(id) : null;
if (!session) continue;
const name = this.getSessionName?.(session) || session.name || 'Session';
if (parts.nameValue !== name) {
parts.nameValue = name;
parts.name.textContent = name;
}
this._paintSessionHarness(parts, session, 'split-harness');
}
},
/**
* Paints a session header's harness logo and model: a grid tile's, and the
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
* data, never a branch), the model is text (describeSessionHarness,
* constants.js). Diffs against the values it last wrote, kept on `parts`,
* never against the DOM, which the translator may have rewritten: an
* unchanged session writes nothing, and this runs on every tab render.
*
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
* header's nodes (the model's box and the name inside it); the memo lives here too
* @param {object} session - the session the header shows
* @param {string} logoClass - the header's own class for its logo
*/
_paintSessionHarness(parts, session, logoClass) {
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
if (parts.harnessClass !== cls) {
parts.harnessClass = cls;
parts.harness.className = cls;
}
if (parts.harnessTitle !== harness.title) {
parts.harnessTitle = harness.title;
parts.harness.title = harness.title;
parts.harness.setAttribute('aria-label', harness.title);
parts.model.title = harness.title;
}
if (parts.modelValue !== harness.model) {
parts.modelValue = harness.model;
parts.modelName.textContent = harness.model;
parts.model.hidden = !harness.model;
}
},
_updateSplitButtonState(open) {
const btn = document.querySelector('.btn-split');
if (!btn) return;
@@ -276,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
// frame). Coalesced to one call per animation frame below — a raw
// mousemove stream fires far faster than the browser repaints, and
// without the rAF gate each event did a full xterm reflow on BOTH
// panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has
// no client-side "dims unchanged" skip), which fanned out into a
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
// of each dragging across half a wide viewport.
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
// fifty of each dragging across half a wide viewport.
const applyDragPercent = (clientX) => {
const container = divider.parentElement;
// The split can auto-collapse mid-drag (the other pane's session
@@ -415,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
// still attached to — two live WebSockets to one session, each claiming PTY
// dimensions via its own `{t:'z',...}` resize frame.
// Every tab render (any session change: a rename, a model switch) refreshes
// the split headers too, the way tile-grid.js refreshes the tile headers.
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
this._renderSplitChrome?.();
return result;
};
const _originalSelectSession = CodemanApp.prototype.selectSession;
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
if (this._splitPane && this._splitSessionId === sessionId) {
+404 -171
View File
@@ -4,8 +4,10 @@
* @fileoverview TerminalTile: one independent live terminal pane bound to one
* session, with its own xterm instance and its own
* `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js)
* uses one as its second pane ("Pane B"); the tile grid planned in
* docs/tile-grid-plan.md reuses the same class for every tile.
* uses one as its second pane ("Pane B"); the tile grid (tile-grid.js,
* docs/tile-grid-plan.md) uses one per tile, and feeds every capture they
* fetch through ONE TileLoadQueue (below), because each capture is a
* synchronous tmux call that blocks the server's event loop.
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
@@ -13,51 +15,67 @@
* docs/split-pane-sessions-plan.md.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
(function (global) {
// How long a scroll-to-top history pull may hold Pane B's live output.
// How long a scroll-to-top history pull may hold this pane's live output.
const HISTORY_PULL_TIMEOUT_MS = 10000;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one
// window, and xterm's write queue throws past 50 MB, which an unbounded
// `full=1` capture (up to the server's 32 MB) would otherwise come near.
const REPLAY_WINDOW_BYTES = 1024 * 1024;
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
* Replays a capture into a pane's own xterm: TERMINAL_CHUNK_SIZE slices, all
* of a window queued at once. xterm 6 parses its write queue in 12 ms slices
* and yields between them, so a long scrollback never becomes a long task,
* and it is not held to one slice per animation frame either (that pacing
* took about a second per 1 MiB, with the grid's load queue waiting behind
* it). Queued up front, the capture also stays in one piece: live output
* written during the parse lands after it, not between two of its slices.
* Deliberately NOT the primary pane's chunkedTerminalWrite (terminal-ui.js):
* that one is wired into session-switch generation counters and the
* live-output gate this simpler, independently created/destroyed pane has no
* equivalent of.
*
* Resolves once xterm has parsed the last slice (a write's callback runs once
* everything queued before it is parsed), so _loadBuffer() below holds its
* single-flight flag across the whole replay. A disposed xterm never runs its
* callbacks, so `setCancel` hands the owner a function that settles the
* replay at once: destroy() calls it, or the pane's flag and the grid's load
* queue would wait forever.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
function writeChunked(terminal, buffer, isDestroyed, setCancel) {
if (!buffer || !terminal) return Promise.resolve();
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
setCancel?.(null);
resolve();
};
const writeWindow = () => {
if (settled) return;
if (isDestroyed()) {
settle();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
const end = Math.min(buffer.length, offset + REPLAY_WINDOW_BYTES);
while (offset < end) {
const chunk = buffer.slice(offset, Math.min(end, offset + TERMINAL_CHUNK_SIZE));
offset += chunk.length;
terminal.write(chunk);
}
terminal.write('', offset < buffer.length ? writeWindow : settle);
};
writeNext();
setCancel?.(settle);
writeWindow();
});
}
@@ -67,11 +85,25 @@
this.mountEl = mountEl;
this.sessionMode = opts.mode;
this.fontSettings = opts.fontSettings || {};
// Live reference (not a snapshot) to the app's detachedSessions Set —
// detaching this session AFTER the split is already open must still be
// seen by _sendResize() below, or it re-creates the exact PTY-size
// fight the split picker already refuses to open at pick time.
// Live reference (not a snapshot) to the app's detachedSessions Set:
// detaching this session AFTER the pane opened must still be seen by
// _sendResize() below, or this pane and the session's own window fight
// over the PTY's size (which the split picker refuses at pick time).
this.detachedSessions = opts.detachedSessions;
// Lines of scrollback this pane's xterm keeps (the grid passes its smaller
// TILE_SCROLLBACK) and its font size (the grid's own tile font); absent,
// the primary pane's values.
this.scrollback = Number.isFinite(opts.scrollback) ? opts.scrollback : null;
this.fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : null;
// `scheduleLoad(tile, kind, run)` runs every capture this pane fetches
// (`kind`: 'initial', 'refresh' or 'history') when its owner says so, and
// resolves once `run` has finished or was dropped. The grid passes its one
// queue so N tiles never fetch at once; absent, a load runs straight away.
this._scheduleLoad = typeof opts.scheduleLoad === 'function' ? opts.scheduleLoad : null;
// Loads a BOUNDED window (`full=1&tail=` for a TUI, `tail=` for a shell)
// instead of a TUI's whole history. Grid tiles do; full history is one
// "leave the grid" away in the primary pane.
this.boundedLoad = opts.boundedLoad === true;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
@@ -81,6 +113,15 @@
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
// True only while a load's work runs, not while it waits in the owner's
// queue (see _runLoad): a close during the wait writes its marker at once.
this._loadRunning = false;
// Aborts the running load's fetch; destroy() uses it so a removed tile
// does not hold the owner's queue for a whole deadline.
this._loadAbort = null;
// Settles a replay xterm is still parsing (writeChunked): destroy() calls
// it, because a disposed xterm never runs the callback the replay awaits.
this._cancelReplay = null;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
@@ -119,7 +160,7 @@
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
@@ -129,7 +170,7 @@
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
scrollback: this.scrollback ?? DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
@@ -159,17 +200,18 @@
this.terminal.onData((data) => this._onTerminalData(data));
// Pane B has no gates of its own by default, so every app-level chord
// that the document capture-phase handler (app.js) only preventDefault()s
// — never stopPropagation()s — reaches xterm here too and writes its raw
// byte/escape sequence into THIS session's PTY on top of whatever the app
// action already did to Pane A (COD-153; mirrors the primary pane's own
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
// registry-aware predicates so a rebind or a disable restores plain
// terminal behavior here too. Ctrl+V goes through the primary pane's
// paste trap (image-input.js), aimed at this pane (below).
// xterm has no gates of its own, so every app-level chord that the
// document capture-phase handler (app.js) only preventDefault()s (never
// stopPropagation()s) would reach this xterm too and write its raw byte
// or escape sequence into THIS session's PTY on top of whatever the app
// action did (COD-153). These are the primary pane's gates
// (terminal-ui.js attachCustomKeyEventHandler): command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, the tile grid's chords,
// Ctrl+Z suspend, Shift/Ctrl+Enter newline, and smart-copy
// Ctrl+C/Ctrl+Shift+C. Routed through the same registry-aware
// predicates so a rebind or a disable restores plain terminal behavior
// here too. Ctrl+V goes through the primary pane's paste trap
// (image-input.js), aimed at this pane (below).
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
@@ -186,6 +228,10 @@
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false;
}
// Tile grid chords (focus, toggle): acted on by the capture handler, so
// they must never reach this tile's PTY. Every event type, and before
// the Shift+Enter branch below.
if (global.app?.tileShortcutFor?.(ev)) return false;
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
// a pasted image uploads to this pane's session and its path is typed
// here, and pasted text goes into this xterm with its bracketed-paste
@@ -195,13 +241,11 @@
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
// swallow — in a plain shell session this is the user's own
// job-control tool and must reach the PTY, but in every other mode
// (claude/omp/pi/codex/...) it silently stops an unattended agent
// loop dead. Pane B has its own PTY/session and must not send a
// suspend into a non-shell one just because the primary pane's own
// gate lives elsewhere.
// Ctrl+Z (SIGTSTP/job-control suspend), as terminal-ui.js swallows it:
// in a plain shell session this is the user's own job-control tool and
// must reach the PTY, but in every other mode (claude/omp/pi/codex/...)
// it silently stops an unattended agent loop dead. This pane has its
// own session and applies the same rule to it.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
@@ -213,15 +257,14 @@
) {
return false;
}
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
// every Enter variant, so an Ink app (Claude Code) can't tell a
// newline from a submit. Without this gate, Pane B's onData would
// send that bare \r straight over the WS and submit an incomplete
// prompt instead of adding a line to it. Targets THIS pane's own
// session (this.sessionId), never the primary pane's
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting, as
// terminal-ui.js does. xterm sends plain \r for every Enter variant,
// so an Ink app (Claude Code) can't tell a newline from a submit, and
// without this gate this pane's onData would send that bare \r and
// submit an incomplete prompt instead of adding a line to it. Targets
// THIS pane's own session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
// first (this pane is deliberately plainer, see the fileoverview).
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
@@ -235,57 +278,32 @@
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
// selection, Ctrl+C copies THIS pane's own selection instead of
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
// explicit, never-falls-through copy chord, and the predicate above
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
// a branch that assigns c.key only for a couple of special cases
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
// for Ctrl+Shift+C either way — this is not about an accidental
// interrupt byte reaching the PTY (verified live: it does not).
// Gating this whole block on hasSelection() (an earlier draft) meant
// that with no selection Ctrl+Shift+C skipped straight to `return
// true`, silently ceding the keystroke to the BROWSER's own handling
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
// attempt to copy, unlike Pane A, which always intercepts it.
// Re-implemented against this.terminal rather than reusing
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
// and would copy the wrong pane's selection.
// Smart copy, the primary pane's rule (terminal-ui.js's Ctrl+C gate,
// #211) through the SAME helpers, aimed at THIS pane: the gutter width
// comes from this session's run mode, the partial first line from this
// terminal's selection, and the clear and refocus after the copy land
// here. With a selection worth copying, Ctrl+C copies instead of
// sending ^C; with none, plain Ctrl+C falls through unchanged or the
// interrupt key is lost. Ctrl+Shift+C is the explicit copy chord and
// never falls through (ev.shiftKey, below): with nothing to copy it
// would otherwise reach the browser's own binding for that chord.
// As in the primary gate, the CLEANED selection decides and the copy is
// handed the RAW one, because the margin strip is not idempotent.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const app = global.app;
const target = { terminal: this.terminal, sessionId: this.sessionId };
const raw = this.terminal?.getSelection?.() || '';
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
// Both clean options are read for THIS pane, never the primary one:
// the gutter width comes from this.sessionId's own run mode, and the
// partial-first-line flag from this terminal's own selection range.
// Passing neither left Pane B keeping a margin Pane A dropped, on the
// same split and the same keystroke.
const range = global.app?._normalisedSelectionRange?.(this.terminal);
const selection = isColumnSelection
? raw
: (global.CodemanCopySelection?.clean?.(raw, {
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
firstLinePartial: !!range && range.start.x > 0,
}) ?? raw);
if (selection.trim()) {
if (app.cleanedTerminalSelection?.(raw, target)?.trim()) {
ev.preventDefault();
void global.app._copyText?.(selection).then((ok) => {
this.terminal?.clearSelection?.();
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
});
void app.copyTerminalSelection(raw, target);
return false;
}
// Nothing worth copying — clear for feedback (a padding-only
// selection cleans to '' and this press still falls through to the
// PTY as 0x03, matching the primary pane's own rule).
// Nothing worth copying: cleared for feedback, and the press still
// reaches the PTY as 0x03, as in the primary pane.
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.app.showToast?.('Nothing to copy', 'warning');
app.showToast?.('Nothing to copy', 'warning');
}
// Ctrl+Shift+C never falls through, even with nothing to copy —
// matches terminal-ui.js's own ev.shiftKey branch.
if (ev.shiftKey) {
ev.preventDefault();
return false;
@@ -296,17 +314,13 @@
// Load existing scrollback before going live. The WS below is
// subscribe-only (ws-routes.ts sends nothing on connect, only future
// 'terminal' events), so without this Pane B stays blank until the
// target session happens to produce new output. It LOOKED
// intermittent rather than always-broken because _sendResize() below
// often nudges the shared session's real tmux window to a new size,
// and tmux repaints its current screen on resize — that repaint was
// getting captured and streamed here, incidentally populating the
// pane. When Pane B's computed dimensions happened to already match
// the session's last-known size, Session.resize() (session.ts) skips
// the resize as a no-op, no repaint fires, and the pane stayed blank.
// The await covers the whole chunked replay, not just the fetch, so a
// live frame from the socket below can never land in the middle of it.
// 'terminal' events), so without this the pane stays blank until the
// session happens to produce new output. The resize _sendResize() sends
// on open is no substitute: tmux repaints on a resize, but
// Session.resize() (session.ts) skips one that matches the session's
// last size, and then nothing repaints at all. The await covers the
// whole chunked replay, not just the fetch, so a live frame from the
// socket below can never land in the middle of it.
await this._loadBuffer();
if (this._destroyed) return;
@@ -352,8 +366,8 @@
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js) — Pane B has its own
// buffer loader for the same reason connect() does.
// _onSessionNeedsRefresh (app.js); this pane has its own buffer
// loader for the same reason connect() does.
this._refreshBuffer();
} else if (msg.t === 'ia') {
// Input ACK. The frame names no session, so it is this pane's.
@@ -362,7 +376,7 @@
this._onPtyGeometryReport(msg.c, msg.r);
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
/* Malformed frame: ignored, as in the primary pane. */
}
};
@@ -377,7 +391,7 @@
};
ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
// onclose fires after onerror: cleanup happens there.
};
}
@@ -387,8 +401,8 @@
if (!ws) return;
ws.onopen = null;
ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran its
// "disconnected" write against a pane already torn down or replaced.
// onclose fires asynchronously AFTER close(); without this it would run
// its "disconnected" write against a pane already torn down or replaced.
ws.onclose = null;
ws.onerror = null;
try {
@@ -453,7 +467,7 @@
const code = event?.code;
const permanent = TerminalTile.STOP_MARKERS[code];
this._markerText = permanent || TerminalTile.MARKER_RECONNECTING;
if (this._bufferLoading) this._markerOwed = true;
if (this._loadRunning) this._markerOwed = true;
else this._writeDisconnectedMarker();
if (this._destroyed) return;
if (permanent) {
@@ -519,6 +533,8 @@
this._inputHandle = { ws: this.ws, lastRecvAt: 0 };
app._registerInputSocket(this.sessionId, this._inputHandle);
app._onWsReady?.(this.sessionId);
// The header's connection dot reads tile sockets while the grid is open.
app._updateConnectionIndicator?.();
}
// Leaves the map; only this pane's own handle is removed (a replacement
@@ -527,6 +543,7 @@
if (!this._inputHandle) return;
global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle);
this._inputHandle = null;
global.app?._updateConnectionIndicator?.();
}
// Settles a marker the pane owes: set when a close lands during a load (the
@@ -552,7 +569,7 @@
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
// (above). The primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
@@ -561,31 +578,98 @@
// _onSessionNeedsRefresh): a shell session can retain hundreds of
// thousands of plain scrollback lines, so pulling `?full=1` there parses
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
// get one full replay. `fetch` here goes through the global wrapper
// (constants.js), which already prefixes CodemanBase — unlike the raw
// WebSocket URL above, which does not.
// into this xterm on every load; a shell loads the `tail=` window. A
// non-shell (TUI) session gets one full replay, or the same bounded window
// with `boundedLoad` (grid tiles). `fetch` here goes through the global
// wrapper (constants.js), which already prefixes CodemanBase, unlike the
// raw WebSocket URL above, which does not.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
// in flight is dropped here; _refreshBuffer() is the caller that queues
// a trailing re-run instead.
async _loadBuffer() {
async _loadBuffer({ refresh = false } = {}) {
if (this._bufferLoading) return;
this._bufferLoading = true;
try {
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
const payload = (await res.json())?.data ?? {};
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
this._loadRunning = true;
try {
if (this._destroyed) return;
if (refresh) {
// Cleared at the load's turn, not when it was asked for: a grid tile
// waiting in the queue keeps its last frame instead of sitting blank.
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue
// a trailing refresh behind a pull that the socket's close then
// interrupts), so a refresh on a closed socket owes it back once its
// replay is written.
if (this._wsClosed) this._markerOwed = true;
}
const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever.
const controller = global.AbortController ? new global.AbortController() : null;
this._loadAbort = controller;
const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000;
const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
let payload;
try {
const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?${query}`,
controller ? { signal: controller.signal } : undefined
);
payload = (await res.json())?.data ?? {};
} finally {
clearTimeout(timer);
this._loadAbort = null;
}
if (payload.terminalBuffer && this.terminal) {
await writeChunked(
this.terminal,
payload.terminalBuffer,
() => this._destroyed,
(cancel) => (this._cancelReplay = cancel)
);
}
} catch {
/* Best-effort: live output still arrives once the socket connects. */
} finally {
this._loadRunning = false;
this._stampMarkerIfOwed();
this._endBufferLoad();
}
});
}
// Runs a load's work now, or when the owner's queue gives this pane its turn
// (`scheduleLoad`). The single-flight flag is already set by the caller, so a
// load waiting in the queue still coalesces refreshes and blocks a second
// pull; the work itself sets `_loadRunning`. A load the queue drops (this
// pane was destroyed while it waited) never runs, so its flags are released
// here.
async _runLoad(kind, work) {
let ran = false;
const run = () => {
ran = true;
return work();
};
if (!this._scheduleLoad) {
await run();
return;
}
try {
await this._scheduleLoad(this, kind, run);
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
/* The queue never rejects; a load that failed already settled itself. */
}
if (!ran) {
this._bufferLoading = false;
this._bufferRefreshPending = false;
}
}
@@ -632,20 +716,20 @@
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
// output (`cat` of a file longer than the screen) instead of scrolling it,
// so this pane's xterm ends up with about one screen of scrollback while
// tmux holds every line — and nothing here ever went back to ask, so the
// history was unreachable. The primary pane has the same pull
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
// own. Shell only: a non-shell CLI's history is out of scope for this pull
// (its load already takes `full=1`; codex and Claude's inline renderer do
// grow tmux history, this just isn't how they recover it). The alternate-
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
// tmux the browser xterm never enters the alternate buffer.
// tmux holds every line, and without this pull the history is
// unreachable. The primary pane has the same pull
// (app.js _maybeRefetchFullHistory); a tile is a separate xterm and needs
// its own. Shell only: a non-shell CLI's history is out of scope for this
// pull (its load already takes `full=1`; codex and Claude's inline renderer
// do grow tmux history, this just isn't how they recover it). The
// alternate-screen skip (nano, vim, less) only matters for a direct-PTY
// shell: under tmux the browser xterm never enters the alternate buffer.
_maybeLoadMoreHistory() {
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
if (this._bufferLoading) return;
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
// _sendResize(): a detached session's own window already owns its PTY
// size and scrollback, so Pane B has nothing of its own to reconcile.
// size and scrollback, so this pane has nothing of its own to reconcile.
if (this.detachedSessions?.has(this.sessionId)) return;
const active = this.terminal.buffer.active;
if (active.type !== 'normal' || active.viewportY !== 0) return;
@@ -656,7 +740,8 @@
const now = Date.now();
if (now - this._historyPullAt < cooldown) return;
this._historyPullAt = now;
void this._pullHistory();
this._bufferLoading = true;
void this._runLoad('history', () => this._pullHistory());
}
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
@@ -665,6 +750,11 @@
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
this._bufferLoading = true;
if (this._destroyed) {
this._endBufferLoad();
return;
}
this._loadRunning = true;
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
@@ -678,6 +768,7 @@
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
this._loadAbort = controller;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
@@ -686,9 +777,10 @@
};
try {
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: controller?.signal,
});
const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`,
{ signal: controller?.signal }
);
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
@@ -698,7 +790,7 @@
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
// would buy nothing and freeze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
@@ -728,7 +820,12 @@
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
await writeChunked(
term,
buffer,
() => this._destroyed,
(cancel) => (this._cancelReplay = cancel)
);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
// after everything before it, so the row count below is the settled one.
@@ -740,9 +837,11 @@
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
/* Best-effort: live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
@@ -765,8 +864,18 @@
}
}
// A bounded load's `lines=` (grid tiles): tmux history beyond what this
// xterm keeps (its scrollback plus the screen) would only be captured to be
// thrown away, and a full capture is synchronous work on the server, about
// 0.7 s for a 30k-line history. Unbounded panes (the split's Pane B) ask
// for everything, as before.
_historyLinesQuery() {
if (!this.boundedLoad || !Number.isFinite(this.scrollback)) return '';
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to start two concurrent replays, each clearing
// frames in a row must not start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
@@ -776,16 +885,11 @@
this._bufferRefreshPending = true;
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
void this._loadBuffer({ refresh: true });
}
// Local reflow only — no PTY resize frame. Split out so a divider drag
// can reflow both panes at the browser's paint rate (rAF) while sending
// Local reflow only, no PTY resize frame. Split out so a divider drag
// can reflow the panes at the browser's paint rate (rAF) while sending
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
@@ -806,8 +910,8 @@
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this split was opened, so its own window now
// owns the PTY's size and Pane B must stand aside.
// to its own window AFTER this pane was opened, so its own window now
// owns the PTY's size and this pane must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
// fit() then leaves the xterm alone: there is no size worth reporting.
@@ -815,9 +919,9 @@
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
// Report what the xterm actually holds, so the PTY gets exactly the size
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
// a floor here misreported Pane B's width at the divider's reachable 20%
// position (about 28 columns), causing real output-wrapping bugs, and a
// floored xterm would be wider than its container. The server enforces
// a floor would misreport the split's Pane B at its divider's reachable
// 20% position (about 28 columns) and wrap output wrongly, and a floored
// xterm would be wider than its container. The server enforces
// its own valid range ([1,500]/[1,200] in ws-routes.ts).
const cols = this.terminal.cols;
const rows = this.terminal.rows;
@@ -827,6 +931,17 @@
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
}
// The session's PTY is new: a tile can connect before its session has a
// pane (one Run started while the grid is open joins first), and the
// server drops a resize that arrives with no PTY, then spawns at its own
// default size. Forget what was sent, so the size goes out now, or with
// the next fit() when this tile is hidden right now (a zoomed neighbour):
// _sendResize() returns before recording anything it did not send.
paneStarted() {
this._lastSentDims = null;
this._sendResize();
}
// The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to
// every resize). A PTY and a terminal that disagree on WIDTH render
// garbled, so a different column count is adopted; rows stay local, as in
@@ -851,6 +966,18 @@
// keystroke loses nothing.
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
// A load still fetching would otherwise hold the owner's queue (and the
// server's attention) for a pane nobody can see any more.
try {
this._loadAbort?.abort();
} catch {
/* Already settled. */
}
this._loadAbort = null;
// Likewise a replay still parsing: the xterm is disposed below, so the
// write callback it waits for would never come.
this._cancelReplay?.();
this._cancelReplay = null;
if (this._onWheel) {
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;
@@ -883,5 +1010,111 @@
4010: '[disconnected: another connection took over this pane]',
};
/**
* ONE queue for every capture a set of tiles fetches (`GET
* /api/sessions/:id/terminal`): the initial load, the refresh after a
* reconnect, a server `{t:'r'}` refresh and the shell history pull. Each
* capture runs synchronous tmux calls on the server, so N of them at once do
* not run in parallel, they stall every WebSocket and SSE stream on it back to
* back. After a deploy restart all N tiles reopen within the same second; this
* drains their refreshes one at a time.
*
* Concurrency 1. Next up is a history pull (the user is waiting on it), then
* the lowest `rank(tile)` (the grid ranks the focused tile first, then reading
* order), then arrival order. A destroyed tile's entries are dropped, never run.
* DOM-free, so the grid owns the policy and tests drive it directly.
*/
class TileLoadQueue {
/**
* @param {{rank?: (tile: object) => number, onChange?: (tile: object, state: 'queued'|'running'|'idle') => void}} [opts]
*/
constructor(opts = {}) {
this._rank = typeof opts.rank === 'function' ? opts.rank : () => 0;
this._onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
this._pending = [];
this._active = null;
this._seq = 0;
}
/** The `scheduleLoad` a TerminalTile takes. Resolves once `run` finished or was dropped; never rejects. */
schedule(tile, kind, run) {
return new Promise((resolve) => {
this._pending.push({ tile, kind, run, resolve, seq: this._seq++ });
this._notify(tile, 'queued');
this._pump();
});
}
/** Drops every load still waiting for `tile` (the running one, if any, finishes on its own). */
drop(tile) {
const keep = [];
for (const entry of this._pending) {
if (entry.tile === tile) entry.resolve();
else keep.push(entry);
}
this._pending = keep;
if (this._active?.tile !== tile) this._notify(tile, 'idle');
}
/** How many loads are waiting (not counting the running one). */
get size() {
return this._pending.length;
}
/** The tile whose load is running, or null. */
get activeTile() {
return this._active?.tile ?? null;
}
_notify(tile, state) {
try {
this._onChange?.(tile, state);
} catch {
/* A display callback never stops the queue. */
}
}
_takeNext() {
let best = -1;
let bestKey = null;
for (let i = 0; i < this._pending.length; i++) {
const entry = this._pending[i];
if (entry.tile?._destroyed) continue;
const key = [entry.kind === 'history' ? 0 : 1, this._rank(entry.tile), entry.seq];
const order = bestKey ? key[0] - bestKey[0] || key[1] - bestKey[1] || key[2] - bestKey[2] : -1;
if (order < 0) {
best = i;
bestKey = key;
}
}
// Destroyed tiles' entries go now, resolved but never run.
const dropped = this._pending.filter((entry) => entry.tile?._destroyed);
const next = best === -1 ? null : this._pending[best];
this._pending = this._pending.filter((entry) => entry !== next && !entry.tile?._destroyed);
for (const entry of dropped) entry.resolve();
return next;
}
async _pump() {
if (this._active) return;
const entry = this._takeNext();
if (!entry) return;
this._active = entry;
this._notify(entry.tile, 'running');
try {
await entry.run();
} catch {
/* A load settles its own failure; the queue only moves on. */
} finally {
this._active = null;
const stillQueued = this._pending.some((e) => e.tile === entry.tile);
this._notify(entry.tile, stillQueued ? 'queued' : 'idle');
entry.resolve();
void this._pump();
}
}
}
global.TerminalTile = TerminalTile;
global.TileLoadQueue = TileLoadQueue;
})(window);
+55 -13
View File
@@ -590,6 +590,11 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Tile grid chords (Ctrl+Shift+G, Alt+Shift+Arrows): the capture handler
// has already acted on one that applies, and its preventDefault() does not
// stop xterm. Every event type, and BEFORE the Shift+Enter branch below.
if (this.tileShortcutFor?.(ev)) return false;
// Smart copy (#211): with a selection, Ctrl+C copies it instead of sending
// ^C. With NO selection the branch must fall through (return true, and no
// preventDefault) or the interrupt key is lost, which is the whole reason
@@ -1328,6 +1333,9 @@ Object.assign(CodemanApp.prototype, {
// Same yield as sendResize: never resize a PTY whose session is showing
// in its own window. Dragging the dashboard's border must not reshape it.
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
// The tile grid parks the main terminal: its session is sized by its
// tile, which this same timer refits below (_forEachTile).
const tilesOwnTerminal = this._tilesOwnTerminal?.();
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
// This block used to fit unconditionally and skip only the SIGWINCH,
// which is the one combination that cannot be right: it moves xterm to
@@ -1335,7 +1343,9 @@ Object.assign(CodemanApp.prototype, {
// repaints from the shape it was told. Withhold both, or neither —
// a reflow nothing is rendering for buys nothing and costs correctness.
const dims =
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
this.activeSessionId && !keyboardUp && !detachedElsewhere && !tilesOwnTerminal
? this._geometryForResizeRequest()
: null;
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
// and xterm does not hold — the write-only guess this whole change exists
@@ -1418,8 +1428,11 @@ Object.assign(CodemanApp.prototype, {
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
// stayed at whatever size it was last dragged to.
this._forEachTile?.((tile) => tile.fit());
// stayed at whatever size it was last dragged to. Grid tiles are left
// out: the grid's own observer (tile-grid.js _scheduleTileGridRefit)
// refits every one of them on the same resize, and a second fit here
// only re-measured six panes to send nothing.
this._forEachTile?.((tile) => tile.fit(), { grid: false });
}, 300); // Trailing-edge: only fire after 300ms of no resize events
};
@@ -4512,7 +4525,12 @@ Object.assign(CodemanApp.prototype, {
* it from its own terminal's focus; the primary terminal's focus gives it back.
*/
_focusedPane() {
const tile = this._focusedTile;
let tile = this._focusedTile;
// With the tile grid open the main terminal is parked, so the pane is the
// focused tile even when DOM focus sits on a button or a panel.
if ((!tile || tile._destroyed || !tile.terminal) && this._tilesOwnTerminal?.()) {
tile = this._tileFor(this.activeSessionId);
}
if (tile && !tile._destroyed && tile.terminal) {
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
}
@@ -4525,13 +4543,19 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Run `fn(tile)` for every secondary terminal pane on screen: today the split
* pane's second terminal, when one is open. Font, weight, family and skin
* changes go through here so they reach every pane without a special case
* per pane kind. Agent Teams terminals size themselves and are not tiles.
* Run `fn(tile)` for every secondary terminal pane on screen: the split
* pane's second terminal and every tile of the tile grid (never both: the two
* modes are not open together). Font, weight, family and skin changes go
* through here so they reach every pane without a special case per pane kind.
* `{ grid: false }` skips grid tiles (they keep their own font size).
* Agent Teams terminals size themselves and are not tiles.
*/
_forEachTile(fn) {
_forEachTile(fn, { grid = true } = {}) {
if (this._splitPane?.terminal) fn(this._splitPane);
if (!grid || !this._tileGrid?.open) return;
for (const { tile } of this._tileGrid.tiles.values()) {
if (tile.terminal) fn(tile);
}
},
// Clears the pane the keyboard is in. The chord itself also reaches that
@@ -4614,6 +4638,9 @@ Object.assign(CodemanApp.prototype, {
this.showToast('No active session', 'warning');
return;
}
// Backstop: _focusedPane() answers with the focused tile while the grid is
// open, so this is reached only if that tile is gone mid-call.
if (this._tilesOwnTerminal?.()) return;
// The pane belongs to the popup showing it, so this window has nothing to
// restore. Say so rather than reporting a size that was never sent — the
@@ -5742,11 +5769,20 @@ Object.assign(CodemanApp.prototype, {
},
increaseFontSize() {
// With the tile grid open, Ctrl +/- sizes the tiles (their own font size).
if (this._tilesOwnTerminal?.()) {
this.setTileFontSize(Math.min(this._tileGridFontSize() + 2, 24));
return;
}
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.min(current + 2, 24));
},
decreaseFontSize() {
if (this._tilesOwnTerminal?.()) {
this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10));
return;
}
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.max(current - 2, 10));
},
@@ -5759,10 +5795,13 @@ Object.assign(CodemanApp.prototype, {
// Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
this._forEachTile?.((tile) => {
tile.terminal.options.fontSize = size;
tile.fit(); // a font change is a size change: tell its PTY too (#464)
});
this._forEachTile?.(
(tile) => {
tile.terminal.options.fontSize = size;
tile.fit(); // a font change is a size change: tell its PTY too (#464)
},
{ grid: false }
);
},
/**
@@ -6066,6 +6105,9 @@ Object.assign(CodemanApp.prototype, {
// settle-time refit through this call and has no fallback, which is correct:
// a pane it does not own is not its to refit either.
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
// Backstop: while the tile grid owns the terminal, a tile sizes this PTY and
// the parked main terminal measures nothing worth sending.
if (this._tilesOwnTerminal?.()) return false;
// Fit, floor, and apply in one step so the numbers below are the numbers
// xterm is actually holding (or, while another device holds the width,
// the numbers this container would hold if the PTY followed).
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -775,6 +775,20 @@ export function resolveOmpConfigForCreate(
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
}
/**
* The tmux history lines a full capture may read (`capture-pane -S -<n>`): the
* optional `lines` query parameter, an integer of at least 1, never more than
* the configured history limit; absent or malformed, the limit itself, as
* before. A grid tile sends its own scrollback size: its xterm keeps no more
* than that, while a capture of the whole history (tens of thousands of lines,
* cut to `tail` only afterwards) is synchronous work on this event loop.
*/
function captureHistoryLines(raw: string | undefined, historyLimit: number): number {
if (typeof raw !== 'string' || !/^\d{1,9}$/.test(raw)) return historyLimit;
const lines = Number(raw);
return lines >= 1 ? Math.min(lines, historyLimit) : historyLimit;
}
/**
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
@@ -2986,7 +3000,7 @@ export function registerSessionRoutes(
app.get('/api/sessions/:id/terminal', async (req, reply) => {
const routeStartedAt = performance.now();
const { id } = req.params as { id: string };
const query = req.query as { tail?: string; full?: string };
const query = req.query as { tail?: string; full?: string; lines?: string };
const session = findSessionOrFail(ctx, id, req);
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
@@ -3010,8 +3024,14 @@ export function registerSessionRoutes(
// single reason: `capturedGeometry` comes BACK on it, and the response has
// to tell the client what size the frame it is about to render was built
// for. See PaneCaptureOptions.capturedGeometry.
// `lines` bounds only the history a FULL capture reads; the visible-frame
// path reads no history and is untouched by it.
const captureOpts: PaneCaptureOptions = isFullReload
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
? {
fullHistory: true,
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
maxCaptureBytes: terminalBufferMaxBytes,
}
: {};
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
+13
View File
@@ -7,6 +7,9 @@
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Also records the session's model (`model.display_name`) as its reported model, which
* `session:updated` publishes as `displayModel` for the session headers.
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer (print-through) when it has no statusline of the user's
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
@@ -26,6 +29,7 @@ import {
import { SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
@@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
+4
View File
@@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter {
// pane-exit watcher's own tick replaces it with a first-hand
// reading (not the stats collector — see `startPaneExitWatcher`).
paneExit: savedState?.paneExit,
// The model the CLI last reported (statusline or its own footer), so a
// tile header keeps naming it until the next report; an idle claude
// session re-renders its statusline only on its next turn.
displayModel: savedState?.displayModel,
// A record rebuilt from the socket has no provenance, so its
// apparent locality is a guess (see `MuxSession.discovered`).
discoveredMuxSession: muxSession.discovered,
+13
View File
@@ -44,6 +44,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
@@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
* restores the last reported model until the next report.
*/
displayModelChanged: () => {
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
// ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */
@@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted);
@@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted);
+275
View File
@@ -0,0 +1,275 @@
/**
* The model a dsh session's TUI route config pins (src/deepseek-route-config.ts),
* shown in a session header when the TUI's screen names none.
*
* Fixture dsh homes are built in a temp directory. The two user layers below are the
* owner's real files on 2026-10-07, verbatim apart from the comment text: the
* profile layer pins the dsh-tui row's route (provider AND model), the home layer
* pins `agent-default-model`, which is the headless default and NOT the TUI's.
*
* Port: N/A.
*/
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
DSH_TUI_PACKAGE,
MAX_ROUTE_FILE_BYTES,
effectiveDshHome,
parseDshTuiPatches,
readDeepSeekRouteModel,
resolveDshTuiRouteModel,
} from '../src/deepseek-route-config.js';
const PROFILE_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer:
# a top-level YAML array of loader patch entries (id-targeted config
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
#
# Route the TUI at the 5090 box's live serve. A config route counts only
# when it names BOTH provider and model.
- id: dsh-tui
config:
provider: qwen5090
model: qwen3.8-27b
effort: medium
preset: qwen5090
`;
const HOME_LAYER = `# Home-level patch layer. The default model is a composition entry,
# not a settings key.
- id: agent-default-model
config:
provider: qwen5090
model: qwen3.8-27b
`;
const SETTINGS = `agent-default-model:
provider: qwen5090
model: qwen3.8-27b
`;
const TUI_BUNDLES = ['@deepseek-ai/dsh-base', DSH_TUI_PACKAGE];
const homes: string[] = [];
afterEach(() => {
for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true });
});
/** A fixture dsh home: profiles (bundles + optional patch layer), a home layer, settings.yaml. */
function makeHome(spec: {
profiles?: Record<string, { bundles?: string[]; patch?: string }>;
homePatch?: string;
settings?: string;
}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-route-'));
homes.push(root);
const home = join(root, 'dsh');
mkdirSync(join(home, 'profiles'), { recursive: true });
for (const [name, p] of Object.entries(spec.profiles ?? {})) {
mkdirSync(join(home, 'profiles', name), { recursive: true });
writeFileSync(
join(home, 'profiles', name, 'package.json'),
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles: p.bundles ?? TUI_BUNDLES } } })
);
if (p.patch !== undefined) writeFileSync(join(home, 'profiles', name, 'cordis.patch.yml'), p.patch);
}
if (spec.homePatch !== undefined) writeFileSync(join(home, 'cordis.patch.yml'), spec.homePatch);
if (spec.settings !== undefined) writeFileSync(join(home, 'settings.yaml'), spec.settings);
return home;
}
const read = (home: string, profile: string | undefined = 'dsh-tui', serverHome: string | null = home) =>
readDeepSeekRouteModel({ profile, home, serverHome });
describe('readDeepSeekRouteModel', () => {
it('a route pinned with both provider and model: the model', async () => {
const home = makeHome({
profiles: { 'dsh-tui': { patch: PROFILE_LAYER } },
homePatch: HOME_LAYER,
settings: SETTINGS,
});
expect(await read(home)).toBe('qwen3.8-27b');
});
it('a half-pinned route (provider alone): nothing, the TUI drops it whole', async () => {
const half = '- id: dsh-tui\n config:\n provider: qwen5090\n effort: medium\n';
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: half } } }))).toBeNull();
const modelOnly = '- id: dsh-tui\n config:\n model: qwen3.8-27b\n';
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: modelOnly } } }))).toBeNull();
});
it('no profile layer: nothing (the bundle pins a provider alone, by design)', async () => {
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER, settings: SETTINGS }))).toBeNull();
});
it("settings.yaml and the home layer's agent-default-model are the headless default, never the TUI's", async () => {
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, settings: SETTINGS }))).toBeNull();
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER }))).toBeNull();
});
it('a layer that does not parse: nothing', async () => {
const broken = [
'- id: dsh-tui\n config:\n\tprovider: qwen5090\n\tmodel: x\n', // tab indentation
'- id: dsh-tui\n config:\n \tprovider: qwen5090\n \tmodel: x\n', // a tab inside the indentation
"- id: dsh-tui\n config:\n provider: 'qwen5090\n model: x\n", // unterminated quote
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n model: other\n', // duplicate key
'dsh-tui:\n provider: qwen5090\n', // not a sequence
];
for (const patch of broken) {
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch } } })), patch).toBeNull();
}
// The home layer is read too: a broken one blanks a good profile layer.
const home = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: '- id: [\n' });
expect(await read(home)).toBeNull();
});
it('a layer that is a symlink out of the dsh home: nothing; one inside it is followed', async () => {
const home = makeHome({ profiles: { 'dsh-tui': {} } });
const outside = join(home, '..', 'outside.yml');
writeFileSync(outside, PROFILE_LAYER);
symlinkSync(outside, join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
expect(await read(home)).toBeNull();
const inside = makeHome({ profiles: { 'dsh-tui': {} } });
writeFileSync(join(inside, 'shared-route.yml'), PROFILE_LAYER);
symlinkSync(join(inside, 'shared-route.yml'), join(inside, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
expect(await read(inside)).toBe('qwen3.8-27b');
});
it("a session's own DSH_HOME is the one read", async () => {
const server = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } });
const own = makeHome({
profiles: {
'dsh-tui': { patch: '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n' },
},
});
expect(await read(own, 'dsh-tui', server)).toBe('deepseek-v4-pro');
expect(await read(server, 'dsh-tui', server)).toBe('qwen3.8-27b');
// The status bar can be switched off in that home without touching the route.
expect(effectiveDshHome((k) => (k === 'DSH_HOME' ? own : undefined))).toBe(own);
});
it('the home layer outranks the profile layer, and replaces its whole config', async () => {
const pin = '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n';
const homeWins = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: pin });
expect(await read(homeWins)).toBe('deepseek-v4-flash');
const half = '- id: dsh-tui\n config:\n effort: high\n';
const halved = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: half });
expect(await read(halved)).toBeNull();
});
it('no profile named: the one the launch would boot (the first terminal profile)', async () => {
const home = makeHome({
profiles: {
web: { bundles: ['@deepseek-ai/dsh-web-app'] },
'dsh-tui': { patch: PROFILE_LAYER },
},
});
expect(await read(home, undefined)).toBe('qwen3.8-27b');
// An invalid name reads as unset at launch, so the default applies here too.
expect(await read(home, '../web')).toBe('qwen3.8-27b');
});
it('a profile that does not compose dsh-TUI: nothing (its route is not this reader’s to know)', async () => {
const home = makeHome({ profiles: { other: { bundles: ['@someone/other-tui'], patch: PROFILE_LAYER } } });
expect(await read(home, 'other')).toBeNull();
});
it('a disabled row, a name mismatch, a missing home or profile: nothing', async () => {
const disabled = `${PROFILE_LAYER}- id: dsh-tui\n disabled: true\n`;
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: disabled } } }))).toBeNull();
const mismatch = '- id: dsh-tui\n name: "@someone/else"\n config:\n provider: a\n model: b\n';
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: mismatch } } }))).toBeNull();
expect(await read(join(tmpdir(), 'no-such-dsh-home-xyz'))).toBeNull();
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }), 'missing')).toBeNull();
});
it('an oversized layer: nothing', async () => {
const big = PROFILE_LAYER + `# ${'x'.repeat(MAX_ROUTE_FILE_BYTES)}\n`;
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: big } } }))).toBeNull();
});
});
describe('effectiveDshHome', () => {
it("the session's, else ~/.dsh; a relative value names no place", () => {
expect(effectiveDshHome(() => '/srv/dsh')).toBe('/srv/dsh');
expect(effectiveDshHome(() => ' ')).toMatch(/\.dsh$/);
expect(effectiveDshHome(() => undefined)).toMatch(/\.dsh$/);
expect(effectiveDshHome(() => 'dsh-copy')).toBeNull();
expect(effectiveDshHome(() => '~/dsh')).toBeNull();
});
});
describe('parseDshTuiPatches (the narrow YAML subset)', () => {
it("reads the owner's two layers", () => {
expect(parseDshTuiPatches(PROFILE_LAYER)).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
expect(parseDshTuiPatches(HOME_LAYER)).toEqual([]);
});
it('an empty list, comments only, and a leading document marker', () => {
expect(parseDshTuiPatches('[]\n')).toEqual([]);
expect(parseDshTuiPatches('# nothing yet\n')).toEqual([]);
expect(parseDshTuiPatches(`---\n${PROFILE_LAYER}`)).toHaveLength(1);
});
it('quoted scalars, a key on its own line under `-`, a wider item indent', () => {
expect(
parseDshTuiPatches('- id: "dsh-tui"\n config:\n provider: "qwen5090"\n model: \'qwen3.8-27b\'\n')
).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
expect(parseDshTuiPatches('-\n id: dsh-tui\n config:\n provider: p\n model: m # trailing\n')).toEqual([
{ config: { provider: 'p', model: 'm' } },
]);
expect(parseDshTuiPatches('- id: dsh-tui\n config:\n provider: p\n model: m\n')).toEqual([
{ config: { provider: 'p', model: 'm' } },
]);
});
it('anything that could change the answer and is beyond the subset: null', () => {
const cases = [
'- id: dsh-tui\n config:\n provider: !!js process.env.P\n model: m\n', // a tag
'- id: dsh-tui\n config: &route\n provider: p\n model: m\n', // an anchor
'- id: other\n config: &r { provider: p, model: m }\n- id: dsh-tui\n config: *r\n', // an alias
'- id: dsh-tui\n config:\n <<: { provider: p, model: m }\n', // a merge key
'- id: dsh-tui\n config: { provider: p, model: m }\n', // a flow config
'- id: dsh-tui\n config:\n provider: p\n model: 12\n', // a number, not a string
'- id: dsh-tui\n config:\n provider: p\n model: |\n m\n', // a block scalar
'- insert:\n - id: dsh-tui\n name: x\n', // re-inserting the row
`${PROFILE_LAYER}---\n- id: dsh-tui\n`, // a second document
'%YAML 1.2\n---\n- id: dsh-tui\n', // a directive
// A quoted scalar spanning lines swallows the next "key": YAML reads note as
// "start model: m" and the route as half-pinned.
'- id: dsh-tui\n config:\n note: "start\n model: m"\n provider: p\n',
// A plain scalar continued on the next line is one value ("m continued").
'- id: dsh-tui\n config:\n provider: p\n model: m\n continued\n',
// A group row whose config re-defines a dsh-tui row inside it.
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
];
for (const text of cases) expect(parseDshTuiPatches(text), text).toBeNull();
});
it('an unrelated item it cannot follow is skipped, one about dsh-tui is not', () => {
// `rules`' sequence at its key's own indent is valid YAML but beyond the subset.
const odd = '- id: approval\n rules:\n - a\n config:\n policy: |\n line one\n';
expect(parseDshTuiPatches(`${odd}${PROFILE_LAYER}`)).toEqual([
{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } },
]);
expect(parseDshTuiPatches(`- id: dsh-tui\n config:\n rules:\n - a\n`)).toBeNull();
});
it('a disabled flag, a name, and an empty config', () => {
expect(parseDshTuiPatches('- id: dsh-tui\n disabled: true\n name: x\n config: {}\n')).toEqual([
{ disabled: true, name: 'x', config: {} },
]);
});
});
describe('resolveDshTuiRouteModel', () => {
it('the last config wins whole; a layer that could not be read blanks the answer', () => {
const full = { config: { provider: 'p', model: 'm' } };
expect(resolveDshTuiRouteModel([[full], []])).toBe('m');
expect(resolveDshTuiRouteModel([[full], [{ config: { provider: 'p' } }]])).toBeNull();
expect(resolveDshTuiRouteModel([[full], null])).toBeNull();
expect(resolveDshTuiRouteModel([[], []])).toBeNull();
expect(resolveDshTuiRouteModel([[full, { disabled: true }]])).toBeNull();
expect(resolveDshTuiRouteModel([[full, { name: '@someone/else', config: {} }]])).toBe('m');
});
});
+13 -3
View File
@@ -1,6 +1,9 @@
/**
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
* (`session-ui.js`, `mobile-overview.js`), mirroring
* (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session
* header's harness logo and model (`constants.js`, `terminal-split.js`,
* `tile-grid.js`: the logo's `run-mode-dot <cliId>` class is the id as DATA),
* mirroring
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
*
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
@@ -21,7 +24,7 @@ import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js'];
/**
* Every currently-surviving branch, each with the COUNT of physical call
@@ -90,6 +93,13 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
},
// tile-grid.js: a tile's Attach re-attaches through the same route pair the
// primary pane uses, and that pair is split by mode on the server side.
"tile-grid.js::mode === 'shell'": {
count: 1,
reason: 'attach route: a shell session attaches through /shell, an agent through /interactive',
},
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
"mobile-overview.js::mode !== 'shell'": {
@@ -177,7 +187,7 @@ function actualCounts(): Map<string, number> {
return counts;
}
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
describe('no NEW CLI-id branching in the scanned frontend files', () => {
it('scans both files (sanity)', () => {
// If this drops to zero the scanner or the file list drifted and every
// assertion below would pass vacuously.
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Header icon buttons: hover motion moves the icon, never the button.
*
* A global `.btn-icon-header:hover { transform: rotate(45deg) }` (meant for the
* settings gear) turned EVERY header icon button on hover, so the folder, Tiles,
* Split and the rest swung their rounded hover background into a diamond. Three
* buttons had already been patched one by one with `transform: none`. The owner
* asked for a nicer hover on the folder and Tiles buttons; this file pins the
* shape of the fix:
*
* 1. No rule on a header icon button transforms the BUTTON on hover.
* 2. Only the gear's icon turns; the Tiles squares spread apart; the folder
* cross-fades from closed to open (two drawings in its SVG).
* 3. Those motions live inside `@media (hover: hover)`, so a tap on a touch
* screen cannot leave an icon stuck mid-motion, and reduced motion turns the
* transitions off.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type AtRule, type Rule } from 'postcss';
const ROOT = resolve(__dirname, '..');
const css = readFileSync(resolve(ROOT, 'src/web/public/styles.css'), 'utf8');
const html = readFileSync(resolve(ROOT, 'src/web/public/index.html'), 'utf8');
const root = postcss.parse(css);
interface FoundRule {
selector: string;
decls: Record<string, string>;
media: string[];
}
function rules(): FoundRule[] {
const out: FoundRule[] = [];
root.walkRules((rule: Rule) => {
const media: string[] = [];
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
if (p.type === 'atrule' && (p as AtRule).name === 'media') media.push((p as AtRule).params);
}
const decls: Record<string, string> = {};
rule.walkDecls((d) => {
decls[d.prop] = d.value;
});
for (const selector of rule.selectors) out.push({ selector, decls, media });
});
return out;
}
const all = rules();
const iconRules = all.filter((r) => r.selector.includes('btn-icon-header'));
function buttonTag(cls: string): string {
const i = html.indexOf(`class="btn-icon-header ${cls}`);
expect(i, `${cls} button in index.html`).toBeGreaterThan(-1);
return html.slice(i, html.indexOf('</button>', i));
}
describe('header icon hover', () => {
it('never transforms the button itself on hover', () => {
const offenders = iconRules.filter(
(r) => /:hover\s*$/.test(r.selector) && r.decls.transform && r.decls.transform !== 'none'
);
expect(offenders.map((r) => `${r.selector} { transform: ${r.decls.transform} }`)).toEqual([]);
});
it('turns only the gear icon, and only on pointer devices', () => {
const rotations = iconRules.filter((r) => /rotate\(/.test(r.decls.transform || ''));
expect(rotations.map((r) => r.selector)).toEqual(['.btn-icon-header.btn-settings:hover svg']);
expect(rotations[0].media).toContain('(hover: hover)');
});
it('spreads the four Tiles squares apart, each toward its own corner', () => {
const svg = buttonTag('btn-tile-grid');
expect(svg.match(/<rect /g)?.length).toBe(4);
const expected = ['(-1.5px, -1.5px)', '(1.5px, -1.5px)', '(-1.5px, 1.5px)', '(1.5px, 1.5px)'];
expected.forEach((offset, i) => {
const rule = iconRules.find(
(r) => r.selector === `.btn-icon-header.btn-tile-grid:hover svg rect:nth-of-type(${i + 1})`
);
expect(rule?.decls.transform, `rect ${i + 1}`).toBe(`translate${offset}`);
expect(rule?.media).toContain('(hover: hover)');
});
const box = iconRules.find(
(r) => r.selector === '.btn-icon-header.btn-tile-grid svg rect' && r.decls['transform-box']
);
expect(box?.decls['transform-origin']).toBe('center');
});
it('opens the folder: the open drawing is hidden at rest and replaces the closed one on hover', () => {
const svg = buttonTag('btn-file-viewer');
expect(svg).toContain('class="icon-folder-closed"');
expect(svg).toContain('class="icon-folder-open"');
const rest = iconRules.find(
(r) => r.selector === '.btn-icon-header.btn-file-viewer svg .icon-folder-open' && r.decls.opacity
);
expect(rest?.decls.opacity).toBe('0');
expect(rest?.media).toEqual([]);
const hoverClosed = iconRules.find(
(r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-closed'
);
const hoverOpen = iconRules.find(
(r) => r.selector === '.btn-icon-header.btn-file-viewer:hover svg .icon-folder-open'
);
expect(hoverClosed?.decls.opacity).toBe('0');
expect(hoverOpen?.decls.opacity).toBe('1');
expect(hoverClosed?.media).toContain('(hover: hover)');
expect(hoverOpen?.media).toContain('(hover: hover)');
});
it('turns the motion off under reduced motion', () => {
const reduced = iconRules.filter((r) => r.media.includes('(prefers-reduced-motion: reduce)'));
for (const sel of [
'.btn-icon-header.btn-settings svg',
'.btn-icon-header.btn-tile-grid svg rect',
'.btn-icon-header.btn-file-viewer svg .icon-folder-closed',
'.btn-icon-header.btn-file-viewer svg .icon-folder-open',
]) {
expect(reduced.find((r) => r.selector === sel)?.decls.transition, sel).toBe('none');
}
for (const sel of ['.btn-icon-header.btn-settings:hover svg', '.btn-icon-header.btn-tile-grid:hover svg rect']) {
expect(reduced.find((r) => r.selector === sel)?.decls.transform, sel).toBe('none');
}
});
});
+9 -1
View File
@@ -31,7 +31,7 @@ function escapeRegExp(value: string): string {
function expectShortcut(html: string, keys: string[], label: string): void {
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
expect(html).toMatch(new RegExp(`${keyPattern}.*?${escapeRegExp(label)}`, 'i'));
}
describe('help modal shortcuts', () => {
@@ -53,6 +53,14 @@ describe('help modal shortcuts', () => {
expectShortcut(helpModal, ['Escape'], 'Close Panels');
});
it('documents the tile grid chords', () => {
expectShortcut(helpModal, ['Ctrl', 'Shift', 'G'], 'Toggle Tile Grid');
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
expectShortcut(helpModal, ['Right-click'], 'Choose How Many Tiles (2, 4 or 6)');
});
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'C'], 'Copy Selection');
expectShortcut(helpModal, ['Ctrl', 'Shift', 'C'], 'Copy Selection');
+94
View File
@@ -91,6 +91,100 @@ describe('custom display name and browser localization', () => {
dom.window.close();
});
it('does not walk what a terminal renders: one check per mutation inside a skipped surface', async () => {
const dom = makeDom('<div class="xterm"><div class="xterm-rows" id="rows"></div></div><div id="app"></div>');
const { window } = dom;
const api = window.CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
// i18n.js also starts itself on DOMContentLoaded (one walk of the body).
await new Promise((resolve) => window.setTimeout(resolve, 0));
let walks = 0;
const createTreeWalker = window.document.createTreeWalker.bind(window.document);
window.document.createTreeWalker = (...args: Parameters<Document['createTreeWalker']>) => {
walks++;
return createTreeWalker(...args);
};
// What xterm's DOM renderer does every frame: replace the rows.
const rows = window.document.getElementById('rows')!;
for (let frame = 0; frame < 3; frame++) {
const fresh = Array.from({ length: 20 }, () => {
const row = window.document.createElement('div');
row.innerHTML = '<span>Settings saved</span><span title="Run">Run</span>';
return row;
});
rows.replaceChildren(...fresh);
}
rows.firstElementChild!.firstChild!.firstChild!.nodeValue = 'Settings saved';
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(walks).toBe(0);
expect(rows.firstElementChild?.textContent).toBe('Settings savedRun');
expect(rows.querySelector('[title]')?.getAttribute('title')).toBe('Run');
// Application DOM next to it is still translated.
const button = window.document.createElement('button');
button.textContent = 'Settings saved';
window.document.getElementById('app')!.appendChild(button);
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(button.textContent).toBe('设置已保存');
expect(walks).toBe(1);
dom.window.close();
});
it('decides an xterm row by its rows container, with the verdict closest() gives', async () => {
// A terminal (rows inside .xterm) and a stray `.xterm-rows` outside any
// skipped surface, which the full check translates.
const dom = makeDom(
'<div class="xterm"><div class="xterm-screen"><div class="xterm-rows" id="rows"></div></div></div>' +
'<div class="xterm-rows" id="stray"></div>'
);
const { window } = dom;
const api = window.CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
await new Promise((resolve) => window.setTimeout(resolve, 0));
const rowsOf = (id: string) =>
Array.from({ length: 20 }, () => {
const row = window.document.createElement('div');
window.document.getElementById(id)!.appendChild(row);
return row;
});
const rows = rowsOf('rows');
const stray = rowsOf('stray');
await new Promise((resolve) => window.setTimeout(resolve, 0));
let closest = 0;
const original = window.Element.prototype.closest;
window.Element.prototype.closest = function (this: Element, selector: string) {
closest++;
return original.call(this, selector);
};
// What xterm's DOM renderer does every frame: replace each row's children.
const frames = (list: Element[]) => {
for (let frame = 0; frame < 3; frame++) {
for (const row of list) {
const span = window.document.createElement('span');
span.textContent = 'Settings saved';
row.replaceChildren(span);
}
}
};
frames(rows);
await new Promise((resolve) => window.setTimeout(resolve, 0));
// 60 row records, ONE closest(): the container's own verdict, then kept.
expect(closest).toBe(1);
expect(rows.every((row) => row.textContent === 'Settings saved')).toBe(true);
// A rows container outside any skipped surface keeps the full check, and
// with it the verdict closest() gives: its rows are translated.
frames(stray);
await new Promise((resolve) => window.setTimeout(resolve, 0));
window.Element.prototype.closest = original;
expect(stray.every((row) => row.textContent === '设置已保存')).toBe(true);
dom.window.close();
});
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
const dom = makeDom('');
const api = dom.window.CodemanI18n;
+185
View File
@@ -0,0 +1,185 @@
/**
* @fileoverview zh-CN coverage for the tab's exited-agent badge (and the tab's
* accessible name that carries it).
*
* Every form `paneExitLabel()` produces (plain, an exit code, a negative code,
* a signal) must translate fully through i18n.js's real `t()`, with the
* session name in the accessible name passed through untranslated (a name
* that is itself a dictionary word included), and read unchanged in English.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
const I18N = read('i18n.js');
const APP = read('app.js');
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language });
return { api, dom };
}
const zh = translator('zh-CN');
const en = translator('en');
afterAll(() => {
zh.dom.window.close();
en.dom.window.close();
});
/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */
const leftover = (text: string) =>
text
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '')
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '')
.match(/[A-Za-z]+/g) ?? [];
const helpers = new Function(
`${APP.match(/function paneExitLabel\([\s\S]*?\n\}/)![0]}\n${APP.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)![0]}\n` +
'return { paneExitLabel, paneExitAriaLabel };'
)() as { paneExitLabel(p: unknown): string; paneExitAriaLabel(name: string, label: string): string };
const EXITS = [{}, { status: 0 }, { status: 3 }, { status: 137 }, { status: -1 }, { signal: 9 }];
describe('the exited-agent badge in zh-CN', () => {
const labels = EXITS.map((p) => helpers.paneExitLabel({ ...p, at: 1 }));
it('produces the forms this test covers', () => {
expect(labels).toEqual(['exited', 'exited (0)', 'exited (3)', 'exited (137)', 'exited (-1)', 'exited (signal 9)']);
});
it('every badge form translates fully, and reads the same in English', () => {
const bad = labels.filter((l) => {
const text = zh.api.t(l);
return text === l || leftover(text).length > 0 || en.api.t(l) !== l;
});
expect(bad).toEqual([]);
expect(zh.api.t('exited (3)')).toBe('已退出(3)');
expect(zh.api.t('exited (signal 9)')).toBe('已退出(信号 9)');
});
it("the tab's accessible name translates around the session name, which stays as typed", () => {
for (const name of ['w1-case', 'Open tiles', 'Tiles']) {
for (const label of labels) {
const source = helpers.paneExitAriaLabel(name, label);
const text = zh.api.t(source);
expect(text.startsWith(`${name} 会话,智能体已退出`), `${source} -> ${text}`).toBe(true);
expect(leftover(text.slice(name.length))).toEqual([]);
expect(en.api.t(source)).toBe(source);
}
}
});
});
describe('the Run button family in zh-CN', () => {
const SESSION_UI = read('session-ui.js');
const STOCK = JSON.parse(readFileSync(resolve(import.meta.dirname, '../config/clis.stock.json'), 'utf8')) as Array<{
shortBadge: string;
}>;
// What _applyRunMode can show: its hard-coded labels, and `Run <shortBadge>` for any registry CLI.
const applyRunMode = SESSION_UI.slice(
SESSION_UI.indexOf(' _applyRunMode() {'),
SESSION_UI.indexOf(' sendEnterKey() {')
);
const hardCoded = [...applyRunMode.matchAll(/'(Run(?: [A-Z]+)?)'/g)].map((m) => m[1]);
const fromRegistry = STOCK.map((e) => `Run ${e.shortBadge}`);
it('covers the hard-coded labels and every stock mode code', () => {
expect(hardCoded).toEqual(expect.arrayContaining(['Run SH', 'Run OC', 'Run CX', 'Run OMP', 'Run']));
expect(fromRegistry).toEqual(expect.arrayContaining(['Run CC', 'Run SH', 'Run OM']));
});
it('"Run" becomes 运行, the mode code and product names stay, English unchanged', () => {
for (const label of new Set([...hardCoded, ...fromRegistry])) {
const text = zh.api.t(label);
const code = label.slice(4);
// A code that is also a product name has its own entry, matched without
// case ("Run PI" -> the "Run Pi" entry, 运行 Pi): the code survives either way.
expect(text.startsWith('运行'), label).toBe(true);
expect(text.slice(2).trim().toLowerCase(), label).toBe(code.toLowerCase());
expect(en.api.t(label)).toBe(label);
}
});
it('the toolbar around it: titles and the Shell button', () => {
for (const s of [
'Run Shell',
'Select AI backend',
'Terminal / Shell',
'Send Enter',
'Instance count',
'Stop (Ctrl+C)',
]) {
const text = zh.api.t(s);
expect(text, s).not.toBe(s);
// "Shell" stays, as the table already had it (运行 Shell).
expect(leftover(text.replace(/Shell/g, '')), s).toEqual([]);
expect(en.api.t(s)).toBe(s);
}
});
});
describe('the Help modal and the shortcut overlay in zh-CN', () => {
const dom = new JSDOM(read('index.html'), { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const doc = dom.window.document;
const control = doc.createElement('button');
control.textContent = 'Home';
doc.body.appendChild(control);
const api = (dom.window as unknown as { CodemanI18n: Api & { start(): void } }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
it('no English left in the Help modal outside the key names', () => {
const left: string[] = [];
const walk = (el: Element) => {
for (const node of el.childNodes) {
if (node.nodeType === 3) {
if (node.parentElement?.tagName !== 'KBD' && leftover(node.nodeValue ?? '').length)
left.push(node.nodeValue!.trim());
} else if (node.nodeType === 1) walk(node as Element);
}
};
walk(doc.getElementById('helpModal')!);
expect(left).toEqual([]);
});
it('the Home KEY stays Home, while the word Home elsewhere still translates', () => {
const homeKey = [...doc.querySelectorAll('#helpModal kbd')].find((k) => k.closest('div')?.nextElementSibling);
const keys = [...doc.querySelectorAll('#helpModal kbd')].map((k) => k.textContent);
expect(keys).toContain('Home');
expect(keys).not.toContain('主页');
// Mouse inputs in the key column do translate (as Click / Right-click do).
expect(keys).toContain('滚轮');
expect(homeKey).toBeTruthy();
expect(control.textContent).toBe('主页');
});
it('every shortcut registry group and label translates (the overlay and the App Settings list)', () => {
const start = APP.indexOf('const DEFAULT_SHORTCUTS = [');
const registry = APP.slice(start, APP.indexOf('\n];', start));
const pairs = [...registry.matchAll(/group: '([^']+)',\s*label: '([^']+)'/g)];
expect(pairs.length).toBeGreaterThan(20);
const bad = pairs.flatMap(([, group, label]) =>
[group, label].filter((s) => zh.api.t(s) === s || leftover(zh.api.t(s)).length > 0)
);
expect([...new Set(bad)]).toEqual([]);
});
it("the overlay's key column is never translated", () => {
const overlay = APP.slice(APP.indexOf(' renderShortcutOverlay() {'), APP.indexOf(' closeShortcutOverlay() {'));
expect(overlay.match(/<kbd data-i18n-skip>/g)).toHaveLength(2);
expect(overlay).not.toMatch(/<kbd>/);
});
});
+8
View File
@@ -383,6 +383,14 @@ export class MockSession extends EventEmitter {
);
restartCli = vi.fn(async () => true);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
if (typeof raw !== 'string' || !raw.trim()) return false;
if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false;
this.reportedModel = { model: raw, source };
return true;
});
/** Stub for sendInput */
sendInput = vi.fn();
+133
View File
@@ -0,0 +1,133 @@
/**
* @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a
* `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in
* the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs
* `connect()` and the socket handlers for real.
*/
import { vi } from 'vitest';
export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */
export class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev?: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(JSON.parse(data) as Frame);
}
close = vi.fn(() => {
this.readyState = 3;
});
open() {
this.readyState = 1;
this.onopen?.();
}
receive(msg: object) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
inputFrames() {
return this.sent.filter((f) => f.t === 'i');
}
/** The connection drops: closed, and the tile hears `code`. */
drop(code = 1006) {
this.readyState = 3;
this.onclose?.({ code });
}
}
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
export class FakeFit {
static proposed = { cols: 80, rows: 24 };
term: FakeTerminal | null = null;
fit() {
const { cols, rows } = FakeFit.proposed;
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
this.term?.resize(cols, rows);
}
proposeDimensions() {
return { ...FakeFit.proposed };
}
}
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
export class FakeTerminal {
static last: FakeTerminal | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
open() {}
onData(cb: (data: string) => void) {
this.dataCb = cb;
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
textarea = {
addEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners.push(fn);
},
removeEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
},
};
focusTextarea() {
for (const fn of this.focusListeners) fn();
}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
writes: string[] = [];
/** Set by a test: write callbacks never run, as on a disposed xterm. */
holdParse = false;
write(data: string, cb?: () => void) {
// An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
if (!this.holdParse) cb?.();
}
clear() {
this.writes.push('<CLEAR>');
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
}
scrollToLine() {}
scrollToTop() {}
dispose() {}
type(data: string) {
this.dataCb?.(data);
}
/** What a drag selected; '' is no selection. */
selection = '';
hasSelection() {
return this.selection !== '';
}
getSelection() {
return this.selection;
}
clearSelection = vi.fn(() => {
this.selection = '';
});
focus = vi.fn();
}
+387
View File
@@ -0,0 +1,387 @@
/**
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
* tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake
* TerminalTile that records what the grid asks of it.
*
* `makeGridApp()` returns an app instance with everything around the grid that
* a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs
* (or deletes a stub to run the real method from the prototype).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { vi } from 'vitest';
/** What has the keyboard: the last FakeEl focused (document.activeElement). */
let focusedEl: unknown = null;
export const activeElement = () => focusedEl;
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
export class FakeEl {
id = '';
className = '';
dataset: Record<string, string> = {};
/** Inline style; `setProperty` (custom properties) writes into it too. */
style: Record<string, string> = Object.defineProperty({} as Record<string, string>, 'setProperty', {
value(this: Record<string, string>, name: string, value: string) {
this[name] = value;
},
enumerable: false,
});
inert = false;
children: FakeEl[] = [];
parentElement: FakeEl | null = null;
attrs: Record<string, string> = {};
listeners: Record<string, Array<(ev: unknown) => void>> = {};
classList = {
add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)),
remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)),
toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)),
contains: (n: string) => this.className.split(/\s+/).includes(n),
};
_setClass(name: string, on: boolean) {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
if (on) set.add(name);
else set.delete(name);
this.className = [...set].join(' ');
return on;
}
append(...children: FakeEl[]) {
for (const child of children) this.appendChild(child);
}
replaceWith(other: FakeEl) {
const parent = this.parentElement;
if (!parent) return;
other.remove();
const i = parent.children.indexOf(this);
parent.children.splice(i, 1, other);
other.parentElement = parent;
this.parentElement = null;
}
title = '';
hidden = false;
disabled = false;
checked = false;
contains(other: FakeEl | null): boolean {
for (let n: FakeEl | null = other; n; n = n.parentElement) if (n === this) return true;
return false;
}
textContent = '';
value = '';
type = '';
/** Records itself as `document.activeElement` (see activeElement()). */
focus = vi.fn(() => {
focusedEl = this;
});
select = vi.fn();
setPointerCapture = vi.fn();
releasePointerCapture = vi.fn();
removeEventListener(type: string, fn: (ev: unknown) => void) {
this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn);
}
appendChild(child: FakeEl) {
child.remove();
child.parentElement = this;
this.children.push(child);
return child;
}
insertBefore(child: FakeEl, ref: FakeEl | null) {
child.remove();
child.parentElement = this;
const i = ref ? this.children.indexOf(ref) : -1;
if (i === -1) this.children.push(child);
else this.children.splice(i, 0, child);
return child;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastElementChild() {
return this.children.at(-1) ?? null;
}
/** In the fake document: its ancestors end at `main` or `body`. */
get isConnected(): boolean {
let n: FakeEl = this;
while (n.parentElement) n = n.parentElement;
return n === main || n === body;
}
/** A copy of this element and (deep) its subtree: classes, data, style, attributes, text; no listeners. */
cloneNode(deep = false): FakeEl {
const copy = new FakeEl();
copy.id = this.id;
copy.className = this.className;
copy.dataset = { ...this.dataset };
for (const [k, v] of Object.entries(this.style)) copy.style[k] = v;
copy.attrs = { ...this.attrs };
copy.textContent = this.textContent;
copy.title = this.title;
copy.hidden = this.hidden;
if (deep) for (const child of this.children) copy.appendChild(child.cloneNode(true));
return copy;
}
get nextSibling() {
const siblings = this.parentElement?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
remove() {
if (!this.parentElement) return;
const siblings = this.parentElement.children;
const i = siblings.indexOf(this);
if (i !== -1) siblings.splice(i, 1);
this.parentElement = null;
}
setAttribute(k: string, v: string) {
this.attrs[k] = v;
}
getAttribute(k: string) {
return this.attrs[k] ?? null;
}
removeAttribute(k: string) {
delete this.attrs[k];
if (k === 'title') this.title = '';
}
/** Per event type, whether each listener was registered for the capture phase. */
captureFlags: Record<string, boolean[]> = {};
addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) {
(this.listeners[type] ||= []).push(fn);
(this.captureFlags[type] ||= []).push(opts === true || (typeof opts === 'object' && !!opts?.capture));
}
dispatch(type: string, ev: unknown = {}) {
for (const fn of this.listeners[type] ?? []) fn(ev);
}
getBoundingClientRect() {
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
}
/** `.class` selectors only: the first descendant carrying that class. */
querySelector(sel: string): FakeEl | null {
if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null;
const cls = sel.slice(1);
for (const child of this.children) {
if (child.classList.contains(cls)) return child;
const deeper = child.querySelector(sel);
if (deeper) return deeper;
}
return null;
}
}
/** A TerminalTile stand-in: records what the grid asks of it. */
export class FakeTile {
static all: FakeTile[] = [];
_wsReady = false;
_stoppedCode: number | null = null;
_destroyed = false;
fontSize: number | null;
terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record<string, unknown> };
connect = vi.fn(async () => {});
reconnectNow = vi.fn();
fit = vi.fn();
localFit = vi.fn();
paneStarted = vi.fn();
destroy = vi.fn(() => {
this._destroyed = true;
});
onExit: ((code: number) => void) | null;
constructor(
public sessionId: string,
public mountEl: FakeEl,
public opts: Record<string, unknown>
) {
this.fontSize = (opts.fontSize as number) ?? null;
this.onExit = (opts.onExit as (code: number) => void) ?? null;
FakeTile.all.push(this);
}
}
export const main = new FakeEl();
main.className = 'main';
/** The main terminal's `.terminal-wrap` (Pane A while a split is open). */
export const wrap = new FakeEl();
wrap.className = 'terminal-wrap';
main.appendChild(wrap);
export const section = new FakeEl();
section.id = 'tileGrid';
section.className = 'tile-grid';
main.appendChild(section);
/** The grid's element for one tiled session (its `.tile`), found by its data-session-id. */
export const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
export const bySelector = new Map<string, FakeEl>();
export const body = new FakeEl();
/** The context's `fetch`; a test sets what it answers. */
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
/** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn();
export const documentRemoveEventListener = vi.fn();
export const localStore = new Map<string, string>();
/** The clock behind `performance.now` inside the context; tests move it with advanceClock(). */
let clock = 100_000;
export function advanceClock(ms: number) {
clock += ms;
}
export const clockNow = () => clock;
/** Every callback the code under test handed a PerformanceObserver, newest last. */
export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = [];
/** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */
export const rafCallbacks: Array<() => void> = [];
/**
* Runs every queued animation frame, and the frames those queue in turn (the
* grid builds its tiles' terminals one per frame), until none is left.
*/
export function flushFrames(limit = 100) {
for (let n = 0; n < limit && rafCallbacks.length; n++) for (const cb of rafCallbacks.splice(0)) cb();
}
/** What the code under test deferred with requestIdleCallback; a test runs them. */
export const idleCallbacks: Array<() => void> = [];
export const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
innerWidth: 2400,
innerHeight: 1200,
};
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8');
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => clock },
PerformanceObserver: class {
constructor(cb: (list: { getEntries(): unknown[] }) => void) {
perfObserverCallbacks.push(cb);
}
observe() {}
disconnect() {}
},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: (cb: () => void) => rafCallbacks.push(cb),
cancelAnimationFrame: (id: number) => {
if (id > 0) rafCallbacks[id - 1] = () => {};
},
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: (...args: unknown[]) => fetchSpy(...args),
navigator: { onLine: true },
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
history: { replaceState: vi.fn(), state: null },
document: {
addEventListener: documentAddEventListener,
removeEventListener: documentRemoveEventListener,
documentElement: { dataset: {} },
createElement: () => new FakeEl(),
createElementNS: () => new FakeEl(),
get activeElement() {
return focusedEl;
},
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
body,
querySelector: (sel: string) =>
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
querySelectorAll: () => [],
},
localStorage: {
getItem: (k: string) => localStore.get(k) ?? null,
setItem: (k: string, v: string) => localStore.set(k, String(v)),
removeItem: (k: string) => localStore.delete(k),
},
window: windowStub,
VoiceInput: { cleanup: vi.fn() },
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
`${read('tile-grid.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
windowStub.TerminalTile = FakeTile;
windowStub.TileLoadQueue = class {
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GridApp = Record<string, any>;
/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */
const STUBS = [
'_cleanupPreviousSession',
'hideWelcome',
'showWelcome',
'markIdleAlertSeen',
'renderSessionTabs',
'renderRalphStatePanel',
'renderProjectInsightsPanel',
'_updateActiveTabImmediate',
'_refreshSessionPanels',
'_updateSseSubscription',
'_updateConnectionIndicator',
'_activateFileBrowserSession',
'_hideWebviewLayer',
'closeSessionSidebarOnHandheld',
'updateAttachmentHistoryBadge',
'refreshHostWakeBanner',
'_connectWs',
'sendResize',
'showToast',
'_cleanupSessionData',
'stopSystemStatsPolling',
];
/**
* An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and
* never tiled), `s-a` active, nothing open.
*/
export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
const app = Object.create(CodemanApp.prototype) as GridApp;
app.sessions = new Map(
[...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }])
);
app.sessionOrder = ['s-other', ...ids];
app.detachedSessions = new Set();
app.isSoloWindow = false;
app.activeSessionId = ids[0];
app._selectGeneration = 0;
app._xtermSnapshots = new Map();
app.terminalBufferCache = new Map();
app._pendingDeliveries = new Map();
app._closingSessions = new Set();
app.pendingHooks = new Map();
app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } };
for (const name of STUBS) app[name] = vi.fn();
app.loadAppSettingsFromStorage = () => ({});
return app;
}
/** Resets the shared fake DOM and tile registry between tests. */
export function resetGridHarness() {
FakeTile.all = [];
// A test's prefers-reduced-motion answer (window.matchMedia) goes with it.
delete windowStub.matchMedia;
focusedEl = null;
idleCallbacks.length = 0;
rafCallbacks.length = 0;
localStore.clear();
windowStub.innerWidth = 2400;
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
delete windowStub.__codemanCliCatalog;
section.children = [];
main.className = 'main';
// A split a test left open moved .terminal-wrap into its container, with
// Pane A's header strip in it.
main.children = [];
wrap.children = [];
main.appendChild(wrap);
main.appendChild(section);
bySelector.clear();
body.children = [];
}
+56
View File
@@ -1037,6 +1037,62 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
});
it('`lines=` bounds the history a full capture reads, clamped to the configured limit', async () => {
// Grid tiles send it (TerminalTile._historyLinesQuery): without it a full
// capture reads the whole history limit synchronously, and `tail` cuts it
// only afterwards. Absent or malformed, the limit itself, as before.
const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit;
harness.ctx._session.mode = 'claude';
const captureSpy = vi.fn((_name: string, _opts?: { historyLimitLines?: number }) => 'captured frame');
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const linesFor = async (query: string) => {
captureSpy.mockClear();
await harness.app.inject({ method: 'GET', url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}` });
return captureSpy.mock.calls[0]?.[1]?.historyLimitLines;
};
expect(await linesFor(`full=1&tail=${1024 * 1024}&lines=10040`)).toBe(10040);
expect(await linesFor('full=1&lines=1')).toBe(1);
expect(await linesFor('full=1')).toBe(limit);
expect(await linesFor(`full=1&lines=${limit + 5}`)).toBe(limit);
for (const bad of ['0', '-5', 'abc', '1.5', '', '1e4', '9999999999']) {
expect(await linesFor(`full=1&lines=${bad}`), `lines=${bad}`).toBe(limit);
}
});
it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => {
harness.ctx._session.mode = 'shell';
const captureSpy = vi.fn(() => 'only the visible frame');
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=${1024 * 1024}&lines=500`,
});
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {});
});
it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => {
// Only how much history tmux reads changes. The row-preserving skips key
// on isFullCapture, and the relative cursor move must still end it.
const cursorRestore = '\x1b[3A\r\x1b[2C';
const capture = `\r\n${['first row', 'second row', 'last row'].join('\r\n')}${cursorRestore}`;
harness.ctx._session.mode = 'claude';
harness.ctx._session.terminalBuffer = 'byte history that must not be prepended';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? capture : 'only the visible frame'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=40`,
});
const body = JSON.parse(res.body);
expect(body.data.source).toBe('mux-full-history');
expect(body.data.terminalBuffer.startsWith('\r\nfirst row')).toBe(true);
expect(body.data.terminalBuffer.endsWith(`last row${cursorRestore}`)).toBe(true);
expect(body.data.terminalBuffer).not.toContain('byte history');
});
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
// The full-history capture is the rendered form of everything already in
// the byte buffer; prepending the byte history would replay the whole
+57 -2
View File
@@ -6,12 +6,14 @@
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
* and 400 on a malformed body.
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
* records off the same payload.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
import { Session } from '../../src/session.js';
const SID = 'test-session-1'; // default id created by createMockRouteContext
@@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => {
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/status-telemetry: the session model (displayModel)', () => {
let h: RouteTestHarness;
beforeEach(async () => {
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
});
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
/** A real claude session in the route's map, so displayModel is the shipped resolver's. */
function realSession(mode: 'claude' | 'codex', id: string): Session {
const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters<typeof Session>[0]);
(h.ctx.sessions as unknown as Map<string, unknown>).set(id, session);
return session;
}
it('records the model the statusline names, from the first render (no rate limits yet)', async () => {
const session = realSession('claude', 'claude-1');
const changed = vi.fn();
session.on('displayModelChanged', changed);
const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } });
expect(res.statusCode).toBe(200);
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
expect(changed).toHaveBeenCalledTimes(1);
});
it('follows a model switch, and says nothing for an unchanged one', async () => {
const session = realSession('claude', 'claude-2');
const changed = vi.fn();
session.on('displayModelChanged', changed);
await post({ sessionId: 'claude-2', data: REAL });
await post({ sessionId: 'claude-2', data: REAL });
expect(changed).toHaveBeenCalledTimes(1);
await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } });
expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' });
expect(changed).toHaveBeenCalledTimes(2);
});
it('keeps a model name with markup as text, and a null name changes nothing', async () => {
const session = realSession('claude', 'claude-3');
await post({ sessionId: 'claude-3', data: { model: { display_name: '<b>Opus</b>' } } });
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
await post({ sessionId: 'claude-3', data: { model: { display_name: null } } });
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
});
it('takes no model from a CLI that has no statusline exporter', async () => {
const session = realSession('codex', 'codex-1');
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
expect(session.toState().displayModel).toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Static guard for the Run menus' per-CLI logos (styles.css).
*
* Every launch surface (toolbar Run menu, phone overview picker, Custom Endpoint
* rows, model picker) renders `<span class="run-mode-dot <id>">`, and styles.css
* turns that slot into the CLI's logo through a `--run-mode-logo` data URI plus
* one of two paint groups: brand-coloured marks paint as a background image,
* monochrome marks as a mask over the row's text colour. Three ways that breaks
* silently, each pinned here:
* - a new stock CLI lands with no logo rule (its row shows a bare grey dot);
* - a logo rule exists but the id is in neither paint group (the variable is
* set and never painted, so the slot is a blank 15px gap);
* - a later `background:` shorthand on `.run-mode-dot.<id>` (e.g. a skin
* override like the ones the non-og block used to carry) resets
* background-image and wipes the logo.
*
* Port: none (pure static analysis).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
// Comments stripped: they sit between rules and carry commas, which would bleed
// into the selector lists split below.
const styles = readFileSync(resolve('src/web/public/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
/** Every id a Run menu renders as `.run-mode-dot <id>`: the stock agents, Shell, and web tabs. */
const RUN_MENU_IDS = [
...STOCK_CLIS.filter((cli) => cli.kind === 'agent').map((cli) => cli.id as string),
'shell',
'web',
];
/** The selector list of the one rule whose body contains `marker`. */
function groupIds(marker: string): string[] {
const rule = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[2].includes(marker));
expect(rule, `exactly one rule paints via "${marker}"`).toHaveLength(1);
return rule[0][1]
.split(',')
.map((s) => s.trim().match(/^\.run-mode-dot\.([a-z0-9-]+)$/)?.[1])
.filter((id): id is string => Boolean(id));
}
function logoSvg(id: string): string {
const m = styles.match(
new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\("data:image/svg\\+xml,([^"]+)"\\); \\}`)
);
expect(m, `.run-mode-dot.${id} has a --run-mode-logo rule`).not.toBeNull();
return decodeURIComponent(m![1]);
}
describe('Run menu CLI logos', () => {
const colourIds = groupIds('background: var(--run-mode-logo)');
const maskIds = groupIds('mask: var(--run-mode-logo)');
it.each(RUN_MENU_IDS)('%s has a logo that one paint group draws', (id) => {
const svg = logoSvg(id);
expect(svg.startsWith('<svg')).toBe(true);
expect(svg).toMatch(/viewBox='[\d. ]+'/);
expect(colourIds.includes(id) !== maskIds.includes(id), `${id} is in exactly one paint group`).toBe(true);
});
it('defines no logo that no surface renders, and no group member without a logo', () => {
expect([...colourIds, ...maskIds].sort()).toEqual([...RUN_MENU_IDS].sort());
});
it('carries no script or event handler inside a data URI', () => {
for (const id of RUN_MENU_IDS) {
const svg = logoSvg(id);
expect(svg).not.toMatch(/<script|\son[a-z]+=|javascript:|href=/i);
}
});
it('never resets a logo slot with a background shorthand outside the colour group', () => {
const offenders = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter(
([, selector, body]) =>
/\.run-mode-dot\.[a-z]/.test(selector) &&
/(^|[;\s])background\s*:/.test(body) &&
!body.includes('var(--run-mode-logo)')
);
expect(offenders.map((m) => m[1].trim())).toEqual([]);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* The config source of a session's `displayModel`: the model its CLI's own config pins
* (`capabilities.modelDetect.configResolver`), read at each pane start, attach or
* relaunch, ranked below any report from the running CLI.
*
* The reader is mocked here so the session's own rules are what is tested: when it
* reads, with what, and which answer wins. The reader itself (dsh-TUI's route) is
* `test/deepseek-route-config.test.ts`; one end-to-end read over a fixture dsh home
* is in `test/session-display-model.test.ts`.
*
* Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
const resolveConfigModel = vi.fn();
vi.mock('../src/model-config-resolvers.js', () => ({
resolveConfigModel: (...a: unknown[]) => resolveConfigModel(...a),
}));
const { Session } = await import('../src/session.js');
type Internals = {
_withPaneLifecycle<T>(op: () => Promise<T>): Promise<T>;
_isStopped: boolean;
};
/** A deferred answer, so a test decides when (and in which order) reads land. */
function deferred() {
let resolve!: (v: string | null) => void;
const promise = new Promise<string | null>((r) => (resolve = r));
return { promise, resolve };
}
function session(extra: Record<string, unknown> = {}, mode = 'deepseek') {
return new Session({ workingDir: '/tmp', mode, ...extra } as ConstructorParameters<typeof Session>[0]);
}
/** One pane start: what triggers the read. */
const start = (s: InstanceType<typeof Session>) => (s as unknown as Internals)._withPaneLifecycle(async () => {});
const flush = () => new Promise((r) => setTimeout(r, 0));
beforeEach(() => {
resolveConfigModel.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("a session's config model", () => {
it("is read at each pane start, with the session's own config and env", async () => {
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
// The server has a dsh home of its own; the session's override is the one its CLI gets.
const before = process.env.DSH_HOME;
process.env.DSH_HOME = '/srv/server-dsh';
onTestFinished(() => {
if (before === undefined) delete process.env.DSH_HOME;
else process.env.DSH_HOME = before;
});
const s = session({ deepSeekConfig: { profile: 'dsh-tui' }, envOverrides: { DSH_HOME: '/srv/dsh-copy' } });
const changed = vi.fn();
s.on('displayModelChanged', changed);
expect(resolveConfigModel).not.toHaveBeenCalled();
await start(s);
await flush();
expect(resolveConfigModel).toHaveBeenCalledTimes(1);
const [name, ctx] = resolveConfigModel.mock.calls[0];
expect(name).toBe('deepseek-route');
expect(ctx.config).toEqual({ profile: 'dsh-tui' });
expect(ctx.env('DSH_HOME')).toBe('/srv/dsh-copy');
expect(s.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' });
expect(changed).toHaveBeenCalledTimes(1);
// A relaunch reads again; the same answer announces nothing new.
await start(s);
await flush();
expect(resolveConfigModel).toHaveBeenCalledTimes(2);
expect(changed).toHaveBeenCalledTimes(1);
});
it('without a session DSH_HOME the server one is what the CLI inherits', async () => {
resolveConfigModel.mockResolvedValue(null);
const before = process.env.DSH_HOME;
process.env.DSH_HOME = '/srv/server-dsh';
try {
const s = session();
await start(s);
expect(resolveConfigModel.mock.calls[0][1].env('DSH_HOME')).toBe('/srv/server-dsh');
} finally {
if (before === undefined) delete process.env.DSH_HOME;
else process.env.DSH_HOME = before;
}
});
it('the screen wins over the config whenever it names a model, in either order', async () => {
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
const s = session();
await start(s);
await flush();
s.noteReportedModel('screen', 'deepseek-v4-flash');
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
await start(s);
await flush();
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
});
it('a screen value restored after a restart outranks a fresh config read', async () => {
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
const s = session({ displayModel: { model: 'deepseek-v4-flash', source: 'screen' } });
await start(s);
await flush();
expect(s.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
});
it('the config outranks the launch model, and a config that pins nothing leaves it', async () => {
resolveConfigModel.mockResolvedValue('qwen-from-config');
const codex = session({ codexConfig: { model: 'gpt-5.5' } }, 'codex');
await start(codex);
await flush();
// codex declares no config reader: nothing is read, the launch model stays.
expect(resolveConfigModel).not.toHaveBeenCalled();
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
resolveConfigModel.mockResolvedValue(null);
const s = session();
await start(s);
await flush();
expect(s.toState().displayModel).toBeUndefined();
});
it('a read that lands after a newer one is dropped, and so is one after the session stopped', async () => {
const first = deferred();
const second = deferred();
resolveConfigModel.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const s = session();
await start(s);
await start(s);
second.resolve('newer');
await flush();
first.resolve('older');
await flush();
expect(s.toState().displayModel?.model).toBe('newer');
const late = deferred();
resolveConfigModel.mockReturnValueOnce(late.promise);
const stopped = session();
await start(stopped);
(stopped as unknown as Internals)._isStopped = true;
late.resolve('too late');
await flush();
expect(stopped.toState().displayModel).toBeUndefined();
});
it('a remote or docker session reads nothing locally', async () => {
resolveConfigModel.mockResolvedValue('qwen3.8-27b');
await start(session({ remote: { hostId: 'h', label: 'h', host: 'h', username: 'u', remotePath: '/w' } }));
await start(
session({
docker: {
hostId: 'd',
label: 'd',
engine: 'docker',
image: 'i',
containerName: 'c',
hostWorkspacePath: '/w',
containerWorkdir: '/w',
},
})
);
await flush();
expect(resolveConfigModel).not.toHaveBeenCalled();
});
it('a config model is untrusted text: control characters dropped, length capped', async () => {
resolveConfigModel.mockResolvedValue(`\x1b[31mqwen\x1b[0m${'x'.repeat(200)}`);
const s = session();
await start(s);
await flush();
const dm = s.toState().displayModel!;
expect(dm.source).toBe('config');
expect(dm.model.startsWith('qwenx')).toBe(true);
expect(dm.model.length).toBe(64);
});
});
+454
View File
@@ -0,0 +1,454 @@
/**
* Which model a session runs (`SessionState.displayModel`), for the tile grid's and the
* split pane's headers: the pure resolver, the screen read with each CLI's registry
* pattern, and the session that feeds them.
*
* The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from
* live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import {
MAX_DISPLAY_MODEL_CHARS,
launchModelFor,
readScreenModel,
resolveDisplayModel,
restoredReportedModel,
sanitizeModelName,
} from '../src/session-display-model.js';
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
const DSH_ROWS = detectOf('deepseek').screenLines;
const DSH_REJECT = detectOf('deepseek').rejectWords;
const CODEX = compileVersionRegex(detectOf('codex').screenLine)!;
const CODEX_ROWS = detectOf('codex').screenLines;
const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮';
const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯';
const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶';
/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */
function dshPane(statusLine: string | null, transcript: string[] = []): string {
return [
' Tip: Footer compact on = one merged line; off = metric…',
' Explore the uncharted!',
' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …',
...transcript,
DSH_BORDER_TOP,
DSH_COMPOSER,
DSH_BORDER_BOTTOM,
...(statusLine === null ? [] : [statusLine]),
'',
].join('\n');
}
const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch');
/** The foot of a codex pane: transcript, composer, then the status line on the last row. */
function codexPane(statusLine: string | null, transcript: string[] = []): string {
return [
'│ directory: ~/codeman-cases/th-scratch │',
'│ permissions: YOLO mode │',
'╰───────────────────────────────────────────────╯',
' Tip: New For a limited time, Codex is included in your',
' plan for free – let’s build together.',
...transcript,
'› Explain this codebase',
...(statusLine === null ? [] : [statusLine]),
'',
].join('\n');
}
const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch');
const CODEX_154 = codexPane(
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left'
);
describe('the registry patterns', () => {
it('compile through compileVersionRegex() with exactly one capture group', () => {
for (const mode of ['deepseek', 'codex']) {
const { screenLine } = detectOf(mode);
expect(compileVersionRegex(screenLine), mode).not.toBeNull();
expect(countCaptureGroups(screenLine), mode).toBe(1);
}
});
it('are declared only where a footer was measured (claude reports through its statusline)', () => {
expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined();
expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined();
});
it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true);
expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false);
expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false);
expect(withDetect({ screenLine: '(a+)+$' })).toBe(false);
expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false);
});
it("dsh declares what its footer's first field can be when it is not the model", () => {
// Every effort id dsh's adapters offer, and the shipped mode ids.
expect(DSH_REJECT).toEqual(
expect.arrayContaining(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'])
);
expect(detectOf('codex').rejectWords).toBeUndefined();
});
it('the schema takes single-token reject words with a screenLine only, and bounds them', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
const screenLine = '^ {2}([a-z]+) · ';
expect(withDetect({ screenLine, rejectWords: ['medium'] })).toBe(true);
expect(withDetect({ configResolver: 'deepseek-route', rejectWords: ['medium'] })).toBe(false);
expect(withDetect({ screenLine, rejectWords: ['two words'] })).toBe(false);
expect(withDetect({ screenLine, rejectWords: Array.from({ length: 33 }, (_, i) => `w${i}`) })).toBe(false);
});
it('dsh also names a config reader for while its screen names no model', () => {
expect(detectOf('deepseek').configResolver).toBe('deepseek-route');
expect(detectOf('codex').configResolver).toBeUndefined();
});
it('the schema takes a known config reader alone, and refuses an empty or unknown one', () => {
const codex = getCli('codex')!;
const withDetect = (modelDetect: unknown) =>
CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success;
expect(withDetect({ configResolver: 'deepseek-route' })).toBe(true);
expect(withDetect({ configResolver: 'read-anything' })).toBe(false);
expect(withDetect({})).toBe(false);
expect(withDetect({ configResolver: 'deepseek-route', screenLines: 2 })).toBe(false);
});
it('countCaptureGroups counts named groups and ignores non-capturing ones', () => {
expect(countCaptureGroups('a(?:b)(?<m>c)')).toBe(1);
expect(countCaptureGroups('(a)(b)')).toBe(2);
expect(countCaptureGroups('abc')).toBe(0);
expect(countCaptureGroups('(')).toBe(-1);
});
});
describe('readScreenModel', () => {
it("reads dsh's model off the row under its composer", () => {
expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
// A footer with the model alone, and one with a row below it (the activity line).
expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash');
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe(
'qwen3.8-27b'
);
});
it("never reads the field after a switched-off model as the model (dsh's effort, mode, folder)", () => {
// dsh-TUI with `statusBar.model: false` (live capture: ` medium · th-config`): the
// effort id comes first, then the mode, then the folder name.
const at = (cwdBasename: string) => ({ rejectWords: DSH_REJECT, cwdBasename });
for (const effort of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) {
expect(readScreenModel(dshPane(` ${effort} · x`), DSH, DSH_ROWS, at('x')), effort).toBeUndefined();
}
expect(readScreenModel(dshPane(' default · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// The words compare ignoring case (the banner capitalizes effort; the footer does not).
expect(readScreenModel(dshPane(' Medium · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// A mode's drawn label is two words: never one field the pattern takes.
expect(readScreenModel(dshPane(' plan mode · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined();
// The session's own folder name first, with and without a digit.
expect(readScreenModel(dshPane(' th-config'), DSH, DSH_ROWS, at('th-config'))).toBeUndefined();
expect(readScreenModel(dshPane(' project2 · main'), DSH, DSH_ROWS, at('project2'))).toBeUndefined();
// A field that is none of those IS read: the same `project2` in another folder.
expect(readScreenModel(dshPane(' project2'), DSH, DSH_ROWS, at('elsewhere'))).toBe('project2');
});
it('reads the official DeepSeek ids, which carry no digit, with the model field on', () => {
const at = { rejectWords: DSH_REJECT, cwdBasename: 'th-config' };
expect(readScreenModel(dshPane(' deepseek-chat · max · th-config'), DSH, DSH_ROWS, at)).toBe('deepseek-chat');
expect(readScreenModel(dshPane(' deepseek-reasoner · high · th-config'), DSH, DSH_ROWS, at)).toBe(
'deepseek-reasoner'
);
// The live qwen footer still reads.
expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch'), DSH, DSH_ROWS, at)).toBe('qwen3.8-27b');
});
it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => {
expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol');
});
it('never takes a transcript line shaped like the footer', () => {
// The agent printed a line exactly like each CLI's footer, and the real footer is
// hidden (a dsh status bar switched off; a codex popup over its last row). The
// transcript sits above the composer, so neither may be read as the model.
const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']);
expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined();
const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']);
// A codex screen whose last row is the composer: nothing to read.
expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined();
// With the real footer back, the real model wins over the forged line above it.
const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [
DSH_BORDER_BOTTOM,
' evil-model · medium · th-scratch',
]);
expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [
' evil-model high · ~/codeman-cases/th-scratch',
]);
expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra');
});
it('does not read a popup under the composer as a model', () => {
const slash = dshPane(' /model Switch the model route');
expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined();
const codexSlash = codexPane(' /model choose what model and reasoning effort to use');
expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined();
// A last row without codex's `<model> <effort> ·` shape names no model, even one
// whose first word could pass for a model id.
const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch');
expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined();
});
it('says nothing about an empty or unreadable frame', () => {
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
});
});
describe('sanitizeModelName', () => {
it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => {
expect(sanitizeModelName('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>');
});
it('drops escape sequences and control characters, and collapses whitespace', () => {
expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8‮\n')).toBe('Opus 4.8');
expect(sanitizeModelName('\x00\x07')).toBeUndefined();
expect(sanitizeModelName(' ')).toBeUndefined();
expect(sanitizeModelName(42)).toBeUndefined();
});
it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => {
expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS);
});
});
describe('resolveDisplayModel', () => {
const reported = { model: 'Sonnet 4.6', source: 'statusline' as const };
it('the custom endpoint wins, then the newest report, then the launch model', () => {
expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({
model: 'qwen3.8-27b',
source: 'custom-endpoint',
});
expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported);
expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' });
});
it("the config ranks below the CLI's own report and above the launch model", () => {
expect(resolveDisplayModel({ reported, configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual(reported);
expect(resolveDisplayModel({ configModel: 'qwen3.8-27b', launchModel: 'opus' })).toEqual({
model: 'qwen3.8-27b',
source: 'config',
});
expect(resolveDisplayModel({ customModelId: 'm', configModel: 'qwen3.8-27b' })?.source).toBe('custom-endpoint');
expect(resolveDisplayModel({ configModel: null, launchModel: 'opus' })?.source).toBe('launch');
});
it('knows nothing when nothing is known: no placeholder', () => {
expect(resolveDisplayModel({})).toBeUndefined();
expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined();
});
});
describe('restoredReportedModel', () => {
it('restores what the CLI reported, never a derived answer', () => {
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({
model: 'qwen3.8-27b',
source: 'screen',
});
expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline');
expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined();
expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'config' })).toBeUndefined();
expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined();
expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined();
expect(restoredReportedModel('screen')).toBeUndefined();
expect(restoredReportedModel(undefined)).toBeUndefined();
});
});
describe('launchModelFor', () => {
it("reads the model param where each CLI's registry entry keeps it", () => {
expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku');
expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5');
expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast');
// Another CLI's config, or a top-level model, is not this CLI's launch model.
expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined();
});
it('has none for a CLI whose model is not a launch param', () => {
expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined();
expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined();
expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined();
});
});
describe('a session', () => {
afterEach(() => {
vi.useRealTimers();
});
type Internals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
_customModel?: { endpointId: string; modelId: string; envKeys: string[] };
};
function withFakePane(mode: string, screen: () => string | null, extra: Record<string, unknown> = {}): Session {
const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable<
ConstructorParameters<typeof Session>[0]
>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
...extra,
} as ConstructorParameters<typeof Session>[0]);
}
/** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */
function settle(session: Session, glyph: string): void {
const internals = session as unknown as Internals;
for (let i = 0; i < 3; i++) {
const frame = `\x1b[31;1H${glyph}\xa0`;
internals._handleTerminalOutput(frame);
internals._detectInteractiveActivity(frame);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
it('publishes the model its footer names, and follows the footer when it changes', () => {
vi.useFakeTimers();
let screen = DSH_LIVE;
const session = withFakePane('deepseek', () => screen);
const changed = vi.fn();
session.on('displayModelChanged', changed);
expect(session.toState().displayModel).toBeUndefined();
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
expect(changed).toHaveBeenCalledTimes(1);
// The same footer again: nothing new to say.
settle(session, '❯');
expect(changed).toHaveBeenCalledTimes(1);
// An in-session switch redraws the footer.
screen = dshPane(' deepseek-v4-flash · high · th-scratch');
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
expect(changed).toHaveBeenCalledTimes(2);
});
it("a footer field equal to the session's folder is not its model; the official ids are", () => {
vi.useFakeTimers();
let screen = dshPane(' th-config');
const session = withFakePane('deepseek', () => screen, { workingDir: '/w/th-config' });
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
screen = dshPane(' medium · th-config');
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
screen = dshPane(' deepseek-chat · max · th-config');
settle(session, '❯');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-chat', source: 'screen' });
});
it('keeps the last model when the footer cannot be read', () => {
vi.useFakeTimers();
let screen: string | null = CODEX_LIVE;
const session = withFakePane('codex', () => screen);
settle(session, '›');
expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra');
screen = codexPane(' /model choose what model and reasoning effort to use');
settle(session, '›');
screen = null;
settle(session, '›');
expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' });
});
it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => {
vi.useFakeTimers();
const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n');
settle(session, '❯');
expect(session.toState().displayModel).toBeUndefined();
});
it('shows the launch model until the CLI reports, and a custom endpoint over both', () => {
const session = withFakePane('claude', () => null, { model: 'haiku' });
expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' });
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true);
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false);
expect(session.noteReportedModel('statusline', '')).toBe(false);
(session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] };
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' });
(session as unknown as Internals)._customModel = undefined;
expect(session.toState().displayModel?.model).toBe('Haiku 4.5');
});
it("an external CLI's launch model is its own config's, never the inert top-level one", () => {
const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } });
expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' });
const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } });
expect(dsh.toState().displayModel).toBeUndefined();
});
it('restores a reported model after a restart, until the next report replaces it', () => {
const session = withFakePane('deepseek', () => null, {
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' });
session.noteReportedModel('screen', 'deepseek-v4-flash');
expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash');
// A launch answer from the previous run is derived again, not restored.
const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } });
expect(claude.toState().displayModel).toBeUndefined();
});
});
describe('a dsh session over a fixture dsh home (end to end, no mocks)', () => {
it('names the route its profile pins until the screen names one, then the screen', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-e2e-'));
try {
const home = join(root, 'dsh');
mkdirSync(join(home, 'profiles', 'dsh-tui'), { recursive: true });
writeFileSync(
join(home, 'profiles', 'dsh-tui', 'package.json'),
JSON.stringify({ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui'] } } })
);
writeFileSync(
join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'),
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n'
);
const session = new Session({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { profile: 'dsh-tui' },
envOverrides: { DSH_HOME: home },
} as ConstructorParameters<typeof Session>[0]);
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {}
);
await vi.waitFor(() =>
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'config' })
);
session.noteReportedModel('screen', 'deepseek-v4-flash');
expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+26 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
import {
attachSessionListeners,
createSessionListeners,
detachSessionListeners,
} from '../src/web/session-listener-wiring.js';
import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
@@ -37,6 +41,27 @@ describe('session listener wiring', () => {
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
});
it('pushes and persists the session state when the reported model changes', () => {
// A model switch changes nothing the status reflects, so without its own broadcast
// every header would keep naming the old model; persisting it is what lets a restart
// restore it.
const session = new Session({ id: 'wiring-model-test', workingDir: '/tmp', mode: 'claude' });
const broadcastSessionStateDebounced = vi.fn();
const persistSessionState = vi.fn();
const deps = { broadcastSessionStateDebounced, persistSessionState } as unknown as Parameters<
typeof createSessionListeners
>[1];
const refs = createSessionListeners(session, deps);
attachSessionListeners(session, refs);
session.noteReportedModel('statusline', 'Sonnet 4.6');
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-model-test');
expect(persistSessionState).toHaveBeenCalledWith(session);
detachSessionListeners(session, refs);
expect(session.listenerCount('displayModelChanged')).toBe(0);
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+25 -4
View File
@@ -92,10 +92,27 @@ describe('the exited-agent badge in a tab', () => {
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
});
it('marks the badge data-i18n-skip, like the other generated status text', () => {
it('leaves the badge to the translator (zh-CN) and remembers its English', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(false);
expect((badge(tab) as unknown as HTMLElement).dataset.label).toBe('exited (0)');
});
it('a translated badge and accessible name survive an unchanged pass; a change writes the new English', () => {
// The translator rewrites both in zh-CN. Comparing with the DOM would write
// the English back on every incremental pass for it to translate again.
const tab = makeTab();
applyPaneExitBadge(tab, { status: 3, at: 1 });
const el = badge(tab) as unknown as HTMLElement;
el.textContent = '已退出(3)';
tab.setAttribute('aria-label', 'w1-case 会话,智能体已退出(3)');
applyPaneExitBadge(tab, { status: 3, at: 1 });
expect(el.textContent).toBe('已退出(3)');
expect(tab.getAttribute('aria-label')).toBe('w1-case 会话,智能体已退出(3)');
applyPaneExitBadge(tab, { signal: 9, at: 2 });
expect(el.textContent).toBe('exited (signal 9)');
expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (signal 9)');
});
it('hides the badge from assistive technology, like its sibling badges', () => {
@@ -115,8 +132,12 @@ describe('the exited-agent badge in a tab', () => {
});
it('builds the full render path accessible name from the same helper', () => {
expect(appJs).toContain('aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"');
expect(appJs).toContain('<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">');
expect(appJs).toContain(
'aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"'
);
expect(appJs).toContain(
'<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">'
);
});
it('updates the text in place rather than stacking a second badge', () => {
+55 -1
View File
@@ -110,6 +110,60 @@ describe('selectSession acknowledgement gate', () => {
});
});
describe('with the tile grid open (tile-grid.js)', () => {
// A plain stand-in for the grid: the branch reads only `open` and `has(id)`.
function withGrid(app: ReturnType<typeof makeApp>, tiled: string[]) {
const a = app as Record<string, unknown>;
a._tileGrid = { open: true, has: (id: string) => tiled.includes(id) };
a._selectTiledSession = vi.fn();
a.closeTileGrid = vi.fn();
a._connectWs = vi.fn();
return a;
}
it('a tiled session goes to its tile, never the main terminal, and the tile decides the ack', async () => {
const app = withGrid(makeApp('other'), [SID]);
await (app.selectSession as (id: string) => Promise<void>)(SID);
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, {});
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
expect(app._connectWs).not.toHaveBeenCalled();
// The branch returns before the main path's own acknowledgement line.
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('passes `auto` through, so an app-driven focus stays unacknowledged', async () => {
const app = withGrid(makeApp('other'), [SID]);
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
expect(app._selectTiledSession).toHaveBeenCalledWith(SID, { auto: true });
});
it('an `auto` selection of a session that is not tiled leaves the grid open and acknowledges nothing', async () => {
const app = withGrid(makeApp('other'), []);
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, { auto: true });
expect(app.closeTileGrid).not.toHaveBeenCalled();
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('a user-initiated one leaves the grid (remembered) and switches normally, acknowledging', async () => {
const app = withGrid(makeApp('other'), []);
await (app.selectSession as (id: string) => Promise<void>)(SID).catch(() => {});
expect(app.closeTileGrid).toHaveBeenCalledWith({ keepStored: true, reselect: false });
expect(app._cleanupPreviousSession).toHaveBeenCalled();
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
});
it('`leaveTiles` leaves it too, without acknowledging an `auto` pick', async () => {
const app = withGrid(makeApp('other'), []);
await (app.selectSession as (id: string, o: object) => Promise<void>)(SID, {
auto: true,
leaveTiles: true,
}).catch(() => {});
expect(app.closeTileGrid).toHaveBeenCalledTimes(1);
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
});
describe('the call sites the app drives itself', () => {
// Source guard: these call sites are the reason the flag exists. If a refactor
// moves or reformats them, fail loudly rather than silently going back to
@@ -119,7 +173,7 @@ describe('selectSession acknowledgement gate', () => {
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true, leaveTiles: true });'],
])('%s passes auto: true', (_label, call) => {
expect(APP_SOURCE).toContain(call);
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @fileoverview Each session in the split view names its harness and model.
*
* - Pane B's header is the tile header's strip: the harness logo (PR #532's
* `run-mode-dot <cliId>` slot), the name and the model, and the close button
* at a tile button's size.
* - Pane A is the main terminal, which has no header of its own: while the
* split is open it gets the same strip (minus the close), as the FIRST child
* of `.terminal-wrap`, and loses it when the split closes. That strip takes
* height from the main terminal, so opening fits it through sendResize (its
* first step is syncTerminalGeometry, #464) with the strip already in place,
* and closing gives the height back the same way, never with a bare
* `fitAddon.fit()`.
* - Every tab render refreshes both headers (a rename, a model switch, the
* active session changing under Pane A); an unchanged session writes nothing.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
main,
makeGridApp,
resetGridHarness,
windowStub,
wrap,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
beforeEach(() => {
resetGridHarness();
windowStub.__codemanCliCatalog = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
];
});
/** s-a (dsh on its route) in the main pane, s-b (claude) in Pane B. */
function openSplit(): GridApp {
const app = makeGridApp(IDS);
Object.assign(app.sessions.get('s-a'), {
name: 'review-api',
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
Object.assign(app.sessions.get('s-b'), {
name: 'docs-pass',
displayModel: { model: 'Haiku 4.5', source: 'statusline' },
});
app.syncTerminalGeometry = vi.fn();
app.fitAddon = { fit: vi.fn() };
app.openSplitPane('s-b');
// closeSplitPane finds its container by selector.
const container = main.querySelector('.terminal-split-container');
if (container) bySelector.set('.terminal-split-container', container);
return app;
}
const headerB = () => main.querySelector('.terminal-pane-b')!.querySelector('.terminal-pane-b-header') as FakeEl;
const headerA = () => wrap.querySelector('.terminal-pane-a-header');
const part = (header: FakeEl, cls: string) => header.querySelector(`.${cls}`) as FakeEl;
const modelName = (header: FakeEl) => part(header, 'split-model').children[0];
/** A tab render, through the real wrapper chain (the original returns early mid-rename). */
function renderTabs(app: GridApp) {
app._inlineRenameActive = true;
app._renderSessionTabsImmediate();
}
describe('the split headers', () => {
it("Pane B's header: logo, name, model, and the close button at a tile button's size", () => {
openSplit();
const b = headerB();
expect(b.children.map((c) => c.className.split(' ')[0])).toEqual(['split-harness', 'split-title', 'tile-btn']);
expect(part(b, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(b, 'session-name').textContent).toBe('docs-pass');
expect(modelName(b).textContent).toBe('Haiku 4.5');
expect(part(b, 'split-harness').title).toBe('Claude Code · Haiku 4.5');
expect(part(b, 'split-harness').getAttribute('aria-label')).toBe('Claude Code · Haiku 4.5');
const close = part(b, 'terminal-pane-b-close');
expect(close.className).toBe('tile-btn tile-remove terminal-pane-b-close');
expect(close.getAttribute('aria-label')).toBe('Close split');
expect(close.textContent).toBe('×');
});
it("Pane A gets the same strip, first in the main terminal's wrap, without a close", () => {
openSplit();
const a = headerA()!;
expect(wrap.children[0]).toBe(a);
expect(a.className).toBe('terminal-pane-b-header terminal-pane-a-header');
expect(part(a, 'split-harness').className).toBe('split-harness run-mode-dot deepseek');
expect(part(a, 'session-name').textContent).toBe('review-api');
expect(modelName(a).textContent).toBe('qwen3.8-27b');
expect(part(a, 'split-harness').title).toBe('DeepSeek · qwen3.8-27b');
expect(part(a, 'terminal-pane-b-close')).toBeNull();
});
it('names and models are text, and the model is never translated', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<img src=x onerror=alert(1)>';
app.sessions.get('s-b').displayModel = { model: '<b>m</b>', source: 'statusline' };
app.syncTerminalGeometry = vi.fn();
app.openSplitPane('s-b');
expect(part(headerB(), 'session-name').textContent).toBe('<img src=x onerror=alert(1)>');
expect(part(headerB(), 'session-name').children).toHaveLength(0);
expect(modelName(headerB()).textContent).toBe('<b>m</b>');
expect(modelName(headerB()).getAttribute('data-i18n-skip')).toBe('');
});
it('an unknown model shows the logo alone', () => {
const app = openSplit();
delete app.sessions.get('s-b').displayModel;
renderTabs(app);
expect(part(headerB(), 'split-model').hidden).toBe(true);
expect(modelName(headerB()).textContent).toBe('');
expect(part(headerB(), 'split-harness').title).toBe('Claude Code');
});
it('a tab render carries a rename and a model switch into both headers', () => {
const app = openSplit();
app.sessions.set('s-b', {
...app.sessions.get('s-b'),
name: 'docs-pass-2',
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
});
app.sessions.get('s-a').displayModel = { model: 'deepseek-v4-flash', source: 'screen' };
renderTabs(app);
expect(part(headerB(), 'session-name').textContent).toBe('docs-pass-2');
expect(modelName(headerB()).textContent).toBe('Sonnet 4.6');
expect(modelName(headerA()!).textContent).toBe('deepseek-v4-flash');
});
it("Pane A's header follows the active session", () => {
const app = openSplit();
app.activeSessionId = 's-c';
renderTabs(app);
expect(part(headerA()!, 'session-name').textContent).toBe('s-c');
expect(part(headerA()!, 'split-harness').className).toBe('split-harness run-mode-dot claude');
expect(part(headerA()!, 'split-model').hidden).toBe(true);
});
it('an unchanged session writes nothing on a refresh', () => {
const app = openSplit();
const writes: string[] = [];
for (const [which, header] of [
['a', headerA()!],
['b', headerB()],
] as Array<[string, FakeEl]>) {
for (const [node, props] of [
[part(header, 'split-harness'), ['className', 'title']],
[part(header, 'session-name'), ['textContent']],
[part(header, 'split-model'), ['title', 'hidden']],
[modelName(header), ['textContent']],
] as Array<[FakeEl, string[]]>) {
for (const prop of props) {
let value = (node as unknown as Record<string, unknown>)[prop];
Object.defineProperty(node, prop, {
get: () => value,
set: (v) => {
writes.push(`${which} ${prop}`);
value = v;
},
});
}
const setAttribute = node.setAttribute.bind(node);
node.setAttribute = (k: string, v: string) => {
writes.push(`${which} @${k}`);
setAttribute(k, v);
};
}
}
renderTabs(app);
renderTabs(app);
expect(writes).toEqual([]);
});
});
describe("the main terminal's height", () => {
it('opening fits Pane A with its header strip already in place, through sendResize', () => {
const app = makeGridApp(IDS);
app.fitAddon = { fit: vi.fn() };
let stripInPlace = false;
app.sendResize = vi.fn(() => {
stripInPlace = wrap.children[0]?.classList.contains('terminal-pane-a-header') ?? false;
return Promise.resolve(true);
});
app.openSplitPane('s-b');
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripInPlace).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it('closing takes the strip away and gives the height back through sendResize, never a bare fit', () => {
const app = openSplit();
app.sendResize.mockClear();
let stripGone = false;
app.sendResize = vi.fn(() => {
stripGone = headerA() === null;
return Promise.resolve(true);
});
app.closeSplitPane();
expect(headerA()).toBeNull();
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
expect(stripGone).toBe(true);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
expect(app._splitHeaders).toBeNull();
});
it('a close that skips the resize (Pane A ended) still refits through syncTerminalGeometry', () => {
const app = openSplit();
app.sendResize.mockClear();
app.closeSplitPane({ skipPrimaryResize: true });
expect(headerA()).toBeNull();
expect(app.sendResize).not.toHaveBeenCalled();
expect(app.syncTerminalGeometry).toHaveBeenCalledTimes(1);
expect(app.fitAddon.fit).not.toHaveBeenCalled();
});
it("Pane B's close button closes the split", () => {
const app = openSplit();
const tile = FakeTile.all.at(-1) as FakeTile;
part(headerB(), 'terminal-pane-b-close').dispatch('click');
expect(app._splitPane).toBeNull();
expect(tile.destroy).toHaveBeenCalledTimes(1);
expect(headerA()).toBeNull();
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @fileoverview The server's side of the tile grid's SSE filter (live server,
* multi-user mode, port 3287).
*
* While tiles own the terminal the page subscribes with TILE_GRID_SSE_FILTER
* (constants.js), an id that names no session, so the server sends it no
* session:terminal batches (test/tile-grid-sse-filter.test.ts covers the page).
* That holds only while:
* - the server takes an id it does not know, on the connect query and on
* POST /api/events/subscribe (a validation change that refused it would
* leave the grid's stream unfiltered, or with no live filter updates at all);
* - the filter gates nothing but terminal batches: in multi-user mode SSE
* routing is fail-closed, so this also checks that session:updated and hook
* events still reach their owner, and only their owner, through the filter.
*
* Every server read of the filter, for the record: the connect route parses
* `?sessions=` (server.ts, GET /api/events), POST /api/events/subscribe
* replaces it (SseStreamManager.updateClientFilter), and its ONE use is
* flushSessionTerminalBatch. broadcast(), the ownership check (canDeliver), the
* heartbeat, the order and tab-layout frames and the shutdown notice never read it.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3287;
const url = (p: string) => `http://localhost:${PORT}${p}`;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const alice = { Authorization: basic('alice', 'alicepass1') };
/** The page's own constant, read from constants.js, so the two can never drift. */
function gridFilter(): string {
const window: Record<string, { TILE_GRID_SSE_FILTER?: string }> = {};
const context = vm.createContext({ window, globalThis: {} });
vm.runInContext(readFileSync(path.resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'), context);
return window.CodemanTileGrid.TILE_GRID_SSE_FILTER as string;
}
const FILTER = gridFilter();
type Received = { event: string; data: unknown };
/** An open SSE stream for `headers`, collecting every event until close(). */
async function openStream(query: string, headers: Record<string, string>) {
const controller = new AbortController();
const received: Received[] = [];
const res = await fetch(url(`/api/events${query}`), { headers, signal: controller.signal });
let text = '';
const reading = (async () => {
const reader = res.body!.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
text += new TextDecoder().decode(value);
let cut: number;
while ((cut = text.indexOf('\n\n')) !== -1) {
const frame = text.slice(0, cut);
text = text.slice(cut + 2);
const event = /^event: (.*)$/m.exec(frame)?.[1];
const data = /^data: (.*)$/m.exec(frame)?.[1];
if (event && data !== undefined) {
try {
received.push({ event, data: JSON.parse(data) });
} catch {
received.push({ event, data });
}
}
}
}
} catch {
/* aborted */
}
})();
return {
status: res.status,
received,
close: async () => {
controller.abort();
await reading;
},
};
}
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const until = async (fn: () => boolean, ms = 3000) => {
const end = Date.now() + ms;
while (!fn() && Date.now() < end) await wait(20);
};
let server: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record<string, string | undefined> = {};
type Internals = {
sessions: Map<string, unknown>;
broadcast(event: string, data: unknown): void;
batchTerminalData(sessionId: string, data: string): void;
};
beforeAll(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sse-grid-data-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sse-grid-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_PASSWORD',
'CODEMAN_USERNAME',
]) {
saved[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = dataDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
describe('the tile grid SSE filter, multi-user', () => {
it('is taken on a live re-subscribe though it names no session, and then withholds terminal output', async () => {
// A tile focus re-subscribes the open stream (POST /api/events/subscribe).
// A 204 alone would not do: a validation that dropped the id would still
// answer 204 with the filter cleared, and the stream would carry every
// session's output. So the filter is checked by what it withholds.
expect(FILTER).toBe('tile-grid');
const internals = server as unknown as Internals;
const grid = await openStream('?clientId=grid-tolerance-1', alice);
const control = await openStream('?clientId=control-1', alice);
expect(grid.status).toBe(200);
await until(() => [grid, control].every((s) => s.received.some((r) => r.event === 'init')));
const res = await fetch(url('/api/events/subscribe'), {
method: 'POST',
headers: { ...alice, 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId: 'grid-tolerance-1', sessions: [FILTER] }),
});
expect(res.status).toBe(204);
internals.sessions.set('alice-2', { id: 'alice-2', owner: 'alice', toState: () => ({ id: 'alice-2' }) });
try {
internals.batchTerminalData('alice-2', 'output of alice-2');
await until(() => control.received.some((r) => r.event === 'session:terminal'));
await wait(150);
} finally {
internals.sessions.delete('alice-2');
}
expect(control.received.some((r) => r.event === 'session:terminal')).toBe(true);
expect(grid.received.some((r) => r.event === 'session:terminal')).toBe(false);
await grid.close();
await control.close();
});
it("withholds only terminal output: the subscriber still gets its own sessions' updates and hooks", async () => {
const internals = server as unknown as Internals;
const grid = await openStream(`?clientId=grid-1&sessions=${FILTER}`, alice);
const plain = await openStream('?clientId=plain-1', alice);
await until(() => [grid, plain].every((s) => s.received.some((r) => r.event === 'init')));
// One session of alice's and one of bob's (spawning is a no-op under test).
const fake = (id: string, owner: string) => ({ id, owner, toState: () => ({ id, owner }) });
internals.sessions.set('alice-1', fake('alice-1', 'alice'));
internals.sessions.set('bob-1', fake('bob-1', 'bob'));
try {
internals.broadcast('session:updated', { id: 'alice-1', session: { id: 'alice-1', status: 'busy' } });
internals.broadcast('session:updated', { id: 'bob-1', session: { id: 'bob-1', status: 'busy' } });
internals.broadcast('hook:idle_prompt', { sessionId: 'alice-1' });
internals.batchTerminalData('alice-1', 'output of alice-1');
await until(() => plain.received.some((r) => r.event === 'session:terminal'));
await wait(150);
} finally {
internals.sessions.delete('alice-1');
internals.sessions.delete('bob-1');
}
// Only what names the two sessions above (heartbeats and anything else the
// server sends meanwhile are not this test's business).
const kinds = (s: { received: Received[] }) =>
s.received
.map((r) => {
const d = (r.data ?? {}) as { id?: string; sessionId?: string };
return `${r.event} ${d.id ?? d.sessionId}`;
})
.filter((k) => k.endsWith(' alice-1') || k.endsWith(' bob-1'));
expect(kinds(grid)).toEqual(['session:updated alice-1', 'hook:idle_prompt alice-1']);
// The same user without the filter got the terminal batch: it was sent.
expect(kinds(plain)).toContain('session:terminal alice-1');
expect(kinds(plain)).not.toContain('session:updated bob-1');
await grid.close();
await plain.close();
});
});
+97 -102
View File
@@ -18,114 +18,15 @@
*
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
* fit addon and WebSocket are fakes; `connect()` runs for real.
* fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts);
* `connect()` runs for real.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev?: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(JSON.parse(data) as Frame);
}
close = vi.fn(() => {
this.readyState = 3;
});
open() {
this.readyState = 1;
this.onopen?.();
}
receive(msg: object) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
inputFrames() {
return this.sent.filter((f) => f.t === 'i');
}
}
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
class FakeFit {
static proposed = { cols: 80, rows: 24 };
term: FakeTerminal | null = null;
fit() {
const { cols, rows } = FakeFit.proposed;
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
this.term?.resize(cols, rows);
}
proposeDimensions() {
return { ...FakeFit.proposed };
}
}
class FakeTerminal {
static last: FakeTerminal | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
open() {}
onData(cb: (data: string) => void) {
this.dataCb = cb;
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
textarea = {
addEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners.push(fn);
},
removeEventListener: (type: string, fn: () => void) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
},
};
focusTextarea() {
for (const fn of this.focusListeners) fn();
}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
writes: string[] = [];
write(data: string, cb?: () => void) {
this.writes.push(data);
cb?.();
}
clear() {
this.writes.push('<CLEAR>');
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
}
dispose() {}
type(data: string) {
this.dataCb?.(data);
}
}
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
const fetchMock = vi.fn();
@@ -583,6 +484,39 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', ()
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
});
it('paneStarted() resends an unchanged size: the first one went out before there was a PTY', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.paneStarted();
expect(resizeFrames(ws)).toEqual([
{ t: 'z', c: 80, r: 24, v: 'desktop' },
{ t: 'z', c: 80, r: 24, v: 'desktop' },
]);
// Only once: the size is recorded again, so a plain fit does not repeat it.
tile.fit();
expect(resizeFrames(ws)).toHaveLength(2);
});
it('paneStarted() on a hidden tile sends nothing, and its next fit sends the size', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: NaN, rows: NaN };
tile.paneStarted();
expect(resizeFrames(ws)).toHaveLength(1);
// Shown again (a zoom ends) at the same size it had: still sent.
FakeFit.proposed = { cols: 80, rows: 24 };
tile.fit();
expect(resizeFrames(ws)).toHaveLength(2);
});
it('paneStarted() before the socket opens sends nothing; the open sends the size once', async () => {
const { tile, ws } = await connectTile(makeApp());
tile.paneStarted();
expect(resizeFrames(ws)).toHaveLength(0);
ws.open();
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
@@ -662,6 +596,67 @@ describe('TerminalTile links and paste follow THIS pane', () => {
});
});
describe("TerminalTile Ctrl+C copies through the primary pane's copy helpers", () => {
const ctrlC = (extra: Record<string, unknown> = {}) => ({
type: 'keydown',
key: 'c',
code: 'KeyC',
ctrlKey: true,
preventDefault: vi.fn(),
...extra,
});
it("copies THIS pane's selection; a failed write keeps it and focus returns to this pane", async () => {
const app = makeApp();
const copyText = vi.fn(async () => false);
app._copyText = copyText;
const { term } = await connectTile(app);
term.selection = 'npm run build';
const ev = ctrlC();
expect(term.keyHandler!(ev)).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(ev.preventDefault).toHaveBeenCalled();
expect(copyText).toHaveBeenCalledWith('npm run build');
expect(app.showToast).toHaveBeenCalledWith('Failed to copy', 'error');
// As in the primary pane: nothing was copied, so the selection stays for a retry.
expect(term.clearSelection).not.toHaveBeenCalled();
expect(term.selection).toBe('npm run build');
// The execCommand fallback focuses a temporary textarea; the keyboard comes back here.
expect(term.focus).toHaveBeenCalled();
});
it('a successful write clears the selection (a second Ctrl+C interrupts) and refocuses this pane', async () => {
const app = makeApp();
app._copyText = vi.fn(async () => true);
const { term } = await connectTile(app);
term.selection = 'npm run build';
expect(term.keyHandler!(ctrlC())).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(app.showToast).toHaveBeenCalledWith('Copied to clipboard', 'success');
expect(term.clearSelection).toHaveBeenCalled();
expect(term.focus).toHaveBeenCalled();
});
it('with nothing selected, Ctrl+C reaches the PTY and Ctrl+Shift+C does not', async () => {
const app = makeApp();
const copyText = vi.fn(async () => true);
app._copyText = copyText;
const { term } = await connectTile(app);
const plain = ctrlC();
expect(term.keyHandler!(plain)).toBe(true);
expect(plain.preventDefault).not.toHaveBeenCalled();
const shifted = ctrlC({ key: 'C', shiftKey: true });
expect(term.keyHandler!(shifted)).toBe(false);
expect(shifted.preventDefault).toHaveBeenCalled();
expect(copyText).not.toHaveBeenCalled();
});
});
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
const app = makeApp();
+115 -34
View File
@@ -70,8 +70,6 @@ type PaneUnderTest = {
};
const fetchMock = vi.fn();
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
const rafQueue: Array<() => void> = [];
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
@@ -105,7 +103,6 @@ function loadTerminalTile() {
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
},
fetch: (...args: unknown[]) => fetchMock(...args),
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
// The constants.js globals the module reads at call time.
TERMINAL_CHUNK_SIZE,
TERMINAL_TAIL_SIZE,
@@ -179,12 +176,36 @@ function deferred<T>() {
// Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
/**
* What reached the pane's screen. A replay also queues an empty write, only to
* hear through its callback that everything before it has been parsed
* (writeChunked); it puts nothing on screen, so it is left out.
*/
const screenWrites = (pane: { terminal: FakeTerminal }) =>
pane.terminal.write.mock.calls.map((call) => call[0]).filter((data) => data !== '');
/**
* Holds xterm's write callbacks, as a real xterm still parsing a replay does:
* the replay stays in progress until `parse()` runs the ones held so far.
*/
function holdParses(pane: { terminal: FakeTerminal }) {
const held: Array<() => void> = [];
pane.terminal.write = vi.fn((_data: string, done?: () => void) => {
if (done) held.push(done);
});
return {
held,
parse: () => {
for (const done of held.splice(0)) done();
},
};
}
/** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0));
beforeEach(() => {
fetchMock.mockReset();
rafQueue.length = 0;
deadlines.length = 0;
clock = 0;
});
@@ -221,7 +242,8 @@ describe('TerminalTile server-refresh single-flight', () => {
await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1');
// The second argument carries the load's deadline (an AbortSignal).
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1', expect.anything());
expect(pane.terminal.write).toHaveBeenCalledWith('one');
expect(pane._bufferLoading).toBe(false);
});
@@ -233,7 +255,7 @@ describe('TerminalTile server-refresh single-flight', () => {
pane._refreshBuffer();
await settle();
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`);
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`, expect.anything());
});
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
@@ -264,40 +286,88 @@ describe('TerminalTile server-refresh single-flight', () => {
second.resolve(jsonResponse('replay-2'));
await settle();
expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2');
expect(screenWrites(pane).at(-1)).toBe('replay-2');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(false);
expect(pane._bufferRefreshPending).toBe(false);
});
it('holds the flag across the chunked write, not just the fetch', async () => {
it('queues the whole replay at once and holds the flag until xterm has parsed it', async () => {
const pane = makePane();
// Three chunks: two full ones plus a tail, so the last two are queued on
// requestAnimationFrame and the replay is mid-write after the fetch lands.
const xterm = holdParses(pane);
// Three slices: two full ones plus a tail.
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
fetchMock.mockResolvedValueOnce(jsonResponse(big));
pane._refreshBuffer();
await settle();
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
expect(rafQueue).toHaveLength(1);
// Every slice queued at once, then the empty write whose callback ends the
// replay: nothing waits for an animation frame.
expect(pane.terminal.write.mock.calls.map((call) => call[0].length)).toEqual([
TERMINAL_CHUNK_SIZE,
TERMINAL_CHUNK_SIZE,
5,
0,
]);
// xterm is still parsing: the replay, and with it the flag, is not done.
expect(pane._bufferLoading).toBe(true);
// A refresh mid-write must not clear the terminal under the chunks still
// to come, nor start a second fetch.
// A refresh mid-parse must not clear the terminal under the replay, nor
// start a second fetch.
pane._refreshBuffer();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
rafQueue.shift()!();
rafQueue.shift()!();
xterm.parse();
await settle();
expect(pane.terminal.write).toHaveBeenCalledTimes(4);
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
// Parsed: the coalesced refresh runs now, once.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane._bufferLoading).toBe(true);
xterm.parse();
await settle();
expect(screenWrites(pane).at(-1)).toBe('after');
expect(pane._bufferLoading).toBe(false);
});
it('queues a replay larger than the window one window at a time', async () => {
// xterm's write queue throws past 50 MB, and an unbounded `full=1` capture
// can reach the server's 32 MB: at most 1 MiB is queued before xterm has
// parsed what came before it.
const pane = makePane();
const xterm = holdParses(pane);
fetchMock.mockResolvedValueOnce(jsonResponse('z'.repeat(TERMINAL_TAIL_SIZE + 5)));
pane._refreshBuffer();
await settle();
const queued = () => pane.terminal.write.mock.calls.reduce((n, call) => n + call[0].length, 0);
expect(queued()).toBe(TERMINAL_TAIL_SIZE);
xterm.parse();
await settle();
expect(queued()).toBe(TERMINAL_TAIL_SIZE + 5);
expect(pane._bufferLoading).toBe(true);
xterm.parse();
await settle();
expect(pane._bufferLoading).toBe(false);
});
it('a replay still parsing when the pane is destroyed settles at once', async () => {
// A disposed xterm never runs a write callback: without destroy() settling
// the replay, the flag (and in the grid the one load queue) would wait forever.
const pane = makePane();
holdParses(pane);
fetchMock.mockResolvedValueOnce(jsonResponse('replay'));
pane._refreshBuffer();
await settle();
expect(pane._bufferLoading).toBe(true);
pane.destroy();
await settle();
expect(pane._bufferLoading).toBe(false);
});
@@ -502,6 +572,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
it('holds live output during the replay and replays only what arrived after the capture', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const xterm = holdParses(pane);
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
@@ -517,24 +588,26 @@ describe('TerminalTile scroll-to-top history pull', () => {
await settle();
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
// three chunks, which leaves the replay mid-write once the fetch lands.
// three chunks, still being parsed once the fetch lands.
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse(bigReplay));
await settle();
expect(rafQueue).toHaveLength(1);
expect(xterm.held).toHaveLength(1);
// Arrives while the snapshot is still being written: must not land under it.
// Arrives while the snapshot is still being parsed: must not land under it.
clock = 3;
pane._onLiveOutput('late');
expect(term.write).not.toHaveBeenCalledWith('late');
rafQueue.shift()!();
rafQueue.shift()!();
// The replay parsed, then the pull's own settle write before it scrolls.
xterm.parse();
await settle();
xterm.parse();
await settle();
const written = term.write.mock.calls.map((call) => call[0]);
const written = screenWrites(pane);
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
expect(written.filter((w) => w === 'early')).toHaveLength(1);
@@ -719,7 +792,13 @@ describe('TerminalTile scroll-to-top history pull', () => {
it('connect() installs the wheel listener (static guard)', () => {
// connect() needs a whole xterm to run, so its wiring is pinned by source
// rather than executed; the listener's behaviour is exercised above.
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
const start = SOURCE.indexOf('async connect()');
const end = SOURCE.indexOf('async _loadBuffer(');
// Both anchors must resolve, or the slice runs to the end of the file and
// every check below passes against code outside connect().
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
const connect = SOURCE.slice(start, end);
expect(connect).toContain('this._installWheelListener();');
expect(connect).toContain('this._onLiveClear();');
expect(connect).not.toContain('this.terminal.clear();');
@@ -830,7 +909,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
second.resolve(jsonResponse('second'));
await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
const writes = screenWrites(pane);
expect(writes.at(-1)).toSatisfy(isMarker);
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
expect(writes.filter(isMarker)).toHaveLength(1);
@@ -886,25 +965,27 @@ describe('TerminalTile scroll-to-top history pull', () => {
}
);
it('a close during the chunked replay writes exactly one marker, at the end', async () => {
it('a close during the replay writes exactly one marker, at the end', async () => {
const pane = makePane('shell');
const xterm = holdParses(pane);
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const pull = pane._pullHistory();
// Three chunks, so the replay is still mid-write once the fetch lands.
// Three chunks, still being parsed once the fetch lands.
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
response.resolve(jsonResponse(bigReplay));
await settle();
expect(rafQueue).toHaveLength(1);
expect(xterm.held).toHaveLength(1);
// Written now, the marker would land between two chunks of recovered history.
// Written now, the marker would land above the recovered history's end.
pane._onSocketClosed();
rafQueue.shift()!();
rafQueue.shift()!();
xterm.parse();
await settle();
xterm.parse();
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
const writes = screenWrites(pane);
expect(writes[0]).toBe('\x1bc');
expect(writes.filter(isMarker)).toHaveLength(1);
expect(isMarker(writes.at(-1))).toBe(true);
+199
View File
@@ -0,0 +1,199 @@
/**
* @fileoverview A tile whose session is not attached shows an Attach overlay.
*
* Three ways a tile has no live terminal: the session has no PTY attached
* (`pid === null`, e.g. restored after a server restart), the agent exited in a
* live pane (`paneExit`), or the server closed the tile's socket because the
* session exited (close code 4009). The tile body then shows why, with an
* Attach button, over the terminal (never resizing it).
*
* Attach is exactly the single view's automatic re-attach: `POST /interactive`
* (or `/shell` for a shell) with NO body, at most one in flight per session
* (the route has no in-flight guard of its own). A tripped PTY-exit breaker
* (`respawnBlocked`) goes through the same confirm before `clearBreaker: true`,
* and nothing automatic ever sends that. On success the tile is remounted onto
* the new pane (a socket stopped for good cannot reconnect).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeTile,
fetchSpy,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const overlayOf = (id: string) => tileEl(id).children[1].children.find((c) => c.className === 'tile-attach') ?? null;
const visible = (id: string) => !!overlayOf(id) && !overlayOf(id)!.hidden;
const textOf = (id: string) => overlayOf(id)!.children[0].textContent;
const attachButton = (id: string) => overlayOf(id)!.children[1];
const tilesFor = (id: string) => FakeTile.all.filter((t) => t.sessionId === id);
function gridWith(setup: (app: GridApp) => void = () => {}): GridApp {
const app = makeGridApp(IDS);
setup(app);
app.openTileGrid(IDS);
return app;
}
async function settle() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
beforeEach(() => {
resetGridHarness();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({}) }));
windowStub.confirm = vi.fn(() => true);
});
describe('when the overlay shows', () => {
it('a session with no PTY attached', () => {
gridWith((app) => (app.sessions.get('s-b').pid = null));
expect(visible('s-b')).toBe(true);
expect(textOf('s-b')).toBe('Not attached');
expect(attachButton('s-b').textContent).toBe('Attach');
expect(visible('s-a')).toBe(false);
});
it('an agent that exited in a live pane: the reason, and no Attach (the server cannot restart it in place)', async () => {
const app = gridWith((a) => (a.sessions.get('s-c').paneExit = { status: 2 }));
expect(visible('s-c')).toBe(true);
expect(textOf('s-c')).toBe('The agent exited (2)');
expect(attachButton('s-c').hidden).toBe(true);
expect(overlayOf('s-c')!.children[2].hidden).toBe(false);
expect(await app.attachTileSession('s-c')).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('a session with no PTY gets the button, not the close hint', () => {
gridWith((app) => (app.sessions.get('s-b').pid = null));
expect(attachButton('s-b').hidden).toBe(false);
expect(overlayOf('s-b')!.children[2].hidden).toBe(true);
});
it('a socket closed because the session exited (4009) keeps the tile and shows it', () => {
const app = gridWith();
const tile = tilesFor('s-b')[0];
tile._stoppedCode = 4009;
tile.onExit?.(4009);
expect(app._tileGrid.ids).toContain('s-b');
expect(visible('s-b')).toBe(true);
expect(textOf('s-b')).toBe('The session ended');
});
it('a live session shows none, and the overlay leaves once the session is back', () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
app.sessions.get('s-b').pid = 4242;
app._renderTileChrome();
expect(visible('s-b')).toBe(false);
});
it('sits over the body, never inside the header (the body keeps its size)', () => {
gridWith((app) => (app.sessions.get('s-b').pid = null));
expect(tileEl('s-b').children[1].className).toBe('tile-body');
expect(overlayOf('s-b')?.parentElement).toBe(tileEl('s-b').children[1]);
});
});
describe('Attach', () => {
it('POSTs /interactive with NO body, then remounts the tile onto the new pane', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
const before = tilesFor('s-b')[0];
attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() });
await settle();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', { method: 'POST' });
expect(before.destroy).toHaveBeenCalledTimes(1);
const after = tilesFor('s-b')[1];
expect(after.connect).toHaveBeenCalledTimes(1);
expect(app._tileFor('s-b')).toBe(after);
// The server has not reported the new pid yet: no overlay meanwhile.
expect(visible('s-b')).toBe(false);
});
it('a shell session attaches through /shell', async () => {
const app = makeGridApp(IDS);
app.sessions.get('s-c').mode = 'shell';
app.sessions.get('s-c').pid = null;
app.openTileGrid(IDS);
await app.attachTileSession('s-c');
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-c/shell', { method: 'POST' });
});
it('at most one attach in flight per session', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
let release: (v: unknown) => void = () => {};
fetchSpy.mockImplementation(() => new Promise((r) => (release = r)));
const first = app.attachTileSession('s-b');
attachButton('s-b').dispatch('click', { stopPropagation: vi.fn() });
void app.attachTileSession('s-b');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(textOf('s-b')).toBe('Attaching…');
expect(attachButton('s-b').disabled).toBe(true);
release({ ok: true });
await first;
});
it('a refusal in the envelope of a 200 is a failure, not a success', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({ success: false, error: 'busy' }) }));
expect(await app.attachTileSession('s-b')).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error');
expect(tilesFor('s-b')).toHaveLength(1);
expect(visible('s-b')).toBe(true);
});
it('a failed attach keeps the overlay and says so', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) }));
expect(await app.attachTileSession('s-b')).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error');
expect(visible('s-b')).toBe(true);
expect(textOf('s-b')).toBe('Not attached');
expect(tilesFor('s-b')).toHaveLength(1);
});
});
describe('the PTY-exit breaker', () => {
function tripped() {
return gridWith((a) => {
a.sessions.get('s-b').pid = null;
a.sessions.get('s-b').respawnBlocked = true;
});
}
it('asks first, and only a yes sends clearBreaker', async () => {
const app = tripped();
await app.attachTileSession('s-b');
expect(windowStub.confirm).toHaveBeenCalledWith('s-b was stopped after crashing repeatedly. Restart it?');
expect(fetchSpy).toHaveBeenCalledWith('/api/sessions/s-b/interactive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clearBreaker: true }),
});
expect(app.sessions.get('s-b').respawnBlocked).toBe(false);
});
it('a no sends nothing at all', async () => {
const app = tripped();
windowStub.confirm = vi.fn(() => false);
await app.attachTileSession('s-b');
expect(fetchSpy).not.toHaveBeenCalled();
expect(visible('s-b')).toBe(true);
});
it('nothing automatic attaches: opening the grid and refreshing headers POST nothing', () => {
const app = tripped();
app._renderTileChrome();
app._reconcileTileGrid();
expect(fetchSpy).not.toHaveBeenCalled();
expect(windowStub.confirm).not.toHaveBeenCalled();
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* @fileoverview New sessions started from THIS tab's Run join the open grid.
*
* Every Run path calls `_ensureCreatedSessionVisible` for each session it
* created, then `selectSession(firstId)` (a human selection). With the grid open,
* `_joinTileGridFromRun` (called from there) adds each new session to the next
* free slot, so that selection focuses its tile instead of leaving the grid.
* Sessions created elsewhere (agents, other devices, cron) arrive only by
* `session:created` and never join. A grid already holding what the window
* fits does not take it: Run's selection then shows it alone, with a hint.
*
* A tile that joined before its pane existed resends its size once the pid
* appears (the server spawned the pane at its own default size).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts); the Run
* hook in session-ui.js is pinned at the source. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, makeGridApp, resetGridHarness, section, type GridApp, tileEl } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b'];
const addSession = (app: GridApp, id: string, workingDir = '/w') =>
app.sessions.set(id, { id, name: id, mode: 'claude', pid: null, workingDir });
beforeEach(() => {
resetGridHarness();
});
describe('Run from this tab', () => {
it('the Run hook is where every Run path makes its new session visible', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
const helper = src.slice(src.indexOf('async _ensureCreatedSessionVisible('), src.indexOf('async run() {'));
expect(helper).toContain('this._joinTileGridFromRun?.(sessionId);');
});
it('with the grid open, the new session joins the next free slot; Run then focuses its tile', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
addSession(app, 's-new');
expect(app._joinTileGridFromRun('s-new')).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-new']);
// What Run does next: a human selection of the session it created.
app.selectSession('s-new');
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-new');
});
it('no Attach overlay flashes on the new tile while Run starts its pane', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
addSession(app, 's-new');
app._joinTileGridFromRun('s-new');
const tile = tileEl('s-new');
const overlay = tile.children[1].children.find((c) => c.className === 'tile-attach');
expect(!overlay || overlay.hidden).toBe(true);
});
it('with the grid closed, nothing joins', () => {
const app = makeGridApp(IDS);
addSession(app, 's-new');
expect(app._joinTileGridFromRun('s-new')).toBe(false);
expect(app._tileGrid?.open ?? false).toBe(false);
});
it('a grid that already holds what the window fits does not take it, and says so', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
addSession(app, 's-new');
section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 });
expect(app._joinTileGridFromRun('s-new')).toBe(false);
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('opens on its own'), 'info');
});
it('a session created ELSEWHERE (session:created) never joins', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.renderSessionTabs = vi.fn();
app.saveSessionOrder = vi.fn();
app.updateCost = vi.fn();
app._onSessionCreated({ id: 's-agent', name: 's-agent', mode: 'claude', pid: 1, workingDir: '/w' });
expect(app.sessions.has('s-agent')).toBe(true);
expect(app._tileGrid.ids).toEqual(IDS);
});
});
describe('a pane that starts after its tile connected (#464)', () => {
// The tile sent its size before there was a PTY; the server dropped it and
// spawned the pane at its default size, so the size must go out again.
const tileOf = (id: string) => FakeTile.all.filter((t) => t.sessionId === id).at(-1)!;
const setPid = (app: GridApp, id: string, pid: number | null) =>
app.sessions.set(id, { ...app.sessions.get(id), pid });
it('resends the size once when the pid appears, and again only for a new PTY', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
addSession(app, 's-new');
app._joinTileGridFromRun('s-new');
const tile = tileOf('s-new');
app._renderTileChrome();
expect(tile.paneStarted).not.toHaveBeenCalled();
setPid(app, 's-new', 4242);
app._renderTileChrome();
expect(tile.paneStarted).toHaveBeenCalledTimes(1);
app._renderTileChrome();
expect(tile.paneStarted).toHaveBeenCalledTimes(1);
// The pane went away and a new one started (an Attach, a respawned pane).
setPid(app, 's-new', null);
app._renderTileChrome();
setPid(app, 's-new', 4343);
app._renderTileChrome();
expect(tile.paneStarted).toHaveBeenCalledTimes(2);
});
it('a tile made for a session that already runs never asks', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app._renderTileChrome();
app._renderTileChrome();
for (const id of IDS) expect(tileOf(id).paneStarted).not.toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview The grid holds at most TILE_GRID_MAX tiles: 6, owner decision 7
* (docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed
* the headless frame bar. The layout table still covers 7 to 9 (unreachable).
*
* Every way into the grid stops at the cap even where the window would fit
* nine (the harness window is 2400x1200): opening, adding, a session Run
* makes, Ctrl/Cmd+click, the count menu and "Open group as tiles".
* The texts say which limit binds: "at most 6" for the cap, "what this window
* fits" for a smaller window.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeEl, body, bySelector, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
const SIX = EIGHT.slice(0, 6);
const AT_MOST = 'The grid holds at most 6 tiles';
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
});
function fullGrid(): GridApp {
const app = makeGridApp(EIGHT);
app.openTileGrid(EIGHT);
return app;
}
describe('the cap', () => {
it('is 6, worded as the cap where the window would fit more', () => {
const app = makeGridApp(EIGHT);
expect(app._tileGridLimit()).toEqual({ capacity: 6, full: AT_MOST });
});
it('a smaller window is worded by the window', () => {
const app = makeGridApp(EIGHT);
wrapRect = { width: 1200, height: 900 };
expect(app._tileGridLimit()).toEqual({
capacity: 4,
full: 'The grid already holds what this window fits (4)',
});
});
it('opening on eight sessions shows the first six', () => {
const app = fullGrid();
expect(app._tileGrid.ids).toEqual(SIX);
expect(app._tileGrid.tiles.size).toBe(6);
});
it('a full grid takes no more tiles', () => {
const app = fullGrid();
expect(app.addTile('s-7')).toBe(false);
expect(app._tileGrid.ids).toEqual(SIX);
});
});
describe('every way in stops at the cap', () => {
it('a session Run makes opens on its own, with a toast', () => {
const app = fullGrid();
expect(app._joinTileGridFromRun('s-7')).toBe(false);
expect(app._tileGrid.ids).toEqual(SIX);
expect(app.showToast).toHaveBeenCalledWith(`${AT_MOST}: the new session opens on its own`, 'info');
});
it('Ctrl/Cmd+click on another tab says the grid is full', () => {
const app = fullGrid();
expect(app.addSessionToTiles('s-7')).toBe(true);
expect(app._tileGrid.ids).toEqual(SIX);
expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info');
});
it('the count menu offers nothing past the cap, and its 6 opens six of eight', () => {
const app = makeGridApp(EIGHT);
app.openTileCountMenu({ preventDefault: vi.fn() });
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
const items = menu.children.filter((c) => c.attrs.role === 'menuitemradio');
expect(items.map((i) => Number(i.dataset.count))).toEqual([2, 4, 6]);
// The cap binds, not the window: nothing greyed out, no "fits" line.
expect(items.every((i) => !i.disabled)).toBe(true);
expect(menu.children.some((c) => c.className === 'tile-count-hint')).toBe(false);
items.at(-1)!.dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids).toEqual(['s-other', ...EIGHT.slice(0, 5)]);
});
it('"Open group as tiles" shows the first six of a larger group', () => {
const app = makeGridApp(EIGHT);
app.tabLayout = { groups: [{ id: 'g', name: 'G', refs: EIGHT.map((id) => ({ kind: 'session', id })) }] };
app.openGroupAsTiles('g');
expect(app._tileGrid.ids).toEqual(SIX);
});
});
+342
View File
@@ -0,0 +1,342 @@
/**
* @fileoverview The grid as cells (owner: "the empty tab doesnt always have to
* be the last one ... it can also be tab nr 4 or 3"). `grid.cells` holds a
* session id or `null` per cell and is the one source of truth; `grid.ids` is
* the tiles in reading order, derived. The shape still comes from the tile
* count (the layout table), and the cap counts tiles, never empty cells.
*
* - Removing a tile without a shape change leaves its cell empty where it was.
* - Adding one without a shape change takes the first empty cell (or the cell a
* tab was dropped on: tile-grid-move.test.ts).
* - A shape change keeps each tile's row and column when all fit (2x2 growing
* to 3x2), else the tiles pack in reading order (fitTileCells).
* - Focus never lands on an empty cell: the Alt+Shift+Arrow chords skip it,
* and Ctrl+Tab / Alt+[ ] cycle through the tiles only.
* - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload
* brings the holes back when the shape is the same, a session gone by then
* leaves its cell empty, a different shape packs, and the old packed format
* reads unchanged. A followed `#session=` link keeps the holes.
* - A divider drag refits only the tiles in its two tracks, holes skipped.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
documentAddEventListener,
localStore,
makeGridApp,
rafCallbacks,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const FIVE = SIX.slice(0, 5);
const KEY = 'codeman:tile-grid';
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
const place = (id: string) => [tileEl(id).style.gridColumn, tileEl(id).style.gridRow];
// Under 1800px wide three tiles take a 2x2 (over it, 3x1).
const narrow = () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
};
function openGrid(ids: string[], focusedId = ids[0], all: string[] = SIX): GridApp {
const app = makeGridApp(all);
app.openTileGrid(ids, { focusedId });
return app;
}
function setCells(app: GridApp, cells: Array<string | null>) {
app._tileGrid.cells = cells.slice();
app._applyTileLayout();
}
beforeEach(() => {
resetGridHarness();
narrow();
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
describe('the cells are the source of truth', () => {
it('ids are the tiles in reading order, derived from the cells (holes skipped)', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
expect(app._tileGrid.ids).toEqual(FIVE);
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c', 's-d', 's-e']);
// A tile sits in its cell, the slot in the empty one.
expect(place('s-b')).toEqual(['5', '1']);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '1']);
});
it('opening packs from the first cell and pads the shape with empty cells', () => {
const app = openGrid(FIVE);
expect(app._tileGrid.cells).toEqual([...FIVE, null]);
});
});
describe('removing a tile', () => {
it('without a shape change (6 to 5 stays 3x2): its cell is left empty where it was', () => {
const app = openGrid(SIX);
app.removeTile('s-b');
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
expect(place('s-c')).toEqual(['5', '1']);
expect(slots()).toHaveLength(1);
expect(slots()[0].dataset.cell).toBe('1');
// Nothing else was remounted.
expect(FakeTile.all.filter((t) => t._destroyed).map((t) => t.sessionId)).toEqual(['s-b']);
expect(stored().ids).toEqual(['s-a', null, 's-c', 's-d', 's-e', 's-f']);
});
it('a shape change (5 to 4 is 3x2 to 2x2) keeps the tiles when all fit, else packs them', () => {
let app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-d', 's-e', 's-c']);
// Removing the only tile in the third column: the rest keep their places.
app.removeTile('s-c');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-d', 's-e']);
resetGridHarness();
narrow();
app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-c', 's-d', 's-e', 's-b']);
// A tile is left in the third column: packed in reading order.
app.removeTile('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-d', 's-b']);
});
it('the focused tile leaving hands focus to the next tile in reading order, past the hole', () => {
const app = openGrid(SIX, 's-a');
setCells(app, ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']);
app.removeTile('s-b');
app.removeTile('s-a');
expect(app.activeSessionId).toBe('s-c');
});
});
describe('adding a tile', () => {
it('without a shape change: the first empty cell in reading order', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app.addTile('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-f', 's-c', 's-d', 's-e']);
expect(slots()).toHaveLength(0);
});
it('Ctrl/Cmd+click on a tab and a session Run from this tab fill the first hole too', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d'], 's-a', [...SIX, 's-g']);
// 2x2 [a b / c d]; the 5th makes it 3x2 with the four kept in place.
app.addSessionToTiles('s-e');
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._joinTileGridFromRun('s-f');
expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']);
});
it('a shape change (4 to 5 is 2x2 to 3x2): every tile keeps its row and column', () => {
const app = openGrid(['s-a', 's-b', 's-c', 's-d']);
app.addTile('s-e');
// [a b / c d] -> [a b e / c d _]
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-e', 's-c', 's-d', null]);
expect(place('s-c')).toEqual(['1', '3']);
expect(place('s-e')).toEqual(['5', '1']);
});
it('into the cell named when it is empty, not just the first one (two holes, a state the cap never reaches)', () => {
const app = openGrid(['s-a', 's-b']);
// A 2x2 with two holes, set up by hand: the third tile still keeps the 2x2.
app._tileGrid.cells = ['s-a', null, 's-b', null];
app._tileGrid.cols = 2;
app._tileGrid.rows = 2;
app.addTile('s-c', { cell: 3 });
expect(app._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-c']);
// A cell that is taken is not: the first empty one instead.
resetGridHarness();
narrow();
const other = openGrid(['s-a', 's-b']);
other._tileGrid.cells = ['s-a', null, 's-b', null];
other._tileGrid.cols = 2;
other._tileGrid.rows = 2;
other.addTile('s-c', { cell: 2 });
expect(other._tileGrid.cells).toEqual(['s-a', 's-c', 's-b', null]);
});
it('the cap counts tiles, never empty cells', () => {
const app = openGrid(FIVE);
setCells(app, [null, 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-f')).toBe(true);
expect(app._tileGrid.cells).toEqual(['s-f', 's-a', 's-b', 's-c', 's-d', 's-e']);
expect(app.addTile('s-other')).toBe(false);
});
});
describe('a shape change from the window (three tiles: 2x2 narrow, 3x1 wide)', () => {
it('positions do not map between shapes: the tiles keep their reading order, packed', () => {
const app = openGrid(['s-a', 's-b', 's-c']);
setCells(app, ['s-a', null, 's-b', 's-c']);
section.getBoundingClientRect = () => ({ width: 2400, height: 1000, top: 0, left: 0, right: 2400, bottom: 1000 });
app._applyTileLayout();
expect([app._tileGrid.cols, app._tileGrid.rows]).toEqual([3, 1]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c']);
narrow();
app._applyTileLayout();
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
});
describe('focus never lands on an empty cell', () => {
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
return added.find(([type, , capture]) => type === 'keydown' && capture === true)![1];
}
const chord = (key: string, mods: Record<string, boolean>) => ({
type: 'keydown',
key,
code: key,
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
target: { closest: () => null },
...mods,
});
const focusKey = (dir: string) => chord(`Arrow${dir}`, { altKey: true, shiftKey: true });
it('Alt+Shift+Arrows skip a hole to the next tile, and do nothing when there is none', () => {
const app = openGrid(FIVE, 's-a');
// [a _ b / c d e]
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const onKeydown = handlerFor(app);
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
onKeydown(focusKey('Right'));
expect(app.activeSessionId).toBe('s-b');
app._selectTiledSession('s-d', { auto: true });
// Up from d (the hole above): the nearest tile in that row, the lower column on a tie.
onKeydown(focusKey('Up'));
expect(app.activeSessionId).toBe('s-a');
expect(app._tileGrid.focusedId).toBe('s-a');
});
it('Ctrl+Tab and Alt+[ / ] cycle through the tiles in reading order, never a hole', () => {
const app = openGrid(FIVE, 's-a');
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
delete app.selectSession;
const seen: string[] = [];
for (let i = 0; i < 5; i++) {
app._cycleTileFocus(1);
seen.push(app.activeSessionId);
}
expect(seen).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
app._cycleTileFocus(-1);
expect(app.activeSessionId).toBe('s-e');
});
});
describe('persistence: the cells, holes included', () => {
function reload(ids: string[] = SIX): GridApp {
// The page goes away; a fresh one on the same device restores the grid.
section.children = [];
FakeTile.all = [];
const app = makeGridApp(ids);
expect(app._restoreTileGrid()).toBe(true);
return app;
}
it('a hole comes back where it was when the shape is the same', () => {
const app = openGrid(FIVE, 's-c');
setCells(app, ['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(stored().ids).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
const again = reload();
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-c', 's-d', 's-e']);
expect(again.activeSessionId).toBe('s-c');
expect(slots()[0].dataset.cell).toBe('2');
});
it('a session gone by then leaves its cell empty if the shape still fits', () => {
const app = openGrid(SIX);
app._tileGrid.open = false;
const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']);
});
it('otherwise (another shape) the grid packs', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.open = false;
// s-d gone: four tiles take a 2x2.
const again = reload(['s-a', 's-b', 's-c', 's-e']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']);
});
it('another shape packs even when the stored holes would fit the new one', () => {
// Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2.
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] }));
const again = reload(['s-a', 's-b', 's-c']);
expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]);
});
it('the old packed format reads unchanged', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: FIVE, focused: 's-b' }));
const again = reload();
expect(again._tileGrid.cells).toEqual([...FIVE, null]);
expect(again.activeSessionId).toBe('s-b');
});
it('a followed #session= link closes the stored grid with its holes kept', () => {
const app = openGrid(FIVE);
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
const fresh = makeGridApp(SIX);
fresh._closeStoredTileGrid();
expect(stored()).toMatchObject({ open: false, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] });
expect(stored()).not.toHaveProperty('cells');
void app;
});
});
describe('a move keeps every tile', () => {
it('cells that lose or duplicate a tile are refused, and nothing changes', () => {
const app = openGrid(FIVE);
const before = app._tileGrid.cells.slice();
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', null, null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-a', 's-c', 's-d', 's-e', null])).toBe(false);
expect(app._reorderTiles(['s-a', 's-b', 's-c', 's-d', 's-e'])).toBe(false);
expect(app._tileGrid.cells).toEqual(before);
});
});
describe('a divider drag with a hole', () => {
it('refits only the tiles in its two columns, the hole skipped', () => {
const app = openGrid(FIVE);
// [a _ b / c d e]: column 0 holds a, c; column 1 holds d.
setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']);
for (const t of FakeTile.all) t.fit.mockClear();
const d = app._tileGrid.dividers.get('col-0') as FakeEl;
d.dispatch('pointerdown', {
button: 0,
clientX: 500,
clientY: 100,
pointerId: 1,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 560, clientY: 100, pointerId: 1 });
for (const cb of rafCallbacks.splice(0)) cb();
d.dispatch('pointerup', { clientX: 560, clientY: 100, pointerId: 1 });
const fits = Object.fromEntries(FIVE.map((id) => [id, tile(id).fit.mock.calls.length]));
expect(fits).toEqual({ 's-a': 1, 's-b': 0, 's-c': 1, 's-d': 1, 's-e': 0 });
});
});
+454
View File
@@ -0,0 +1,454 @@
/**
* @fileoverview A tile's header: `● [logo] name · model ..... ⋯ ⤢ ×`, and the tab marker.
*
* - The logo is PR #532's `run-mode-dot <cliId>` slot (the id is data); the
* logo's tooltip and accessible name carry the harness and the model, with
* where the model came from when the CLI did not report it. The model is
* text (never markup, never translated), and an unknown model shows nothing.
* An unchanged session writes nothing on a refresh.
* - The dot uses the six-state classifier the tab rows and both home screens
* share (`_sidebarRichRow`), with the existing `.home-sessions-dot--*`
* classes; a `needs` tile gets the pulsing red border; hovering shows the
* state and how long ("working 3m").
* - The name is text, never markup, and carries `data-i18n-skip`; a
* double-click renames through the tab rename's own write queue.
* - `⋯` is the tab rail's session menu; `×` removes the tile ONLY (the
* session keeps running), and neither button focuses a tile that is not
* focused (which would spend its idle alert).
* - Every tab render refreshes the headers, so they follow session changes.
* - Tabs of tiled sessions carry `.in-tiles`.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
makeGridApp,
resetGridHarness,
type GridApp,
tileEl,
windowStub,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
/** A grid on IDS with the shared classifier stand-ins mobile-overview.js would provide. */
function openGrid(): GridApp {
const app = makeGridApp(IDS);
app._mobileOverviewState = (session: { status?: string }, hooks?: Set<string>) =>
hooks?.has('permission_prompt') ? 'needs' : session.status === 'busy' ? 'working' : 'idle';
app._mobileOverviewSince = (_state: string, session: { lastActivityAt?: number }) => ({
key: 'x',
at: session.lastActivityAt || 0,
});
app._mobileOverviewExit = () => null;
app._mobileOverviewStampText = (at: number) => (at ? '3m' : '');
app.openTileGrid(IDS);
app.markIdleAlertSeen.mockClear();
return app;
}
const headerOf = (id: string) => tileEl(id).children[0];
/** A header part by its class, wherever the header nests it. */
const partOf = (id: string, cls: string) => headerOf(id).querySelector(`.${cls}`) as FakeEl;
const buttonOf = (id: string, cls: string) =>
partOf(id, 'tile-actions').children.find((b) => b.className.includes(cls)) as FakeEl;
/** Where the name sits: the name itself, or the rename input in its place. */
const nameSlotOf = (id: string) => partOf(id, 'tile-title').children[0];
beforeEach(() => {
resetGridHarness();
});
describe('the header', () => {
it('shows the session name as text, skipped by the translator', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<b>Sessions</b>';
app.openTileGrid(IDS);
const name = partOf('s-b', 'tile-name');
expect(name.textContent).toBe('<b>Sessions</b>');
expect(name.getAttribute('data-i18n-skip')).toBe('');
expect(name.children).toHaveLength(0);
});
it('header and body are siblings, the body holding the terminal', () => {
openGrid();
const el = tileEl('s-a');
expect(el.children.map((c) => c.className)).toEqual(['tile-header', 'tile-body']);
expect(FakeTile.all.find((t) => t.sessionId === 's-a')?.mountEl).toBe(el.children[1]);
});
it('the dot follows the session state, a permission prompt marks the whole tile', () => {
const app = openGrid();
app.sessions.get('s-b').status = 'busy';
app.pendingHooks.set('s-c', new Set(['permission_prompt']));
app._renderTileChrome();
expect(partOf('s-a', 'tile-dot').className).toContain('home-sessions-dot--idle');
expect(partOf('s-b', 'tile-dot').className).toContain('home-sessions-dot--working');
expect(partOf('s-c', 'tile-dot').className).toContain('home-sessions-dot--needs');
expect(tileEl('s-c').classList.contains('tile--needs')).toBe(true);
expect(tileEl('s-b').classList.contains('tile--needs')).toBe(false);
});
it('hovering says the state and for how long', () => {
const app = openGrid();
app.sessions.get('s-b').status = 'busy';
app.sessions.get('s-b').lastActivityAt = Date.now() - 180_000;
app._renderTileChrome();
// While tiles can move, a second line says the header drags.
expect(headerOf('s-b').title).toBe('working 3m\nDrag to move the tile');
});
it('every tab render refreshes the headers', () => {
const app = openGrid();
app._renderTileChrome = vi.fn();
// The original returns at once during an inline tab rename; the headers still refresh.
app._inlineRenameActive = true;
app._renderSessionTabsImmediate();
expect(app._renderTileChrome).toHaveBeenCalledTimes(1);
});
});
describe('the harness logo and the model', () => {
const CATALOG = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
{ id: 'shell', label: 'Shell' },
];
/** s-a on dsh's route, s-b claude on its statusline's model, s-c a shell. */
function harnessGrid(): GridApp {
windowStub.__codemanCliCatalog = CATALOG;
const app = makeGridApp(IDS);
app.sessions.get('s-a').mode = 'deepseek';
app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'screen' };
app.sessions.get('s-b').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
app.sessions.get('s-c').mode = 'shell';
app.openTileGrid(IDS);
return app;
}
const logoOf = (id: string) => partOf(id, 'tile-harness');
const modelOf = (id: string) => partOf(id, 'tile-model');
const modelNameOf = (id: string) => modelOf(id).children[0];
it('sits between the dot and the name: `● [logo] name · model ... ⋯ ⤢ ×`', () => {
harnessGrid();
expect(headerOf('s-a').children.map((c) => c.className.split(' ')[0])).toEqual([
'tile-dot',
'tile-harness',
'tile-title',
'tile-actions',
]);
expect(partOf('s-a', 'tile-title').children.map((c) => c.className)).toEqual(['tile-name', 'tile-model']);
});
it("draws the session's CLI as PR #532's logo slot, the id as data", () => {
harnessGrid();
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot deepseek');
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot claude');
expect(logoOf('s-c').className).toBe('tile-harness run-mode-dot shell');
expect(logoOf('s-a').getAttribute('role')).toBe('img');
});
it('names the harness and the model in the tooltip and the accessible name', () => {
harnessGrid();
expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(logoOf('s-a').getAttribute('aria-label')).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(modelOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b');
expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b');
expect(modelOf('s-a').hidden).toBe(false);
// Said once to a screen reader: the model's box is hidden from it.
expect(modelOf('s-a').getAttribute('aria-hidden')).toBe('true');
// The model name is never translated; its tooltip may be.
expect(modelNameOf('s-a').getAttribute('data-i18n-skip')).toBe('');
expect(modelOf('s-a').getAttribute('data-i18n-skip')).toBeNull();
});
it('an unknown model shows the logo alone: no text, no placeholder', () => {
harnessGrid();
expect(modelOf('s-c').hidden).toBe(true);
expect(modelNameOf('s-c').textContent).toBe('');
expect(logoOf('s-c').title).toBe('Shell');
});
it('says in the tooltip where a model came from when the CLI did not report it', () => {
const app = harnessGrid();
app.sessions.get('s-b').displayModel = { model: 'haiku', source: 'launch' };
app.sessions.get('s-c').mode = 'claude';
app.sessions.get('s-c').displayModel = { model: 'qwen3.8-27b', source: 'custom-endpoint' };
app._renderTileChrome();
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 haiku (set at launch)');
expect(logoOf('s-c').title).toBe('Claude Code \u00B7 qwen3.8-27b (custom endpoint)');
app.sessions.get('s-a').displayModel = { model: 'qwen3.8-27b', source: 'config' };
app._renderTileChrome();
expect(logoOf('s-a').title).toBe('DeepSeek \u00B7 qwen3.8-27b (from config)');
expect(modelNameOf('s-a').textContent).toBe('qwen3.8-27b');
expect(modelNameOf('s-b').textContent).toBe('haiku');
});
it('a model change (session:updated) updates the header', () => {
const app = harnessGrid();
app.sessions.set('s-b', {
...app.sessions.get('s-b'),
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
});
app._renderTileChrome();
expect(modelNameOf('s-b').textContent).toBe('Sonnet 4.6');
expect(logoOf('s-b').title).toBe('Claude Code \u00B7 Sonnet 4.6');
// And the model going away takes the text with it.
delete app.sessions.get('s-b').displayModel;
app._renderTileChrome();
expect(modelOf('s-b').hidden).toBe(true);
expect(modelNameOf('s-b').textContent).toBe('');
});
it('a model with markup stays text', () => {
const app = harnessGrid();
app.sessions.get('s-b').displayModel = { model: '<img src=x onerror=alert(1)>', source: 'statusline' };
app._renderTileChrome();
expect(modelNameOf('s-b').textContent).toBe('<img src=x onerror=alert(1)>');
expect(modelNameOf('s-b').children).toHaveLength(0);
expect(modelOf('s-b').children).toHaveLength(1);
});
it('an unchanged session writes nothing on a refresh', () => {
const app = harnessGrid();
const writes: string[] = [];
for (const id of IDS) {
for (const [node, props] of [
[logoOf(id), ['className', 'title']],
[modelOf(id), ['title', 'hidden']],
[modelNameOf(id), ['textContent']],
] as Array<[FakeEl, string[]]>) {
for (const prop of props) {
let value = (node as unknown as Record<string, unknown>)[prop];
Object.defineProperty(node, prop, {
get: () => value,
set: (v) => {
writes.push(`${id} ${prop}`);
value = v;
},
});
}
const setAttribute = node.setAttribute.bind(node);
node.setAttribute = (k: string, v: string) => {
writes.push(`${id} @${k}`);
setAttribute(k, v);
};
}
}
app._renderTileChrome();
app._renderTileChrome();
expect(writes).toEqual([]);
});
it('an id that is not a CLI id is not a class name; without a catalog the label is the id', () => {
const app = harnessGrid();
delete windowStub.__codemanCliCatalog;
app.sessions.get('s-a').mode = 'bad id" onclick';
app.sessions.get('s-b').mode = 'my-cli';
app._renderTileChrome();
expect(logoOf('s-a').className).toBe('tile-harness run-mode-dot');
expect(logoOf('s-a').title).toBe('qwen3.8-27b');
expect(logoOf('s-b').className).toBe('tile-harness run-mode-dot my-cli');
expect(logoOf('s-b').title).toBe('my-cli \u00B7 Haiku 4.5');
});
});
describe('header buttons', () => {
it('are ⋯ ⤢ × and nothing else: no + (owner decision 9)', () => {
const app = openGrid();
const tile = tileEl('s-a');
const actions = tile.children[0].children.find((c) => c.className === 'tile-actions') as FakeEl;
expect(actions.children.map((b) => b.className)).toEqual([
'tile-btn tile-menu',
'tile-btn tile-zoom',
'tile-btn tile-remove',
]);
expect(actions.children.map((b) => b.textContent)).toEqual(['\u22EF', '\u2922', '\u00D7']);
// The + menu and its "New session in this case" went with it.
for (const gone of ['openTileAddMenu', 'closeTileAddMenu', 'runInCaseForTiles']) {
expect(gone in app, gone).toBe(false);
}
});
it('× removes the tile only: the session keeps running, a neighbour takes focus unacknowledged', () => {
const app = openGrid();
app._apiDelete = vi.fn();
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.sessions.has('s-a')).toBe(true);
expect(app._apiDelete).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('× on the last tile shows that session in the single view', () => {
const app = makeGridApp(['s-a']);
app.openTileGrid(['s-a']);
app.selectSession = vi.fn();
buttonOf('s-a', 'tile-remove').dispatch('click', { stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
});
it('⋯ opens the tab rail session menu for that session', () => {
const app = openGrid();
app.openTabRailActionMenu = vi.fn();
const ev = { stopPropagation: vi.fn() };
buttonOf('s-c', 'tile-menu').dispatch('click', ev);
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(ev, 's-c');
});
it('pressing a header button never focuses the tile (no selection, no acknowledgement)', () => {
const app = openGrid();
app.selectSession = vi.fn();
const stop = vi.fn();
buttonOf('s-c', 'tile-remove').dispatch('pointerdown', { stopPropagation: stop });
expect(stop).toHaveBeenCalled();
// The tile's own pointerdown (a focus) only runs if the event reaches it.
tileEl('s-c').dispatch('pointerdown', {});
expect(app.selectSession).toHaveBeenCalledWith('s-c');
});
});
describe('header button size (owner feedback: the 12px glyphs read as tiny)', () => {
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const rule = (selector: string) => {
const at = css.indexOf(`${selector} {`);
return at === -1 ? '' : css.slice(at, css.indexOf('}', at));
};
it("buttons are the app header's icon size: 26px targets, a 16px glyph, never the header's inherited 12px", () => {
const btn = rule('.tile-btn');
expect(btn).toContain('min-width: 26px;');
expect(btn).toContain('height: 26px;');
expect(btn).toContain('font-size: 16px;');
expect(btn).not.toMatch(/\bfont: inherit;/);
});
it('the thin glyphs (ellipsis, cross) get a step more, and the header holds the buttons', () => {
expect(rule('.tile-btn.tile-menu,\n.tile-btn.tile-remove')).toContain('font-size: 19px;');
const header = rule('.tile-header');
expect(header).toContain('flex: 0 0 28px;');
expect(header).toContain('height: 28px;');
});
});
describe('a translated label survives a refresh (zh-CN)', () => {
// The i18n observer writes the translation into the DOM. Comparing the DOM
// with the English source would never match again, so each refresh would
// rewrite English for the observer to translate again. The guards compare
// with the last English value set instead.
it('the header tooltip, the Attach overlay text and the zoom title stay translated until they change', () => {
const app = makeGridApp(['s-a', 's-b']);
let pill = 'idle';
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app.sessions.get('s-b').pid = null;
app.openTileGrid(['s-a', 's-b']);
const a = app._tileGrid.tiles.get('s-a');
const b = app._tileGrid.tiles.get('s-b');
expect(a.header.title).toBe('idle 3m\nDrag to move the tile');
expect(b.overlayText.textContent).toBe('Not attached');
app.zoomTile('s-a');
expect(a.zoomBtn.title).toBe('Restore the grid');
// Zoomed, nothing moves: the drag hint goes.
expect(a.header.title).toBe('idle 3m');
// What the translator does to them.
a.header.title = '空闲 3m';
b.overlayText.textContent = '未附加';
a.zoomBtn.title = '恢复平铺网格';
app._renderTileChrome();
app._applyTileLayout();
expect(a.header.title).toBe('空闲 3m');
expect(b.overlayText.textContent).toBe('未附加');
expect(a.zoomBtn.title).toBe('恢复平铺网格');
// A real change still writes the new English, for the translator to take.
pill = 'working';
app._renderTileChrome();
expect(a.header.title).toBe('working 3m');
app.zoomTile('s-a');
expect(a.zoomBtn.title).toBe('Zoom this tile');
});
});
describe('rename', () => {
function startRename(app: GridApp, id: string) {
nameSlotOf(id).dispatch('dblclick', { stopPropagation: vi.fn() });
return nameSlotOf(id);
}
it('double-click puts an input in place of the name; Enter renames through the write queue', () => {
const app = openGrid();
// The real queue records the name in flight before its PUT lands.
app._inlineRenamePending = new Map();
app._queueInlineSessionName = vi.fn(async (id: string, name: string) => {
app._inlineRenamePending.set(id, name);
return { status: 'confirmed' };
});
const input = startRename(app, 's-b');
expect(input.className).toBe('tile-rename-input');
expect(input.value).toBe('s-b');
input.value = 'renamed';
input.dispatch('keydown', { key: 'Enter', preventDefault: vi.fn() });
expect(app._queueInlineSessionName).toHaveBeenCalledWith('s-b', 'renamed');
expect(nameSlotOf('s-b').className).toBe('tile-name');
expect(nameSlotOf('s-b').textContent).toBe('renamed');
});
it('Escape cancels without a write', () => {
const app = openGrid();
app._queueInlineSessionName = vi.fn();
const input = startRename(app, 's-b');
input.value = 'nope';
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
expect(nameSlotOf('s-b').textContent).toBe('s-b');
});
it('a header refresh while renaming leaves the input alone', () => {
const app = openGrid();
const input = startRename(app, 's-b');
input.value = 'half-typed';
app._renderTileChrome();
expect(nameSlotOf('s-b')).toBe(input);
expect(input.value).toBe('half-typed');
});
it('an IME composition owns Enter', () => {
const app = openGrid();
app._queueInlineSessionName = vi.fn();
const input = startRename(app, 's-b');
input.value = 'x';
input.dispatch('keydown', { key: 'Enter', isComposing: true, preventDefault: vi.fn() });
expect(app._queueInlineSessionName).not.toHaveBeenCalled();
expect(nameSlotOf('s-b')).toBe(input);
});
});
describe('the tab marker', () => {
// Tab rendering needs the whole strip; the class is pinned at both render paths.
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
it('the full render puts .in-tiles on a tiled session tab', () => {
expect(app).toContain("${this._tileGrid?.has(id) ? ' in-tiles' : ''}");
});
it('the incremental render toggles it', () => {
expect(app).toContain("tab.classList.toggle('in-tiles', !!this._tileGrid?.has(id));");
});
it('it has a style', () => {
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
expect(css).toMatch(/\.session-tab\.in-tiles/);
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* @fileoverview Closing or deleting a tiled session keeps the grid open and
* moves focus to the NEIGHBOURING tile.
*
* `closeSession()` normally falls back to the first remaining `sessionOrder`
* entry with `auto: true`, and that entry is often NOT tiled: an app-driven
* pick that, with the grid open, would be refused (auto never collapses the
* grid) and leave nothing focused. So the fallback is grid-aware, and it lives
* IN closeSession: the delete broadcast routinely lands while the request is in
* flight, and the delete handlers skip ids in `_closingSessions`. The neighbour
* is captured BEFORE the await, like `wasActive`, because that broadcast may
* already have removed the tile.
*
* - closing the focused tile: next tile in grid order, else the previous one;
* `s-other` is FIRST in sessionOrder and never tiled, so the old pick would
* have collapsed the grid;
* - the same with the broadcast arriving mid-request and after it;
* - closing the last tile closes the grid and falls back to the normal pick;
* - a tiled session deleted ELSEWHERE: its tile goes, a neighbour takes focus
* with `auto` (no idle alert spent); the last one leaves the welcome screen.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
/** A grid on `ids` focused on `focus`, with the DELETE request held open until `finish()`. */
function setup(ids = IDS, focus = ids[0]) {
const app = makeGridApp(ids);
app.openTileGrid(ids, { focusedId: focus });
app.selectSession = vi.fn();
app.markIdleAlertSeen.mockClear();
let finish: () => void = () => {};
app._apiDelete = vi.fn(() => new Promise<void>((r) => (finish = r)));
// The real cleanup touches a lot of panels; what the fallback reads is the session list.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
app.sessionOrder = app.sessionOrder.filter((s: string) => s !== id);
});
return { app: app as GridApp, finish: () => finish() };
}
async function settle() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
beforeEach(() => {
resetGridHarness();
});
describe('closeSession on the focused tile', () => {
it('keeps the grid open and focuses the next tile, never the untiled first sessionOrder entry', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.selectSession).not.toHaveBeenCalled();
// The app chose the neighbour: no idle alert spent.
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('the last tile in grid order hands focus back to the previous one', async () => {
const { app, finish } = setup(IDS, 's-c');
const closing = app.closeSession('s-c');
finish();
await closing;
expect(app.activeSessionId).toBe('s-b');
});
it('the delete broadcast arriving DURING the request changes nothing about the outcome', async () => {
const { app, finish } = setup(IDS, 's-b');
const closing = app.closeSession('s-b');
await settle();
app._onSessionDeleted({ id: 's-b' });
// Only the tile went; closeSession owns the follow-up (as the split's
// wrapper does for ids in _closingSessions), so focus has not moved yet.
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.showWelcome).not.toHaveBeenCalled();
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-c');
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('the delete broadcast arriving AFTER the request is a no-op for the grid', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
app._onSessionDeleted({ id: 's-a' });
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
});
it('closing the LAST tile closes the grid and falls back to the normal pick', async () => {
const { app, finish } = setup(['s-a'], 's-a');
const closing = app.closeSession('s-a');
finish();
await closing;
expect(app._tilesOwnTerminal()).toBe(false);
expect(FakeTile.all[0].destroy).toHaveBeenCalledTimes(1);
expect(app.selectSession).toHaveBeenCalledWith('s-other', { auto: true });
});
it('closing a tile that is NOT focused removes it and leaves focus alone', async () => {
const { app, finish } = setup(IDS, 's-a');
const closing = app.closeSession('s-c');
finish();
await closing;
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
});
});
describe('a tiled session deleted elsewhere', () => {
it('removes its tile and moves focus to the neighbour with `auto`', () => {
const { app } = setup(IDS, 's-b');
app._onSessionDeleted({ id: 's-b' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('a deleted tile that was not focused just goes', () => {
const { app } = setup(IDS, 's-a');
app._onSessionDeleted({ id: 's-c' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
});
it('the last tile deleted closes the grid and lands on the welcome screen, as in the single view', () => {
const { app } = setup(['s-a'], 's-a');
app._onSessionDeleted({ id: 's-a' });
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview The tile count (owner decision 10): the pure helpers behind the
* Tiles button's 2 / 4 / 6 right-click menu.
*
* - `sanitizeTileCount`: a remembered count is one of TILE_GRID_COUNTS, else
* the default (6).
* - `tileGridSetForCount`: what the grid opens (or an open grid shows) trimmed
* or filled to N: trimmed from the end with the session to focus always
* kept, filled from the open sessions in tab order; fewer sessions than N
* give fewer tiles; never past the cap.
* - `tileCellCols`: the column count a stored cell list was laid out with.
* - `reformTileCells`: a count change is a shape change: kept tiles stay in
* their cells, the cell model's rule (fitTileCells) reshapes, joining tiles
* fill the empty cells in reading order, holes first.
*
* The helpers from constants.js as the shared harness loads them.
* Port: N/A.
*/
import { describe, expect, it } from 'vitest';
import { windowStub } from './mocks/tile-grid-vm.js';
type Cells = (string | null)[];
type Helpers = {
sanitizeTileCount(raw: unknown): number;
tileCellCols(length: number): number;
reformTileCells(cells: Cells, oldCols: number, keep: string[], add: string[], cols: number, rows: number): Cells;
computeTileLayout(p: { count: number; width?: number }): { cols: number; rows: number };
tileGridSetForCount(base: string[], all: string[], n: number, keepId?: string | null): string[];
TILE_GRID_COUNTS: number[];
TILE_GRID_COUNT_DEFAULT: number;
TILE_GRID_MAX: number;
};
const T = windowStub.CodemanTileGrid as Helpers;
const all = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8'];
describe('sanitizeTileCount', () => {
it('offers 2, 4 and 6, default 6', () => {
expect(T.TILE_GRID_COUNTS).toEqual([2, 4, 6]);
expect(T.TILE_GRID_COUNT_DEFAULT).toBe(6);
});
it('keeps a valid count, as a number or as stored text', () => {
expect(T.sanitizeTileCount(2)).toBe(2);
expect(T.sanitizeTileCount('4')).toBe(4);
expect(T.sanitizeTileCount('6')).toBe(6);
});
it('anything else is the default', () => {
for (const raw of [null, undefined, '', '3', 5, 9, 0, -2, 'six', '{}', NaN]) {
expect(T.sanitizeTileCount(raw)).toBe(6);
}
});
});
describe('tileGridSetForCount', () => {
it('fills from tab order after the base, skipping ones already in it', () => {
expect(T.tileGridSetForCount(['t5', 't2'], all, 6)).toEqual(['t5', 't2', 't1', 't3', 't4', 't6']);
});
it('trims from the end', () => {
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 4)).toEqual(['t1', 't2', 't3', 't4']);
});
it('keeps the session to focus when it sat past N, in the last place', () => {
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4', 't5', 't6'], all, 2, 't5')).toEqual(['t1', 't5']);
expect(T.tileGridSetForCount(['t1', 't2', 't3', 't4'], all, 2, 't2')).toEqual(['t1', 't2']);
});
it('a base id is never trimmed in favour of a filler', () => {
expect(T.tileGridSetForCount(['t7', 't8'], all, 2)).toEqual(['t7', 't8']);
});
it('fewer open sessions than N: fewer tiles', () => {
expect(T.tileGridSetForCount(['t1'], ['t1', 't2', 't3'], 6)).toEqual(['t1', 't2', 't3']);
expect(T.tileGridSetForCount([], [], 4)).toEqual([]);
});
it('never past the cap, and at least one', () => {
expect(T.tileGridSetForCount([], all, 9)).toHaveLength(T.TILE_GRID_MAX);
expect(T.tileGridSetForCount([], all, 0)).toEqual(['t1']);
});
it('drops duplicates and non-ids', () => {
expect(T.tileGridSetForCount(['t2', 't2', '', null as unknown as string], all, 3)).toEqual(['t2', 't1', 't3']);
});
});
describe('tileCellCols', () => {
it('reads the shape back from the cell count, as the layout table lays it out', () => {
expect([1, 2, 3, 4, 6, 9].map((n) => T.tileCellCols(n))).toEqual([1, 2, 3, 2, 3, 3]);
});
it('agrees with computeTileLayout for every count, wide and narrow', () => {
for (let n = 1; n <= 9; n++) {
for (const width of [Infinity, 0]) {
const { cols, rows } = T.computeTileLayout({ count: n, width });
expect(T.tileCellCols(cols * rows)).toBe(cols);
}
}
});
it('an unknown length is 0 (the tiles then pack)', () => {
for (const n of [0, 5, 7, 8, 10, -1, NaN]) expect(T.tileCellCols(n)).toBe(0);
});
});
describe('reformTileCells', () => {
it('growing 2 (2x1) to 6 (3x2): both stay in their cells, the four new ones fill in reading order', () => {
expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'd', 'e', 'f'], 3, 2)).toEqual([
'a', 'b', 'c',
'd', 'e', 'f',
]);
});
it('growing fills the holes first, in reading order', () => {
// 5 of a 3x2 with the hole in the middle of the first row, then 6.
expect(T.reformTileCells(['a', null, 'b', 'c', 'd', 'e'], 3, ['a', 'b', 'c', 'd', 'e'], ['f'], 3, 2)).toEqual([
'a', 'f', 'b',
'c', 'd', 'e',
]);
// 3 of a 2x2 (hole first) growing to 4 keeps the shape: the hole fills.
expect(T.reformTileCells([null, 'a', 'b', 'c'], 2, ['a', 'b', 'c'], ['d'], 2, 2)).toEqual(['d', 'a', 'b', 'c']);
});
it('a 2x2 growing to 3x2 keeps every tile at its row and column', () => {
expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b', 'c', 'd'], ['e', 'f'], 3, 2)).toEqual([
'a', 'b', 'e',
'c', 'd', 'f',
]);
});
it('shrinking 6 (3x2) to 4 (2x2): a tile in the third column does not fit, so the kept ones pack', () => {
expect(T.reformTileCells(['a', 'b', 'c', 'd', 'e', 'f'], 3, ['a', 'b', 'c', 'd'], [], 2, 2)).toEqual([
'a', 'b', 'c', 'd',
]);
});
it('shrinking keeps rows and columns when every kept tile still fits', () => {
// 3x2 to 2x2 keeping two tiles inside the first two columns: they stay at
// their row and column, holes and all (packing would move b up).
expect(T.reformTileCells([null, 'a', 'x', 'b', null, 'y'], 3, ['a', 'b'], [], 2, 2)).toEqual([
null, 'a',
'b', null,
]);
// 2x2 to 2x1 keeping the first row.
expect(T.reformTileCells(['a', 'b', 'c', 'd'], 2, ['a', 'b'], [], 2, 1)).toEqual(['a', 'b']);
});
it('a stored grid of unknown shape (oldCols 0) packs', () => {
expect(T.reformTileCells([null, 'a', 'b'], 0, ['a', 'b'], ['c'], 2, 2)).toEqual(['a', 'b', 'c', null]);
});
it('a full grid takes no more, and a tile already there is not added twice', () => {
expect(T.reformTileCells(['a', 'b'], 2, ['a', 'b'], ['c', 'a'], 2, 1)).toEqual(['a', 'b']);
expect(T.reformTileCells(['a', null], 2, ['a'], ['a', 'c'], 2, 1)).toEqual(['a', 'c']);
});
});
+476
View File
@@ -0,0 +1,476 @@
/**
* @fileoverview The header Tiles button and its right-click count menu (owner
* decision 10, which replaced the session picker).
*
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
* class) and hard-gated to desktop widths like Split: a JS width check plus a
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
* With the grid open it closes it (`aria-pressed`).
* - Right-click (contextmenu, which Shift+F10 and the Menu key fire too) opens
* a menu of three counts, 2, 4 and 6, each with its shape, the remembered
* count checked (default 6). No session names in it, so no user text.
* - A count the window cannot fit is greyed out with the reason, and the
* keyboard starts on the largest that fits.
* - Arrows move over the counts that fit (wrapping), Home/End, Enter or Space
* picks, Escape closes and gives the keyboard back to the Tiles button (the
* menu owns its Escape in the global handler, like the tab-group menu), Tab
* and a click elsewhere close it.
* - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid`
* stays ids only) and opens that many tiles: the session to focus included,
* the rest from tab order. The click and Ctrl+Shift+G then open with it.
* - With the grid open a pick re-forms it: a shape change under the cell
* model's rule, the focused tile always kept, tiles dropped from the end,
* new ones filling the empty cells first, all mounted and laid out before
* any connects. Fewer open sessions than the count give fewer tiles.
* - The session picker is gone (no method, no CSS, no markup hook).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
activeElement,
body,
bySelector,
flushFrames,
localStore,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f', 's-g'];
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const appSrc = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const gridSrc = readFileSync(resolve(PUBLIC, 'tile-grid.js'), 'utf8');
function makeButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
bySelector.set('.btn-tile-grid', btn);
return btn;
}
const menu = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
const items = () => menu()!.children.filter((c) => c.attrs.role === 'menuitemradio');
const item = (n: number) => items().find((i) => i.dataset.count === String(n))!;
const hint = () => menu()!.children.find((c) => c.className === 'tile-count-hint') ?? null;
const focused = () => (activeElement() as FakeEl | null)?.dataset?.count ?? null;
const key = (app: GridApp, k: string) => {
const ev = { key: k, preventDefault: vi.fn() };
app._tileCountMenu.onKey(ev);
return ev;
};
const open = (app: GridApp) => app.openTileCountMenu({ preventDefault: vi.fn() });
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
makeButton();
});
afterEach(() => {
vi.useRealTimers();
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
describe('the Tiles button', () => {
it('shows only when its setting is on and the window is desktop-wide', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app._applyTileGridButtonVisibility(false);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
windowStub.innerWidth = 1100;
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
const btn = makeButton();
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
expect(css).toMatch(
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
);
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
});
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
const app = gridApp(IDS.slice(0, 3));
const btn = bySelector.get('.btn-tile-grid')!;
app.openTileGrid(IDS.slice(0, 3));
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
expect(btn.getAttribute('aria-label')).toBe('Tiles: back to a single session (right-click for how many tiles)');
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
// No native title (the hover card says it, tile-grid-hint.test.ts).
expect(btn.title).toBe('');
expect(menu()).toBeNull();
});
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
expect(html).toContain('aria-label="Tiles: show several sessions side by side (right-click for how many)"');
expect(html).toContain('aria-describedby="tileGridHint"');
});
});
describe('the count menu', () => {
it('offers 2, 4 and 6 with their shapes, the default 6 checked and focused; no user text', () => {
const app = gridApp();
const ev = { preventDefault: vi.fn() };
app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()!.attrs.role).toBe('menu');
expect(menu()!.attrs['aria-label']).toBe('How many tiles');
expect(items().map((i) => i.dataset.count)).toEqual(['2', '4', '6']);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'false', 'true']);
expect(items().map((i) => i.children[1].textContent)).toEqual(['2 tiles', '4 tiles', '6 tiles']);
// Each shape is the grid's own layout for that count: 2x1, 2x2, 3x2 cells.
expect(items().map((i) => i.children[0].children.length)).toEqual([2, 4, 6]);
expect(items().every((i) => !i.disabled)).toBe(true);
expect(hint()).toBeNull();
expect(focused()).toBe('6');
expect(app._tilesOwnTerminal()).toBe(false);
// No session name anywhere in it.
expect(JSON.stringify(menu()!.children.map((c) => c.textContent))).not.toMatch(/s-[a-g]/);
});
it('checks the remembered count; a bad stored value reads as the default', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
open(app);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'true', 'false']);
expect(focused()).toBe('4');
app.closeTileCountMenu();
localStore.set('codeman:tile-count', '5');
open(app);
expect(item(6).attrs['aria-checked']).toBe('true');
});
it('greys out a count the window cannot fit, says why, and starts on the largest that fits', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 }; // fits 4 (2x2 of 600x450), not 3x2
open(app);
expect(item(6).disabled).toBe(true);
expect(item(6).attrs['aria-disabled']).toBe('true');
expect(item(6).title).toBe('This window fits 4 tiles');
expect(item(4).disabled).toBe(false);
expect(hint()!.textContent).toBe('This window fits 4 tiles');
// The remembered 6 stays checked (owner answer), the keyboard starts on 4.
expect(item(6).attrs['aria-checked']).toBe('true');
expect(focused()).toBe('4');
app.closeTileCountMenu();
wrapRect = { width: 1000, height: 400 }; // fits 2
open(app);
expect(items().map((i) => i.disabled)).toEqual([false, true, true]);
expect(hint()!.textContent).toBe('This window fits 2 tiles');
});
it('a greyed count is never picked, by click or by Enter', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 };
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(menu()).not.toBeNull();
item(6).focus();
key(app, 'Enter');
expect(app._tilesOwnTerminal()).toBe(false);
expect(localStore.has('codeman:tile-count')).toBe(false);
});
it('arrows move over the counts that fit and wrap; Home and End', () => {
const app = gridApp();
open(app);
expect(key(app, 'ArrowDown').preventDefault).toHaveBeenCalled();
expect(focused()).toBe('2');
key(app, 'ArrowRight');
expect(focused()).toBe('4');
key(app, 'ArrowUp');
key(app, 'ArrowLeft');
expect(focused()).toBe('6');
key(app, 'Home');
expect(focused()).toBe('2');
key(app, 'End');
expect(focused()).toBe('6');
app.closeTileCountMenu();
wrapRect = { width: 1200, height: 900 };
open(app);
key(app, 'ArrowDown');
expect(focused()).toBe('2');
key(app, 'ArrowUp');
expect(focused()).toBe('4');
// The greyed 6 is skipped both ways.
key(app, 'ArrowDown');
expect(focused()).toBe('2');
});
it('Escape closes it and gives the keyboard back to the Tiles button; closing twice is harmless', () => {
const app = gridApp();
open(app);
const btn = bySelector.get('.btn-tile-grid')!;
const ev = key(app, 'Escape');
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()).toBeNull();
expect(activeElement()).toBe(btn);
expect(() => app.closeTileCountMenu()).not.toThrow();
});
it('the menu owns its Escape in the global handler: it closes alone, with the keyboard back on Tiles', () => {
const escape = appSrc.slice(appSrc.indexOf("if (e.key === 'Escape') {"), appSrc.indexOf('this.closeAllPanels();'));
expect(escape).toMatch(/if \(this\._tileCountMenu\) \{\s*this\.closeTileCountMenu\(\{ refocus: true \}\);\s*return;/);
const app = gridApp();
open(app);
app.closeTileCountMenu({ refocus: true });
expect(activeElement()).toBe(bySelector.get('.btn-tile-grid'));
});
it('Tab closes it; a click elsewhere closes it, a click inside does not', () => {
const app = gridApp();
open(app);
key(app, 'Tab');
expect(menu()).toBeNull();
open(app);
app._tileCountMenu.onOutside({ target: item(4) });
expect(menu()).not.toBeNull();
app._tileCountMenu.onOutside({ target: new FakeEl() });
expect(menu()).toBeNull();
});
it('closes when the keyboard leaves it for something else (a late focus of the single view), not for nothing', () => {
const app = gridApp();
open(app);
// Inside: moving between counts keeps it.
menu()!.dispatch('focusout', { relatedTarget: item(2) });
expect(menu()).not.toBeNull();
// Nowhere (a click on a button in Safari focuses nothing): kept.
menu()!.dispatch('focusout', { relatedTarget: null });
expect(menu()).not.toBeNull();
// Another element, the single view's terminal: closed, the keyboard left there.
const textarea = new FakeEl();
textarea.className = 'xterm-helper-textarea';
textarea.focus();
menu()!.dispatch('focusout', { relatedTarget: textarea });
expect(menu()).toBeNull();
expect(activeElement()).toBe(textarea);
});
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
const app = gridApp();
open(app);
open(app);
expect(menu()).toBeNull();
open(app);
app.toggleTileGrid();
expect(menu()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('refuses in a narrow window', () => {
const app = gridApp();
windowStub.innerWidth = 1100;
open(app);
expect(menu()).toBeNull();
});
});
describe('picking a count', () => {
it('Enter on 2 opens two tiles, the active session included and focused, and remembers 2', () => {
const app = gridApp();
app.activeSessionId = 's-c';
open(app);
key(app, 'ArrowDown'); // 6 -> 2
key(app, 'Enter');
expect(menu()).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-other', 's-c']);
expect(app._tileGrid.focusedId).toBe('s-c');
expect(localStore.get('codeman:tile-count')).toBe('2');
// The grid's own key stays ids only.
const stored = JSON.parse(localStore.get('codeman:tile-grid')!);
expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
});
it('a click on 4 opens four, Space works too', () => {
const app = gridApp();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toHaveLength(4);
app.closeTileGrid({ reselect: false });
open(app);
item(2).focus();
key(app, ' ');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('then the click and Ctrl+Shift+G open with the remembered count', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
app.toggleTileGrid();
localStore.delete('codeman:tile-grid');
localStore.set('codeman:tile-count', '2');
app.runTileShortcut('toggle-tile-grid');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('the default is 6, and the click never opens more than the window fits', () => {
const app = gridApp();
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(6);
app.closeTileGrid({ keepStored: false, reselect: false });
wrapRect = { width: 1200, height: 900 };
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(4);
});
it('fewer open sessions than the count: fewer tiles', () => {
const app = gridApp(['s-a', 's-b']);
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b']);
});
});
describe('Ctrl/Cmd+click on a tab with the grid closed', () => {
it('opens the remembered count in total, the clicked session among them and focused (owner answer 2)', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.addSessionToTiles('s-f');
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-f']);
expect(app._tileGrid.focusedId).toBe('s-f');
});
it('with the default 6, six in total, never seven', () => {
const app = gridApp();
app.addSessionToTiles('s-g');
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids.at(-1)).toBe('s-g');
});
});
describe('re-forming an open grid', () => {
it('6 to 2 keeps the tiles it has first, drops from the end, never the focused one', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-e' });
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-a', 's-e']);
expect(app._tileGrid.focusedId).toBe('s-e');
expect(app.activeSessionId).toBe('s-e');
// The dropped ones were destroyed, not reloaded or kept connected.
const destroyed = FakeTile.all.filter((t) => t.destroy.mock.calls.length).map((t) => t.sessionId);
expect(destroyed.sort()).toEqual(['s-b', 's-c', 's-d', 's-f']);
expect(localStore.get('codeman:tile-count')).toBe('2');
});
it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => {
const app = gridApp();
app.openTileGrid(['s-c', 's-a']);
flushFrames();
const before = new Map(FakeTile.all.map((t) => [t.sessionId, t]));
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-other', 's-b', 's-d', 's-e']);
expect(app._tileFor('s-c')).toBe(before.get('s-c'));
expect(app._tileFor('s-a')).toBe(before.get('s-a'));
expect(before.get('s-c')!.connect).toHaveBeenCalledTimes(1);
});
it('every joining tile connects only once the grid has its final cells (one fit, one PTY resize)', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const seen: string[] = [];
const realMount = app._mountTile.bind(app);
app._mountTile = (id: string, opts?: unknown) => {
const ok = realMount(id, opts);
const tile = app._tileGrid.tiles.get(id)?.tile as FakeTile | undefined;
tile?.connect.mockImplementation(async () => {
seen.push(`${id}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(id)}`);
});
return ok;
};
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
// Each one connected into the 3x2 grid, already in its final cell.
expect(seen).toEqual(['s-other:6:2', 's-c:6:3', 's-d:6:4', 's-e:6:5']);
});
it('holes fill first: a 3x2 of five with an empty middle cell grows to six there', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 5));
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-b', 's-c', 's-d', 's-e']);
});
it('6 to 4: the cell model rule (a tile left in the third column packs the rest)', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-a' });
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cols).toBe(2);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('the same count changes nothing; a zoom the user chose ends with a re-form', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 4));
const cells = app._tileGrid.cells.slice();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(cells);
app.zoomTile('s-b');
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.zoomedId).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
});
describe('the session picker is gone', () => {
it('no picker method, element, style or markup hook is left', () => {
const app = makeGridApp(IDS);
expect(app.openTilePicker).toBeUndefined();
expect(app.closeTilePicker).toBeUndefined();
expect(gridSrc).not.toMatch(/tilePickerMenu|tile-picker|openTilePicker|closeTilePicker/);
expect(css).not.toMatch(/tile-picker/);
expect(html).not.toMatch(/openTilePicker/);
expect(appSrc).not.toMatch(/closeTilePicker/);
});
it('a tile has no + (owner decision 9): tiles come from the Tiles button, its menu, Ctrl/Cmd+click, a tab, a group or Run', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const header = app._tileGrid.tiles.get('s-a').header as FakeEl;
const actions = header.children.find((c) => c.className === 'tile-actions')!;
expect(actions.children.map((b) => b.className)).toEqual(['tile-btn tile-menu', 'tile-btn tile-zoom', 'tile-btn tile-remove']);
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* @fileoverview Draggable column and row dividers.
*
* Every tile is placed explicitly (`grid-column` / `grid-row`, reading order),
* with a divider track between columns and between rows. Dragging a divider
* trades size between the two tracks either side, each kept at the minimum tile
* size. The affected tiles reflow LOCALLY at most once per animation frame
* (`localFit`, no PTY resize), and each hears exactly ONE `fit()` (one PTY
* resize) at pointer-up; tiles in other tracks hear nothing. A drag in progress
* is torn down when the grid closes or a tile is removed (the split's mid-drag
* lesson). Fractions reset when the column or row count changes.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
flushFrames,
makeGridApp,
rafCallbacks,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile;
function grid(ids: string[]): GridApp {
const app = makeGridApp(ids);
app.openTileGrid(ids);
// The tiles' terminals are built one per frame (_connectTilesPaced).
flushFrames();
for (const t of FakeTile.all) {
t.fit.mockClear();
t.localFit.mockClear();
}
return app;
}
const runFrames = () => {
for (const cb of rafCallbacks.splice(0)) cb();
};
beforeEach(() => {
resetGridHarness();
});
describe('placement', () => {
it('2x2: one column divider, one row divider, tiles placed in reading order', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
expect([...app._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'row-0']);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['3', '3']);
expect([tileEl('s-b').style.gridColumn, tileEl('s-b').style.gridRow]).toEqual(['3', '1']);
expect([divider(app, 'col-0').style.gridColumn, divider(app, 'col-0').style.gridRow]).toEqual(['2', '1 / -1']);
expect([divider(app, 'row-0').style.gridColumn, divider(app, 'row-0').style.gridRow]).toEqual(['1 / -1', '2']);
});
it('3x2 has two column dividers; a single tile has none', () => {
expect([...grid(SIX)._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'col-1', 'row-0']);
resetGridHarness();
expect(grid(['s-a'])._tileGrid.dividers.size).toBe(0);
});
it('dividers are separators a screen reader can name', () => {
const app = grid(['s-a', 's-b']);
const d = divider(app, 'col-0');
expect(d.getAttribute('role')).toBe('separator');
expect(d.getAttribute('aria-orientation')).toBe('vertical');
});
});
describe('dragging', () => {
function drag(app: GridApp, key: string, from: number, to: number[]) {
const d = divider(app, key);
const axisCol = key.startsWith('col');
const at = (v: number) => (axisCol ? { clientX: v, clientY: 0 } : { clientX: 0, clientY: v });
d.dispatch('pointerdown', {
button: 0,
pointerId: 7,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...at(from),
});
for (const v of to) d.dispatch('pointermove', at(v));
return d;
}
it('reflows locally once per frame and resizes each affected PTY exactly once, at pointer-up', () => {
const app = grid(SIX);
const d = drag(app, 'col-1', 1600, [1620, 1640, 1660, 1680]);
// Four moves, one frame.
expect(rafCallbacks).toHaveLength(1);
runFrames();
// Column 1 and 2 tiles: b, c (row 0) and e, f (row 1).
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).localFit).toHaveBeenCalledTimes(1);
expect(tile('s-a').localFit).not.toHaveBeenCalled();
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
d.dispatch('pointerup', {});
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(tile('s-a').fit).not.toHaveBeenCalled();
expect(tile('s-d').fit).not.toHaveBeenCalled();
});
it('the frame still queued at pointer-up is applied, so the tracks end where the pointer did', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [1300]);
d.dispatch('pointerup', {});
// 2400 wide, 8px padding, one 6px divider: 2386px shared; the left track gained 100px.
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(1293);
expect(section.style.gridTemplateColumns).toMatch(/^minmax\(0, [\d.]+fr\) 6px minmax\(0, [\d.]+fr\)$/);
});
it('keeps both neighbours at the minimum tile size', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [100]);
d.dispatch('pointerup', {});
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(480);
});
it('a row divider moves rows and resizes the tiles in them', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
const d = drag(app, 'row-0', 600, [650]);
d.dispatch('pointerup', {});
for (const id of ['s-a', 's-b', 's-c', 's-d']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(app._tileGrid.rowFr[0]).toBeGreaterThan(app._tileGrid.rowFr[1]);
});
it('locks the cursor and text selection for the drag, released at the end', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, []);
expect(body.classList.contains('tile-grid-resizing')).toBe(true);
expect(d.classList.contains('dragging')).toBe(true);
d.dispatch('pointerup', {});
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.setPointerCapture).toHaveBeenCalledWith(7);
expect(d.releasePointerCapture).toHaveBeenCalledWith(7);
});
});
describe('a drag torn down mid-way', () => {
function startDrag(app: GridApp) {
const d = divider(app, 'col-0');
d.dispatch('pointerdown', {
button: 0,
pointerId: 1,
clientX: 800,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 900 });
return d;
}
it('closing the grid ends it: no body lock left behind, no late resize', () => {
const app = grid(['s-a', 's-b']);
app.selectSession = vi.fn();
const d = startDrag(app);
app.closeTileGrid({ reselect: false });
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.listeners.pointermove ?? []).toHaveLength(0);
runFrames();
d.dispatch('pointerup', {});
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
});
it('removing a tile ends it', () => {
const app = grid(['s-a', 's-b', 's-c']);
startDrag(app);
app.removeTile('s-c');
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(app._tileDividerDragTeardown ?? null).toBeNull();
});
});
describe('fractions', () => {
it('reset to equal when the column count changes', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
app._tileGrid.colFr = [3, 1];
app._applyTileLayout();
expect(app._tileGrid.colFr).toEqual([3, 1]);
app.sessions.set('s-e', { id: 's-e', name: 's-e', mode: 'claude', pid: 1 });
app.addTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1, 1]);
});
});
describe('fractions on fewer columns', () => {
it('reset to equal when the column count drops (no stale track left over)', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.colFr = [3, 1, 1];
app.removeTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1]);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
});
});
describe('zoomed', () => {
it('has no dividers while a tile fills the grid, and gets them back on restore', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
const dividerEls = () => section.children.filter((el) => el.className.startsWith('tile-divider'));
app.zoomTile('s-a');
expect(app._tileGrid.dividers.size).toBe(0);
expect(dividerEls()).toHaveLength(0);
app.zoomTile('s-a');
expect(dividerEls()).toHaveLength(2);
});
});
+254
View File
@@ -0,0 +1,254 @@
/**
* @fileoverview The ways into the grid besides the Tiles button.
*
* - Drag a tab from the strip onto a tile: a session not yet tiled replaces it
* (same place, the replaced session keeps running); one already tiled swaps
* places. Onto an empty slot (3 tiles in a 2x2): it joins there. The tile
* handles the drag in the CAPTURE phase and stops it, because the payload is
* the session id as text and xterm's helper textarea would type it into the
* PTY; a drag that is not a tab (a file) is left alone.
* - Ctrl/Cmd+click on a tab: the session joins the grid (opening it on what
* Tiles would bring back) and takes focus, as a human selection; on a window
* too narrow for the grid it is an ordinary click.
* - "Open group as tiles" in the grouped rail's group menu: the group's live
* sessions become the grid.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const slots = () => section.children.filter((el) => el.className === 'tile-slot');
const dragEvent = () => ({ preventDefault: vi.fn(), stopPropagation: vi.fn(), dataTransfer: { dropEffect: '' } });
function dropOn(el: FakeEl) {
const over = dragEvent();
el.dispatch('dragover', over);
const drop = dragEvent();
el.dispatch('drop', drop);
return { over, drop };
}
beforeEach(() => {
resetGridHarness();
});
describe('dragging a tab onto a tile', () => {
it('a session not yet tiled replaces the tile in place; the replaced one keeps running', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.markIdleAlertSeen.mockClear();
const replaced = FakeTile.all.find((t) => t.sessionId === 's-b')!;
app.draggedTabId = 's-other';
const { over, drop } = dropOn(tileEl('s-b'));
expect(over.preventDefault).toHaveBeenCalled();
expect(drop.stopPropagation).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']);
expect(replaced.destroy).toHaveBeenCalledTimes(1);
expect(app.sessions.has('s-b')).toBe(true);
expect(tileEl('s-other').style.gridColumn).toBe('3');
expect(app.activeSessionId).toBe('s-other');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-other');
});
it('a session already tiled swaps places with the target', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = 's-c';
dropOn(tileEl('s-a'));
expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a']);
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
expect(tileEl('s-c').style.gridColumn).toBe('1');
expect(app.activeSessionId).toBe('s-c');
});
it('a drag that is not a tab (a file) is left alone', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = null;
const { over, drop } = dropOn(tileEl('s-b'));
expect(over.preventDefault).not.toHaveBeenCalled();
expect(drop.stopPropagation).not.toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(IDS);
});
it('is handled in the capture phase, before xterm sees the drop', () => {
makeGridApp(IDS).openTileGrid(IDS);
expect(tileEl('s-a').captureFlags.dragover).toEqual([true]);
expect(tileEl('s-a').captureFlags.drop).toEqual([true]);
});
});
describe('dragging a tab onto an empty slot', () => {
// Under 1800px wide three tiles take a 2x2 (over it, they sit side by side).
beforeEach(() => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
it('3 tiles in a 2x2 leave one slot, placed after the tiles', () => {
makeGridApp(IDS).openTileGrid(IDS);
expect(slots()).toHaveLength(1);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '3']);
});
it('a session dropped there joins the grid and takes focus', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.draggedTabId = 's-other';
dropOn(slots()[0]);
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
expect(slots()).toHaveLength(0);
expect(app.activeSessionId).toBe('s-other');
});
it('a full layout has no slot', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
expect(slots()).toHaveLength(0);
});
it('a zoomed grid has no slot; restoring the grid brings it back', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.zoomTile('s-a');
expect(slots()).toHaveLength(0);
app.zoomTile('s-a');
expect(slots()).toHaveLength(1);
});
});
describe('Ctrl/Cmd+click on a tab', () => {
const click = (mods: Record<string, boolean>) => ({ preventDefault: vi.fn(), ...mods });
it('with the grid closed: opens what the Tiles toggle would, plus the clicked one, focusing it', () => {
const app = makeGridApp(IDS);
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');
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
it('with the grid open: adds it and focuses it (Cmd works the same)', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b']);
app.markIdleAlertSeen.mockClear();
app.handleSessionTabClick(click({ metaKey: true }), 's-c');
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
it('on a tiled one: just focuses its tile', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-b');
});
it('a plain click is still a plain selection', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.handleSessionTabClick(click({}), 's-b');
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
expect(app._tileGrid?.open ?? false).toBe(false);
});
it('on a window too narrow for the grid it is an ordinary click', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
windowStub.innerWidth = 1100;
app.handleSessionTabClick(click({ ctrlKey: true }), 's-b');
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true });
expect(app._tileGrid?.open ?? false).toBe(false);
});
});
describe('"Open group as tiles"', () => {
function withGroups(app: GridApp) {
app.tabLayout = {
groups: [
{
id: 'g1',
refs: [
{ kind: 'session', id: 's-c' },
// A web tab is never a tile, even one whose id a session also has.
{ kind: 'webview', id: 's-b' },
{ kind: 'session', id: 'gone' },
{ kind: 'session', id: 's-a' },
],
},
],
};
let actions: Array<{ label: string; run: () => void }> = [];
app._openTabLayoutMenu = vi.fn((_e: unknown, _k: string, _l: string, a: typeof actions) => {
actions = a;
return true;
});
return () => actions;
}
it('is in the group menu, and opens the group live sessions as tiles', () => {
const app = makeGridApp(IDS);
const actions = withGroups(app);
app.openTabGroupMenu({}, 'g1');
const open = actions().find((a) => a.label === 'Open group as tiles');
expect(open).toBeTruthy();
open!.run();
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
});
it('replaces an open grid', () => {
const app = makeGridApp(IDS);
withGroups(app);
app.openTileGrid(['s-b']);
app.openGroupAsTiles('g1');
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
});
it('keeps the focused session focused when the group holds it, open grid or not', () => {
for (const gridOpen of [false, true]) {
resetGridHarness();
const app = makeGridApp(IDS);
withGroups(app);
if (gridOpen) app.openTileGrid(['s-b', 's-a'], { focusedId: 's-a' });
else app.activeSessionId = 's-a';
app.openGroupAsTiles('g1');
expect(app._tileGrid.ids).toEqual(['s-c', 's-a']);
expect(app._tileGrid.focusedId).toBe('s-a');
expect(app.activeSessionId).toBe('s-a');
}
});
it("focuses the group's first session when it does not hold the focused one", () => {
const app = makeGridApp(IDS);
withGroups(app);
app.openTileGrid(['s-b']);
app.openGroupAsTiles('g1');
expect(app._tileGrid.focusedId).toBe('s-c');
});
it('is not offered where the grid cannot open', () => {
const app = makeGridApp(IDS);
const actions = withGroups(app);
windowStub.innerWidth = 1100;
app.openTabGroupMenu({}, 'g1');
expect(actions().some((a) => a.label === 'Open group as tiles')).toBe(false);
});
});
+369
View File
@@ -0,0 +1,369 @@
/**
* @fileoverview The Tiles button's hover card (owner feedback 1: "give me the
* hover info to right click over the tile button to adjust it").
*
* - Installed once with the button's visibility, never in a solo window: a
* hidden `role=tooltip` card in body, the button's `aria-describedby`, and
* the button's native title removed (two tooltips never stack); the
* aria-label stays.
* - Shows 300 ms after a pointer that hovers rests on the button, or after a
* keyboard focus (`:focus-visible`), under the button. Never for a touch
* pointer, a device that cannot hover, a hidden button, or while the count
* menu is open.
* - Content: "Tiles · N" (the remembered count, live), what a click does
* (open or close the grid), "Right-click: choose 2, 4 or 6 tiles", what
* opens when the count does not fit the window, and Shift+F10 when shown
* from the keyboard; hidden, it holds all of it (the button's description).
* - Hides on pointer leave, blur, a click, a right-click or a press on the
* button (and stays hidden while the pointer rests there), Escape, a scroll
* and a resize; the count menu never opens beside it, and the Escape out of
* the menu brings no card back.
* - Only `.tile-hint` styles: the panel of the count menu, a fade on opacity
* and transform only, nothing under reduced motion or without hover.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
localStore,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
let btn: FakeEl;
let wrapRect = { width: 2400, height: 1200 };
const card = () => body.children.find((c) => c.id === 'tileGridHint') ?? null;
const lines = () => card()!.children.filter((c) => c.className.includes('tile-hint-line'));
const textOf = (row: FakeEl) => row.children.at(-1)!.textContent;
const visibleText = () => [
card()!.children[0].textContent,
...lines()
.filter((l) => !l.hidden)
.map(textOf),
];
const pointer = (type: string, pointerType = 'mouse') => btn.dispatch(type, { pointerType });
beforeEach(() => {
resetGridHarness();
vi.useFakeTimers();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
btn.title = 'Tiles: show several sessions side by side (right-click for how many)';
btn.getBoundingClientRect = () => ({ width: 26, height: 26, top: 8, left: 2000, right: 2026, bottom: 34 });
bySelector.set('.btn-tile-grid', btn);
});
afterEach(() => {
vi.useRealTimers();
});
function hintApp(): GridApp {
const app = makeGridApp(IDS);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app._applyTileGridButtonVisibility(true);
return app;
}
describe('installed with the button', () => {
it('a hidden tooltip card in body, the button described by it, its native title gone, its label kept', () => {
const app = hintApp();
expect(card()).not.toBeNull();
expect(card()!.attrs.role).toBe('tooltip');
expect(card()!.hidden).toBe(true);
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
expect(btn.title).toBe('');
expect(btn.getAttribute('title')).toBeNull();
app._updateTileGridButtonState();
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
expect(btn.title).toBe('');
// Once: a second call adds no second card.
app._applyTileGridButtonVisibility(true);
expect(body.children.filter((c) => c.id === 'tileGridHint')).toHaveLength(1);
// The markup: no title on the button, described by the card.
const tag = html.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)![0];
expect(tag).not.toMatch(/\stitle=/);
expect(tag).toContain('aria-describedby="tileGridHint"');
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
app._applyTileGridButtonVisibility(true);
expect(card()).toBeNull();
});
it('hidden, it holds everything a screen reader should hear, the keyboard line included', () => {
hintApp();
expect(lines().map((l) => l.hidden)).toEqual([false, false, true, false]);
expect(card()!.children[0].textContent).toBe('Tiles · 6');
expect(textOf(lines()[3])).toBe('Shift+F10: the same menu from the keyboard');
});
});
describe('showing', () => {
it('300 ms after a mouse rests on the button, under it and right-aligned', () => {
hintApp();
btn.classList.remove('btn-tile-grid--hidden');
pointer('pointerenter');
vi.advanceTimersByTime(299);
expect(card()!.hidden).toBe(true);
vi.advanceTimersByTime(1);
expect(card()!.hidden).toBe(false);
expect(card()!.style.top).toBe('40px');
expect(card()!.style.right).toBe(`${2400 - 2026}px`);
});
it('from a pointer: no keyboard line; from a keyboard focus: with it', () => {
hintApp();
btn.classList.remove('btn-tile-grid--hidden');
pointer('pointerenter');
vi.advanceTimersByTime(300);
expect(visibleText()).toEqual(['Tiles · 6', 'Click: open the grid', 'Right-click: choose 2, 4 or 6 tiles']);
pointer('pointerleave');
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
btn.dispatch('focus', {});
vi.advanceTimersByTime(300);
expect(visibleText()).toContain('Shift+F10: the same menu from the keyboard');
});
it('a mouse focus (not :focus-visible) shows nothing', () => {
hintApp();
btn.classList.remove('btn-tile-grid--hidden');
(btn as unknown as { matches: (s: string) => boolean }).matches = () => false;
btn.dispatch('focus', {});
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true);
});
it('never for a touch pointer, a device that cannot hover, or a hidden button', () => {
hintApp();
// The setting off, or a narrow window: the button carries its hidden marker.
btn.classList.add('btn-tile-grid--hidden');
pointer('pointerenter');
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true); // still --hidden
btn.classList.remove('btn-tile-grid--hidden');
pointer('pointerenter', 'touch');
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true);
windowStub.matchMedia = (q: string) => ({ matches: !q.includes('hover'), addEventListener: vi.fn() });
pointer('pointerenter');
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true);
});
it('never while the count menu is open', () => {
const app = hintApp();
btn.classList.remove('btn-tile-grid--hidden');
app.openTileCountMenu({ preventDefault: vi.fn() });
pointer('pointerenter');
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true);
});
});
describe('what it says', () => {
const show = () => {
pointer('pointerenter');
vi.advanceTimersByTime(300);
};
it('the remembered count, live, and open or close by the grid', () => {
const app = hintApp();
btn.classList.remove('btn-tile-grid--hidden');
localStore.set('codeman:tile-count', '2');
app._renderTileHint();
show();
expect(visibleText()[0]).toBe('Tiles · 2');
// A pick in the menu changes it at once, the card still up.
app._rememberTileGridCount(4);
expect(visibleText()[0]).toBe('Tiles · 4');
app.openTileGrid(IDS.slice(0, 4));
expect(visibleText()).toContain('Click: close the grid');
app.closeTileGrid({ reselect: false });
expect(visibleText()).toContain('Click: open the grid');
});
it('a refresh with nothing changed leaves the translated text alone (it compares with the last English)', () => {
const app = hintApp();
// What the zh-CN translator leaves in the DOM.
card()!.children[0].textContent = '平铺 · 6';
expect(textOf(lines()[0])).toBe('Click: open the grid');
lines()[0].children.at(-1)!.textContent = '单击:打开平铺网格';
app._renderTileHint();
app._updateTileGridButtonState();
expect(card()!.children[0].textContent).toBe('平铺 · 6');
expect(textOf(lines()[0])).toBe('单击:打开平铺网格');
// A real change writes the new English for the translator.
app._rememberTileGridCount(2);
expect(card()!.children[0].textContent).toBe('Tiles \u00B7 2');
});
it('a count the window cannot fit says what opens instead (closed) and what fits (open)', () => {
const app = hintApp();
btn.classList.remove('btn-tile-grid--hidden');
wrapRect = { width: 1200, height: 900 }; // fits 4
app._renderTileHint();
show();
expect(visibleText()).toEqual([
'Tiles · 6',
'Click: open the grid',
'Right-click: choose 2, 4 or 6 tiles',
'This window fits 4 tiles: a click opens 4',
]);
localStore.set('codeman:tile-count', '4');
app._renderTileHint();
expect(visibleText()).toHaveLength(3);
});
});
describe('hiding', () => {
const shown = () => {
const app = hintApp();
btn.classList.remove('btn-tile-grid--hidden');
pointer('pointerenter');
vi.advanceTimersByTime(300);
expect(card()!.hidden).toBe(false);
return app;
};
it('on pointer leave, and a pending show is cancelled', () => {
shown();
pointer('pointerleave');
expect(card()!.hidden).toBe(true);
pointer('pointerenter');
vi.advanceTimersByTime(200);
pointer('pointerleave');
vi.advanceTimersByTime(500);
expect(card()!.hidden).toBe(true);
});
it('on blur', () => {
shown();
btn.dispatch('blur', {});
expect(card()!.hidden).toBe(true);
});
it('on a press, a click or a right-click on the button, in the capture phase, and stays hidden there', () => {
for (const type of ['pointerdown', 'click', 'contextmenu']) {
resetGridHarness();
bySelector.set('.btn-tile-grid', btn);
btn.listeners = {};
btn.captureFlags = {};
shown();
btn.dispatch(type, {});
expect(card()!.hidden, type).toBe(true);
expect(btn.captureFlags[type].some(Boolean), type).toBe(true);
// Still resting on it: no card back.
pointer('pointerenter');
vi.advanceTimersByTime(1000);
expect(card()!.hidden, type).toBe(true);
// Once the pointer has left, a new rest shows it again.
pointer('pointerleave');
pointer('pointerenter');
vi.advanceTimersByTime(300);
expect(card()!.hidden, type).toBe(false);
}
});
it('a keyboard focus coming back after a click shows it, the pointer still resting there', () => {
shown();
btn.dispatch('click', {});
expect(card()!.hidden).toBe(true);
// Tab away and back, the mouse never moved.
btn.dispatch('blur', {});
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
btn.dispatch('focus', {});
vi.advanceTimersByTime(300);
expect(card()!.hidden).toBe(false);
});
it('the count menu never opens beside it: openTileCountMenu hides it first', () => {
const app = shown();
app.openTileCountMenu({ preventDefault: vi.fn() });
expect(body.children.some((c) => c.id === 'tileCountMenu')).toBe(true);
expect(card()!.hidden).toBe(true);
});
it('the Escape out of the menu brings no card back with the keyboard focus', () => {
const app = hintApp();
btn.classList.remove('btn-tile-grid--hidden');
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
btn.focus = vi.fn(() => btn.dispatch('focus', {}));
app.openTileCountMenu({ preventDefault: vi.fn() });
app.closeTileCountMenu({ refocus: true });
expect(btn.focus).toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(card()!.hidden).toBe(true);
});
it('on Escape, a scroll and a resize, listened to only while shown', () => {
const docAdd = (windowStub.addEventListener as ReturnType<typeof vi.fn>).mock;
const before = docAdd.calls.length;
const app = shown();
const added = docAdd.calls.slice(before) as Array<[string, () => void, unknown]>;
const scroll = added.find(([t]) => t === 'scroll')!;
const resize = added.find(([t]) => t === 'resize')!;
expect(scroll[2]).toBe(true);
scroll[1]();
expect(card()!.hidden).toBe(true);
expect((windowStub.removeEventListener as ReturnType<typeof vi.fn>).mock.calls.some(([t]) => t === 'scroll')).toBe(true);
pointer('pointerleave');
pointer('pointerenter');
vi.advanceTimersByTime(300);
resize[1]();
expect(card()!.hidden).toBe(true);
pointer('pointerleave');
pointer('pointerenter');
vi.advanceTimersByTime(300);
app._tileHint.onKey({ key: 'Enter' });
expect(card()!.hidden).toBe(false);
app._tileHint.onKey({ key: 'Escape' });
expect(card()!.hidden).toBe(true);
});
it('hidden after a keyboard show, the keyboard line is back in the description', () => {
shown(); // a pointer show: the keyboard line hidden
expect(lines()[3].hidden).toBe(true);
pointer('pointerleave');
expect(lines()[3].hidden).toBe(false);
});
});
describe('the CSS', () => {
it('the count menu panel, no pointer, a fade on opacity and transform only, none without hover or with reduced motion', () => {
const block = css.slice(css.indexOf('.tile-hint {'), css.indexOf('.tile-hint {') + 900);
expect(block).toContain('background: var(--floating-bg');
expect(block).toContain('border: 1px solid var(--control-border');
expect(block).toContain('pointer-events: none;');
const keyframes = css.match(/@keyframes tile-hint-in \{([\s\S]*?)\n\}/)![1];
expect([...keyframes.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]).sort()).toEqual(['opacity', 'transform']);
expect(css).toMatch(/@media \(hover: none\) \{\s*\.tile-hint \{\s*display: none !important;/);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.tile-hint \{\s*animation: none;/);
expect(css).toMatch(/\.tile-hint\[hidden\],\s*\.tile-hint \[hidden\] \{\s*display: none;/);
});
it('no other header button changes: its rules are .tile-hint only', () => {
const start = css.indexOf('*/', css.indexOf("/* The Tiles button's hover card")) + 2;
const end = css.indexOf('/* ── Tile grid motion');
const rules = css.slice(start, end).replace(/\/\*[\s\S]*?\*\//g, '');
const selectors = [...rules.matchAll(/^([^\s@}][^{]*)\{/gm)].map((m) => m[1].trim());
expect(selectors.length).toBeGreaterThan(5);
for (const sel of selectors) expect(sel, sel).toMatch(/^(\.tile-hint|from|to)/);
});
});
+464
View File
@@ -0,0 +1,464 @@
/**
* @fileoverview The tile grid in Chinese (zh-CN): every string it puts on
* screen has its own entry or pattern in i18n.js, so nothing is left in
* English and nothing falls through to the generic leading-verb fallback.
*
* - Runtime strings are HARVESTED from the real tile code (the shared vm
* harness, test/mocks/tile-grid-vm.ts) driven through every state that
* writes text: the count menu (cap and window wording), the Attach
* overlay (not attached, attaching, exited, ended), zoom, the header
* tooltip, the dividers, the empty slot, every toast, the crash-restart
* confirm, the Tiles and Split button titles. Each must translate to text
* with no Latin word left beyond key names and durations, and read
* unchanged in English.
* - Static strings: the shortcut registry's tile entries (overlay and App
* Settings list), and index.html run through the real translator in JSDOM
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
* - User text stays as typed: session names (tile header) and group names
* carry data-i18n-skip, and a session name inside the
* confirm passes through the pattern untranslated.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
fetchSpy,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
const I18N = read('i18n.js');
const INDEX = read('index.html');
type Api = {
t(s: string, v?: Record<string, string>): string;
configure(o: Record<string, string>): void;
start(): void;
};
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language });
return { api, dom };
}
const zh = translator('zh-CN');
const en = translator('en');
afterAll(() => {
zh.dom.window.close();
en.dom.window.close();
});
// What may stay Latin in a translation: key names, compact durations, glyphs.
const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G|F10)\b|<1m|\b\d+[dhm]\b/g;
const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? [];
/** Every string a translation must handle, with where it was seen. */
const seen = new Map<string, string>();
/** Text in a data-i18n-skip subtree (user text): must never be translated. */
const userText = new Set<string>();
/**
* The harness logo's tooltip and accessible name (and the model box's
* tooltip): harness and model NAMES, which stay as they are, plus at most a
* note on where the model came from, which translates.
*/
const harnessLabels = new Map<string, string>();
/** The harness labels and models the exercise below gives its sessions. */
const HARNESS_NAMES = ['Claude Code', 'DeepSeek', 'Codex', 'qwen3.8-27b', 'Haiku 4.5', 'haiku', 'gpt-5.6-terra'];
/**
* A title or accessible name inside a skipped subtree: the translator skips
* the element's attributes along with its text, so a UI label there stays
* English. Only the user text itself may be skipped.
*/
const labelsInSkip: string[] = [];
function harvest(root: FakeEl | null | undefined, where: string) {
const walk = (el: FakeEl, inSkip: boolean) => {
const skip = inSkip || 'data-i18n-skip' in el.attrs;
const names = /\b(run-mode-dot|tile-model)\b/.test(el.className);
const add = (value: unknown, kind: string) => {
if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return;
if (names && !skip && kind !== 'text') harnessLabels.set(value.trim(), `${where} (${kind})`);
else if (skip && kind === 'text') userText.add(value.trim());
else if (skip) labelsInSkip.push(`${where} (${kind}): ${value}`);
else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`);
};
add(el.textContent, 'text');
add(el.title, 'title');
add(el.attrs['aria-label'], 'aria-label');
add(el.attrs.title, 'title attribute');
for (const child of el.children) walk(child, skip);
};
if (root) walk(root, false);
}
function harvestAll(app: GridApp, where: string) {
harvest(section, where);
harvest(body, where);
harvest(bySelector.get('.btn-tile-grid'), where);
harvest(bySelector.get('.btn-split'), where);
for (const [msg] of (app.showToast as ReturnType<typeof vi.fn>).mock.calls) {
if (typeof msg === 'string' && !seen.has(msg)) seen.set(msg, `${where} (toast)`);
}
}
let wrapRect = { width: 2400, height: 1200 };
function setUp() {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
for (const cls of ['btn-tile-grid', 'btn-split']) {
const btn = new FakeEl();
btn.className = `btn-icon-header ${cls}`;
bySelector.set(`.${cls}`, btn);
}
}
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
/** Drives the real code through every state that writes text, harvesting as it goes. */
async function exercise() {
setUp();
const app = makeGridApp(EIGHT);
// The harness logos: a CLI-reported model, a launch one, a custom endpoint's, none.
windowStub.__codemanCliCatalog = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'deepseek', label: 'DeepSeek' },
{ id: 'codex', label: 'Codex' },
];
Object.assign(app.sessions.get('s-1'), {
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
});
app.sessions.get('s-2').displayModel = { model: 'haiku', source: 'launch' };
Object.assign(app.sessions.get('s-3'), {
mode: 'codex',
displayModel: { model: 'qwen3.8-27b', source: 'custom-endpoint' },
});
app.sessions.get('s-6').displayModel = { model: 'Haiku 4.5', source: 'statusline' };
Object.assign(app.sessions.get('s-4'), {
mode: 'deepseek',
displayModel: { model: 'qwen3.8-27b', source: 'config' },
});
let pill = 'idle';
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app.sessions.get('s-2').pid = null;
app.sessions.get('s-3').paneExit = { status: 3 };
app.sessions.get('s-4').paneExit = { signal: 9 };
// A session named like a UI string (a count menu label): user text, never translated.
app.sessions.get('s-5').name = '6 tiles';
// The Tiles button's hover card, closed: the default count, then a count
// the window cannot fit (the card is in body, harvested with it).
app._applyTileGridButtonVisibility(true);
harvestAll(app, 'hover card, closed');
wrapRect = { width: 1200, height: 900 };
app._rememberTileGridCount(6);
harvestAll(app, 'hover card, does not fit');
wrapRect = { width: 2400, height: 1200 };
app._renderTileHint();
// The count menu: the cap (nothing greyed), then the window wording.
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, cap');
app.closeTileCountMenu();
wrapRect = { width: 1200, height: 900 };
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, window');
app.closeTileCountMenu();
// A window that fits one tile words it in the singular.
wrapRect = { width: 700, height: 500 };
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'count menu, one tile');
app.closeTileCountMenu();
wrapRect = { width: 2400, height: 1200 };
// The grid: five tiles first (an empty slot), then the sixth.
app.openTileGrid(EIGHT.slice(0, 5));
harvestAll(app, 'grid of five');
// The hover card with the grid open, and a count it cannot fit there.
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
app._renderTileHint();
harvestAll(app, 'hover card, open, does not fit');
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
app._renderTileHint();
app.addTile('s-6');
app._renderTileChrome();
harvestAll(app, 'grid of six');
for (const state of ['needs you', 'error', 'waiting', 'working', 'done', 'exited']) {
pill = state;
app._renderTileChrome();
harvestAll(app, `tooltip ${state}`);
}
pill = 'idle';
// Zoom and back.
app.zoomTile('s-1');
harvestAll(app, 'zoomed');
app.zoomTile('s-1');
// The toasts of a full grid, by the cap and by the window.
app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-7');
wrapRect = { width: 1200, height: 900 };
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-8');
// Too small for six: the auto-zoom hint.
section.getBoundingClientRect = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 });
app._applyTileLayout();
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
wrapRect = { width: 2400, height: 1200 };
harvestAll(app, 'full grid');
// Attach: in flight, failed, the crash-restart confirm, a socket that ended.
let release: (v: unknown) => void = () => {};
fetchSpy.mockImplementationOnce(() => new Promise((r) => (release = r)));
const pending = app.attachTileSession('s-2');
harvestAll(app, 'attaching');
release({ ok: false, json: async () => ({ success: false }) });
await pending;
harvestAll(app, 'attach failed');
const confirmText: string[] = [];
windowStub.confirm = vi.fn((msg: string) => {
confirmText.push(msg);
return false;
});
app.sessions.get('s-5').pid = null;
app.sessions.get('s-5').respawnBlocked = true;
app._renderTileChrome();
await app.attachTileSession('s-5');
for (const msg of confirmText) seen.set(msg, 'confirm');
const tile6 = app._tileFor('s-6');
tile6._stoppedCode = 4009;
app._renderTileChrome();
harvestAll(app, 'socket ended');
// A group with no live session, and a toggle with nothing to open.
app.tabLayout = { groups: [{ id: 'g', name: 'Tiles', refs: [{ kind: 'session', id: 'gone' }] }] };
app.openGroupAsTiles('g');
harvestAll(app, 'empty group');
app.closeTileGrid({ reselect: false });
harvestAll(app, 'grid closed');
const empty = makeGridApp([]);
empty.sessions.clear();
empty.sessionOrder = [];
empty.toggleTileGrid();
harvestAll(empty, 'nothing to open');
return { confirmText };
}
// The harvest runs once, in the first test; the others read what it saw.
let confirmText: string[] = [];
describe('every tile grid string the code puts on screen translates to zh-CN', () => {
it('harvests the strings it should (the harvest itself is not vacuous)', async () => {
({ confirmText } = await exercise());
// ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.)
const expected = [
'How many tiles',
'Tiles',
'Tiles \u00B7 6',
'Click: open the grid',
'Click: close the grid',
'Right-click: choose 2, 4 or 6 tiles',
'Shift+F10: the same menu from the keyboard',
'This window fits 4 tiles: a click opens 4',
'2 tiles',
'4 tiles',
'6 tiles',
'The grid holds at most 6 tiles',
'This window fits 4 tiles',
'This window fits 1 tile',
'The grid already holds what this window fits (4)',
'Drop a tab or a tile here',
'Resize tile columns',
'Resize tile rows',
'Zoom this tile',
'Restore the grid',
'Session actions',
'Remove tile (the session keeps running)',
'idle 3m',
'idle 3m\nDrag to move the tile',
'needs you 3m\nDrag to move the tile',
'exited 3m\nDrag to move the tile',
'Not attached',
'Attach',
'Attaching…',
'The agent exited (3)',
'The agent exited (signal 9)',
'The session ended',
'It cannot be restarted in place: close it from ⋯ (Close session).',
'The grid holds at most 6 tiles: the new session opens on its own',
'The grid already holds what this window fits (4): the new session opens on its own',
'The grid already holds what this window fits (4)',
'The window is too small for 6 tiles: showing the focused one',
'Could not attach the session',
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)',
'6 tiles was stopped after crashing repeatedly. Restart it?',
];
const missing = expected.filter((s) => !seen.has(s));
expect(missing).toEqual([]);
});
it('each one: Chinese with no English left, and no half-translated fallback', () => {
const bad: string[] = [];
for (const [source, where] of seen) {
const text = zh.api.t(source);
// The session name inside the confirm is user text, allowed to stay.
const words = leftover(text.replace('6 tiles', ''));
if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`);
}
expect(bad).toEqual([]);
});
it('the harness logo keeps harness and model names as they are and translates the rest', () => {
// Not vacuous: every form the logo's tooltip takes was seen.
expect([...harnessLabels.keys()]).toEqual(
expect.arrayContaining([
'DeepSeek \u00B7 qwen3.8-27b',
'Claude Code \u00B7 haiku (set at launch)',
'Codex \u00B7 qwen3.8-27b (custom endpoint)',
'DeepSeek \u00B7 qwen3.8-27b (from config)',
'Claude Code \u00B7 Haiku 4.5',
'Claude Code',
])
);
const bad: string[] = [];
for (const [label, where] of harnessLabels) {
const text = zh.api.t(label);
const kept = HARNESS_NAMES.filter((n) => label.includes(n));
let rest = text;
for (const n of [...kept].sort((a, b) => b.length - a.length)) rest = rest.split(n).join('');
if (!kept.length || kept.some((n) => !text.includes(n)) || leftover(rest).length || en.api.t(label) !== label) {
bad.push(`${where}: "${label}" -> "${text}"`);
}
}
expect(bad).toEqual([]);
});
it('the header tooltip with the drag hint: every state, with and without a duration', () => {
// The harvest sees the hint under a state with a duration; a session the
// overview gives no time yet shows the bare state above it.
expect(zh.api.t('working 3m\nDrag to move the tile')).toBe('工作中 3m\n拖动可移动窗格');
expect(zh.api.t('needs you <1m\nDrag to move the tile')).toBe('需要你 <1m\n拖动可移动窗格');
expect(zh.api.t('idle\nDrag to move the tile')).toBe('空闲\n拖动可移动窗格');
expect(zh.api.t('Drag to move the tile')).toBe('拖动可移动窗格');
expect(en.api.t('idle\nDrag to move the tile')).toBe('idle\nDrag to move the tile');
});
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
expect(labelsInSkip).toEqual([]);
});
it('each one reads exactly as before in English', () => {
const changed = [...seen.keys()].filter((s) => en.api.t(s) !== s);
expect(changed).toEqual([]);
});
it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => {
const app = read('app.js');
const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]);
expect(labels).toHaveLength(11);
expect(app).toContain("label: 'Open group as tiles'");
expect(app).toContain('not bound');
const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => {
const text = zh.api.t(s);
return text === s || leftover(text).length > 0;
});
expect(bad).toEqual([]);
});
});
describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const doc = dom.window.document;
const extra = doc.createElement('div');
extra.innerHTML =
'<span id="u-session" data-i18n-skip>6 tiles</span>' +
'<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' +
'<span id="control">Tiles</span>';
doc.body.appendChild(extra);
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
it('the Tiles header button: its accessible name, no native title, its description is the hover card', () => {
const btn = doc.querySelector('.btn-tile-grid')!;
const label = btn.getAttribute('aria-label')!;
expect(label).toContain('平铺');
expect(leftover(label)).toEqual([]);
expect(btn.hasAttribute('title')).toBe(false);
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
});
it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => {
const chip = (id: string) => doc.getElementById(id)!.closest('label')!.textContent!.trim();
expect(chip('appSettingsShowTileGridButton')).toBe('平铺');
expect(chip('appSettingsShowSplitButton')).toBe('分屏');
expect(doc.getElementById('tileGrid')!.getAttribute('aria-label')).toBe('平铺的会话');
});
it("the Help modal's Tiles rows: Chinese around the key names", () => {
const tiles = [...doc.querySelectorAll('#helpModal .shortcut-section')].find(
(s) => s.querySelector('h4')!.textContent === '平铺'
);
expect(tiles).toBeTruthy();
expect(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格');
expect(tiles!.textContent).toContain('右键单击');
expect(tiles!.textContent).toContain('选择窗格数量(2、4 或 6)');
// Moving tiles: the chords and the header drag.
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
expect(tiles!.textContent).toContain('拖动');
expect(tiles!.textContent).toContain('窗格的标题栏');
});
it('user text stays as typed: a session name and a group name that are also UI words', () => {
expect(doc.getElementById('u-session')!.textContent).toBe('6 tiles');
expect(doc.getElementById('u-group')!.textContent).toBe('Tiles');
expect(doc.getElementById('control')!.textContent).toBe('平铺');
});
});
describe('user text in the tile code', () => {
it('session names in a tile header are marked data-i18n-skip', () => {
// The harvest saw them only inside skipped subtrees.
expect(userText.has('6 tiles')).toBe(true);
expect([...seen.keys()]).not.toContain('s-1');
const src = read('tile-grid.js');
expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2);
});
it('the group names of the grouped rail are marked data-i18n-skip', () => {
expect(read('tab-layout-browser.js')).toContain('class="tab-layout-group-name" id="${nameId}" data-i18n-skip>');
});
it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => {
expect(confirmText).toEqual(['6 tiles was stopped after crashing repeatedly. Restart it?']);
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
});
});
+340
View File
@@ -0,0 +1,340 @@
/**
* @fileoverview The tile grid's pure helpers (constants.js, `window.CodemanTileGrid`).
*
* - `computeTileLayout`: columns x rows by tile count (the spec's table), with
* the 3-tile special case (3x1 only on a wide grid area) and whether every
* cell clears the minimum tile size.
* - `tileGridCapacity`: how many tiles a grid area can hold.
* - `sanitizeTileGridState`: the stored `codeman:tile-grid` value made safe to
* apply (unknown, deleted, detached and duplicate ids dropped); the stored
* `ids` are the grid's cells, `null` for a hole, and come back as `cells`
* (holes kept, a dropped id a hole) beside the packed `ids`. The old packed
* format reads as cells with no hole.
* - `fitTileCells`: the cells after a shape change (owner: an empty cell can be
* any cell). Same shape: unchanged; a new one: each tile keeps its row and
* column when all fit, else the tiles pack in reading order.
* - `tileNeighbor`, `tileInDirection`, `cycleTile`: which tile takes focus when
* one leaves, on a directional chord (over cells: never onto a hole), and on
* Ctrl+Tab / Alt+[ ]. `tileCellInDirection`: the adjacent cell a Move Tile
* chord moves into or swaps with.
*
* Loaded via `vm` like split-pane-helpers.test.ts. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Layout = { cols: number; rows: number; fits: boolean };
type TileGrid = {
computeTileLayout(p: Record<string, number>): Layout;
tileGridCapacity(p: Record<string, number>): number;
sanitizeTileGridState(raw: unknown, live: unknown, detached?: Set<string>): Record<string, unknown> | null;
tileNeighbor(ids: string[], id: string): string | null;
tileInDirection(cells: Array<string | null>, focused: string, dir: string, cols: number): string | null;
tileCellInDirection(index: number, dir: string, cols: number, cellCount: number): number;
fitTileCells(cells: Array<string | null>, oldCols: number, cols: number, rows: number): Array<string | null>;
cycleTile(ids: string[], focused: string, delta: number): string | null;
TILE_GRID_MAX: number;
TILE_LAYOUT_MAX: number;
TILE_MIN_W: number;
TILE_MIN_H: number;
TILE_SCROLLBACK: number;
};
function loadTileGrid(): TileGrid {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanTileGrid: TileGrid }).CodemanTileGrid;
}
const T = loadTileGrid();
// Plenty of room: every layout fits.
const BIG = { width: 3000, height: 2000 };
describe('computeTileLayout', () => {
it.each([
[1, 1, 1],
[2, 2, 1],
[4, 2, 2],
[5, 3, 2],
[6, 3, 2],
[7, 3, 3],
[8, 3, 3],
[9, 3, 3],
])('%i tiles lay out as %ix%i', (count, cols, rows) => {
expect(T.computeTileLayout({ count, ...BIG })).toMatchObject({ cols, rows, fits: true });
});
it('puts 3 tiles side by side only on a grid area at least 1800px wide', () => {
expect(T.computeTileLayout({ count: 3, width: 1800, height: 900 })).toMatchObject({ cols: 3, rows: 1 });
expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 });
});
it('lays out up to 9 (past the cap, unreachable but kept) and treats nothing as an empty grid', () => {
expect(T.TILE_LAYOUT_MAX).toBe(9);
expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 });
expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 });
});
it('reports whether every cell clears the minimum tile size', () => {
// 3x2 needs 3 * 480 = 1440 wide and 2 * 240 = 480 high.
expect(T.computeTileLayout({ count: 6, width: 1440, height: 480 }).fits).toBe(true);
expect(T.computeTileLayout({ count: 6, width: 1439, height: 480 }).fits).toBe(false);
expect(T.computeTileLayout({ count: 6, width: 1440, height: 479 }).fits).toBe(false);
});
});
describe('tileGridCapacity', () => {
it('never holds more than the cap of 6 (owner decision 7), even where nine would fit', () => {
expect(T.TILE_GRID_MAX).toBe(6);
expect(T.computeTileLayout({ count: 9, ...BIG }).fits).toBe(true);
expect(T.tileGridCapacity(BIG)).toBe(6);
});
it('stops at the first count whose layout does not fit', () => {
// 1440 x 600: 3x2 fits (480 x 300) but 3x3 (480 x 200) does not.
expect(T.tileGridCapacity({ width: 1440, height: 600 })).toBe(6);
// 1200 x 900: 2x2 fits (600 x 450), 3x2 does not (400 wide).
expect(T.tileGridCapacity({ width: 1200, height: 900 })).toBe(4);
// 1000 x 400: 2x1 fits (500 x 400); three tiles take a 2x2 (200 high), which does not.
expect(T.tileGridCapacity({ width: 1000, height: 400 })).toBe(2);
});
it('is 0 when not even one tile fits', () => {
expect(T.tileGridCapacity({ width: 400, height: 900 })).toBe(0);
});
});
describe('sanitizeTileGridState', () => {
const live = new Map([
['a', {}],
['b', {}],
['c', {}],
['d', {}],
]);
it('keeps a valid stored grid as it is', () => {
const raw = { v: 1, open: true, ids: ['a', 'b'], focused: 'b', zoomed: 'a', colFr: [1, 2], rowFr: [1] };
expect(T.sanitizeTileGridState(raw, live, new Set())).toEqual({
v: 1,
open: true,
ids: ['a', 'b'],
cells: ['a', 'b'],
focused: 'b',
zoomed: 'a',
colFr: [1, 2],
rowFr: [1],
});
});
it('accepts the stored JSON string', () => {
const raw = JSON.stringify({ v: 1, open: true, ids: ['c'], focused: 'c' });
expect(T.sanitizeTileGridState(raw, live)?.ids).toEqual(['c']);
});
it('drops unknown (deleted), detached and duplicate ids', () => {
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 'c', 7, ''], focused: 'a' };
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
expect(out?.ids).toEqual(['a', 'c']);
});
it('moves focus to the first kept tile when the focused one was dropped, and drops a dropped zoom', () => {
const raw = { v: 1, open: true, ids: ['gone', 'b', 'c'], focused: 'gone', zoomed: 'gone' };
const out = T.sanitizeTileGridState(raw, live);
expect(out?.focused).toBe('b');
expect(out?.zoomed).toBeNull();
});
it('is closed when no tile survives', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['gone'] }, live);
expect(out).toMatchObject({ open: false, ids: [], focused: null });
});
it('caps the list at the cap (6): the extras are dropped', () => {
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
expect(out?.ids).toEqual(many.slice(0, 6));
});
it('a stored 3x3 keeps its focus if it survives the cap, and loses a zoom that did not', () => {
const nine = Array.from({ length: 9 }, (_, i) => `s${i}`);
const kept = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's4', zoomed: 's7' }, nine);
expect(kept).toMatchObject({ ids: nine.slice(0, 6), focused: 's4', zoomed: null });
const lost = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's8', zoomed: 's2' }, nine);
expect(lost).toMatchObject({ focused: 's0', zoomed: 's2' });
});
it('drops malformed track fractions', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, live);
expect(out?.colFr).toBeNull();
expect(out?.rowFr).toBeNull();
});
it('stored cells keep their holes; the list beside them is packed', () => {
const raw = { v: 1, open: true, ids: ['a', null, 'b', 'c', 'd', null], focused: 'c' };
const out = T.sanitizeTileGridState(raw, live);
expect(out?.cells).toEqual(['a', null, 'b', 'c', 'd', null]);
expect(out?.ids).toEqual(['a', 'b', 'c', 'd']);
expect(out?.focused).toBe('c');
});
it('a dropped id (gone, detached, a duplicate, malformed) leaves a hole where it was, never a shift', () => {
const raw = { v: 1, open: true, ids: ['a', 'gone', 'b', 'a', 7, 'c'] };
const out = T.sanitizeTileGridState(raw, live, new Set(['b']));
expect(out?.cells).toEqual(['a', null, null, null, null, 'c']);
expect(out?.ids).toEqual(['a', 'c']);
});
it('the old packed format reads unchanged: cells with no hole', () => {
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a', 'b', 'c'] }, live);
expect(out?.cells).toEqual(['a', 'b', 'c']);
expect(out?.ids).toEqual(['a', 'b', 'c']);
});
it('past the cap a tile becomes a hole; no more cells than the largest layout', () => {
const many = Array.from({ length: 12 }, (_, i) => `s${i}`);
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many);
expect(out?.cells).toEqual([...many.slice(0, 6), null, null, null]);
});
it.each([null, 'not json', '[]', 42, { v: 2, ids: ['a'] }, { ids: ['a'] }])('rejects %j', (raw) => {
expect(T.sanitizeTileGridState(raw, live)).toBeNull();
});
});
describe('focus helpers', () => {
it('tileNeighbor prefers the next tile, then the previous one', () => {
expect(T.tileNeighbor(['a', 'b', 'c'], 'b')).toBe('c');
expect(T.tileNeighbor(['a', 'b', 'c'], 'c')).toBe('b');
expect(T.tileNeighbor(['a'], 'a')).toBeNull();
});
it('tileInDirection moves within a row-major grid', () => {
// 3x2: a b c
// d e
const ids = ['a', 'b', 'c', 'd', 'e'];
expect(T.tileInDirection(ids, 'b', 'left', 3)).toBe('a');
expect(T.tileInDirection(ids, 'a', 'left', 3)).toBeNull();
expect(T.tileInDirection(ids, 'b', 'right', 3)).toBe('c');
expect(T.tileInDirection(ids, 'c', 'right', 3)).toBeNull();
expect(T.tileInDirection(ids, 'e', 'right', 3)).toBeNull();
expect(T.tileInDirection(ids, 'd', 'up', 3)).toBe('a');
expect(T.tileInDirection(ids, 'a', 'up', 3)).toBeNull();
expect(T.tileInDirection(ids, 'b', 'down', 3)).toBe('e');
// Nothing below c in that column: the short last row's last tile.
expect(T.tileInDirection(ids, 'c', 'down', 3)).toBe('e');
expect(T.tileInDirection(ids, 'e', 'down', 3)).toBeNull();
});
it('tileInDirection never lands on a hole: along a row it skips one, never leaving the row', () => {
// 3x2: a _ c
// d e f
const cells = ['a', null, 'c', 'd', 'e', 'f'];
expect(T.tileInDirection(cells, 'a', 'right', 3)).toBe('c');
expect(T.tileInDirection(cells, 'c', 'left', 3)).toBe('a');
expect(T.tileInDirection(cells, 'c', 'right', 3)).toBeNull();
expect(T.tileInDirection(cells, 'd', 'left', 3)).toBeNull();
// Up from e (a hole above): the nearest tile in that row, the lower column on a tie.
expect(T.tileInDirection(cells, 'e', 'up', 3)).toBe('a');
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('d');
});
it('tileInDirection: up and down go to the nearest row that has a tile, even across a column', () => {
// 3x2: a b c
// _ _ f
const cells = ['a', 'b', 'c', null, null, 'f'];
expect(T.tileInDirection(cells, 'a', 'down', 3)).toBe('f');
expect(T.tileInDirection(cells, 'c', 'down', 3)).toBe('f');
expect(T.tileInDirection(cells, 'f', 'up', 3)).toBe('c');
expect(T.tileInDirection(cells, 'f', 'left', 3)).toBeNull();
// A row with no tile at all is passed over (a 3x3, which the layout table keeps).
const tall = ['a', null, null, null, null, null, null, 'h', null];
expect(T.tileInDirection(tall, 'a', 'down', 3)).toBe('h');
expect(T.tileInDirection(tall, 'h', 'up', 3)).toBe('a');
// A hole is never the focused cell either.
expect(T.tileInDirection(cells, null as unknown as string, 'up', 3)).toBeNull();
});
it('tileCellInDirection: the adjacent cell, or -1 at the edge', () => {
// 3x2 cells 0 1 2 / 3 4 5
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'left', 3, 6))).toEqual([-1, 0, 1, -1, 3, 4]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'right', 3, 6))).toEqual([1, 2, -1, 4, 5, -1]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'up', 3, 6))).toEqual([-1, -1, -1, 0, 1, 2]);
expect([0, 1, 2, 3, 4, 5].map((i) => T.tileCellInDirection(i, 'down', 3, 6))).toEqual([3, 4, 5, -1, -1, -1]);
// 2x2
expect([0, 1, 2, 3].map((i) => T.tileCellInDirection(i, 'down', 2, 4))).toEqual([2, 3, -1, -1]);
expect(T.tileCellInDirection(7, 'left', 3, 6)).toBe(-1);
});
it('cycleTile wraps in reading order', () => {
expect(T.cycleTile(['a', 'b', 'c'], 'c', 1)).toBe('a');
expect(T.cycleTile(['a', 'b', 'c'], 'a', -1)).toBe('c');
expect(T.cycleTile([], 'a', 1)).toBeNull();
});
});
describe('fitTileCells (the cells after a shape change)', () => {
it('the same shape keeps every cell, holes included', () => {
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', 'f'], 3, 3, 2)).toEqual(['a', null, 'c', 'd', 'e', 'f']);
});
it('a first layout (no columns yet) packs and pads', () => {
expect(T.fitTileCells(['a', 'b', 'c', 'd', 'e'], 0, 3, 2)).toEqual(['a', 'b', 'c', 'd', 'e', null]);
expect(T.fitTileCells(['a', 'b', 'c'], 0, 2, 2)).toEqual(['a', 'b', 'c', null]);
});
it('growing 2x2 to 3x2: every tile keeps its row and column', () => {
expect(T.fitTileCells(['a', 'b', 'c', 'd'], 2, 3, 2)).toEqual(['a', 'b', null, 'c', 'd', null]);
// 2x1 to 2x2 likewise (the same as packing there).
expect(T.fitTileCells(['a', 'b'], 2, 2, 2)).toEqual(['a', 'b', null, null]);
});
it('shrinking: kept where every tile fits, else packed in reading order', () => {
// 3x2 -> 2x2 with the third column empty: the tiles stay put.
expect(T.fitTileCells(['a', 'b', null, 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'b', 'd', 'e']);
// A tile in the third column: packed, holes collapsed.
expect(T.fitTileCells(['a', null, 'c', 'd', 'e', null], 3, 2, 2)).toEqual(['a', 'c', 'd', 'e']);
// 2x2 -> 3x1 (a wider window, three tiles): row 1 does not fit, packed.
expect(T.fitTileCells(['a', null, 'c', 'd'], 2, 3, 1)).toEqual(['a', 'c', 'd']);
});
});
describe('dragTrackFractions (divider drags)', () => {
const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) =>
(T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min);
const px = (fr: number[], total = 1200) => fr.map((f) => Math.round((f / fr.reduce((a, b) => a + b, 0)) * total));
it('moves size from one neighbour to the other, the rest untouched', () => {
// 3 equal tracks of 400px; divider 0 moves 100px right.
expect(px(drag([1, 1, 1], 0, 100))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 1, -50))).toEqual([400, 350, 450]);
});
it('clamps both neighbours to the minimum size', () => {
expect(px(drag([1, 1, 1], 0, 300))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 0, -300))).toEqual([300, 500, 400]);
});
it('a pair too small for two minimums splits evenly instead of inverting', () => {
expect(px(drag([1, 1], 0, 200, 500, 300), 500)).toEqual([250, 250]);
});
it('works from the fractions the drag started with (no drift)', () => {
const start = [2, 1];
expect(px(drag(start, 0, 0))).toEqual([800, 400]);
expect(start).toEqual([2, 1]);
});
it('ignores a divider that is not between two tracks', () => {
expect(drag([1, 1], 1, 100)).toEqual([1, 1]);
});
});
describe('tile constants', () => {
it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => {
expect(T.TILE_SCROLLBACK).toBe(10000);
});
});
+432
View File
@@ -0,0 +1,432 @@
/**
* @fileoverview Every capture a grid tile fetches goes through ONE queue.
*
* `GET /api/sessions/:id/terminal` runs synchronous tmux calls on the server, so
* N tiles loading at once do not load in parallel: they stall every WebSocket
* and SSE stream on the server back to back. The grid therefore hands each
* TerminalTile a `scheduleLoad` (a TileLoadQueue, terminal-tile.js) and the
* tile routes EVERY capture through it: the initial load, the refresh after a
* reconnect, a server `{t:'r'}` refresh and the shell history pull.
*
* Pinned here, with `connect()` and the socket handlers running for real:
* - at most one `/terminal` fetch is in flight at a time, for initial loads and
* for N tiles reconnecting together;
* - the focused tile goes first, then reading order, and a history pull (the
* user is waiting on it) jumps ahead of background refreshes;
* - `{t:'r'}` goes through the same queue, and a tile waiting its turn keeps its
* last frame (the clear happens at its turn);
* - a destroyed tile's queued load is dropped, and destroying the tile whose
* load is running aborts its fetch so the queue moves on;
* - a load that never answers is cut off by its deadline;
* - a close while a load only WAITS writes the disconnected marker at once;
* - grid tiles load a bounded window and keep TILE_SCROLLBACK lines.
*
* Real code under test: constants.js + app.js + terminal-ui.js +
* terminal-tile.js in one `vm` context; xterm, the fit addon and WebSocket are
* fakes (test/mocks/terminal-tile-fakes.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
/** One `/terminal` fetch the test answers (or lets hang) by hand. */
type Capture = { url: string; settled: boolean; aborted: boolean; answer(body: string): void };
let captures: Capture[] = [];
const fetchMock = vi.fn((url: string, init?: { signal?: AbortSignal }) => {
return new Promise((resolveFetch, rejectFetch) => {
const capture: Capture = {
url,
settled: false,
aborted: false,
answer(body: string) {
capture.settled = true;
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: body } }) });
},
};
init?.signal?.addEventListener('abort', () => {
capture.settled = true;
capture.aborted = true;
rejectFetch(new Error('aborted'));
});
captures.push(capture);
});
});
const inFlight = () => captures.filter((c) => !c.settled).length;
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
AbortController,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
// Late-bound so vi.useFakeTimers() reaches code running in this context.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: FakeSocket,
Terminal: FakeTerminal,
FitAddon: { FitAddon: FakeFit },
fetch: (...args: Parameters<typeof fetchMock>) => fetchMock(...args),
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number };
const TAIL = 1024 * 1024;
// A grid tile's full captures read no more tmux history than its xterm keeps:
// TILE_SCROLLBACK plus the screen (the fake terminal has 24 rows).
const LINES = `&lines=${TileGrid.TILE_SCROLLBACK + 24}`;
type Tile = {
sessionId: string;
connect(): Promise<void>;
destroy(): void;
reconnectNow(): void;
_maybeLoadMoreHistory(): void;
_destroyed: boolean;
terminal: FakeTerminal | null;
ws: FakeSocket | null;
};
type Queue = {
schedule(tile: object, kind: string, run: () => Promise<void>): Promise<void>;
size: number;
activeTile: object | null;
};
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
const TileLoadQueue = windowStub.TileLoadQueue as new (opts?: object) => Queue;
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, unknown>;
app._clientId = 'c-test';
app._wsTabNonce = 'nonce-1';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._extraInputSockets = new Map();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.loadAppSettingsFromStorage = () => ({});
app._estimateReplayRows = (text: string) => text.split('\n').length;
return app;
}
const liveTiles: Tile[] = [];
/** Grid-style tiles sharing one queue: the focused id ranks first, then the order given. */
function makeGrid(ids: string[], { focused = ids[0], modes = {} as Record<string, string> } = {}) {
windowStub.app = makeApp();
const order = [...ids];
const states: Array<[string, string]> = [];
const queue = new TileLoadQueue({
rank: (tile: Tile) => (tile.sessionId === focused ? -1 : order.indexOf(tile.sessionId)),
onChange: (tile: Tile, state: string) => states.push([tile.sessionId, state]),
});
const tiles = ids.map((id) => {
const tile = new TerminalTile(
id,
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
{
mode: modes[id] ?? 'claude',
scheduleLoad: (t: object, kind: string, run: () => Promise<void>) => queue.schedule(t, kind, run),
scrollback: TileGrid.TILE_SCROLLBACK,
fontSize: 13,
boundedLoad: true,
}
);
liveTiles.push(tile);
return tile;
});
return { tiles, queue, states };
}
/** Let promise chains (fetch, json, chunked write, queue pump) run. */
async function settle() {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
/** Answer every capture as it arrives, one at a time, asserting the queue never overlaps two. */
async function drain(body = 'frame') {
let max = 0;
for (let guard = 0; guard < 50; guard++) {
await settle();
max = Math.max(max, inFlight());
const open = captures.find((c) => !c.settled);
if (!open) break;
open.answer(body);
}
return max;
}
beforeEach(() => {
captures = [];
fetchMock.mockClear();
FakeSocket.instances = [];
});
afterEach(() => {
for (const tile of liveTiles.splice(0)) tile.destroy();
vi.useRealTimers();
});
describe('initial loads', () => {
it('N tiles connecting together fetch one capture at a time', async () => {
const { tiles } = makeGrid(['a', 'b', 'c', 'd']);
const connecting = tiles.map((t) => t.connect());
await settle();
expect(inFlight()).toBe(1);
expect(await drain()).toBe(1);
await Promise.all(connecting);
expect(captures).toHaveLength(4);
});
it('runs the focused tile first, then reading order', async () => {
const { tiles } = makeGrid(['a', 'b', 'c', 'd'], { focused: 'c' });
const connecting = tiles.map((t) => t.connect());
await drain();
await Promise.all(connecting);
// `a` was already running when the others arrived; then focus, then order.
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c', 'b', 'd']);
});
it('reports each tile queued, then running, then idle (the quiet loading state)', async () => {
const { tiles, states } = makeGrid(['a', 'b']);
const connecting = tiles.map((t) => t.connect());
await drain();
await Promise.all(connecting);
expect(states.filter(([id]) => id === 'b').map(([, s]) => s)).toEqual(['queued', 'running', 'idle']);
});
it('loads a bounded window: full=1&tail= for a TUI, tail= for a shell', async () => {
const { tiles } = makeGrid(['tui', 'sh'], { modes: { sh: 'shell' } });
const connecting = tiles.map((t) => t.connect());
await drain();
await Promise.all(connecting);
expect(captures.map((c) => c.url)).toEqual([
`/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/sh/terminal?tail=${TAIL}`,
]);
});
it('keeps TILE_SCROLLBACK lines and the tile font size', async () => {
const { tiles } = makeGrid(['a']);
const connecting = tiles[0].connect();
await drain();
await connecting;
expect(tiles[0].terminal?.options.scrollback).toBe(10000);
expect(tiles[0].terminal?.options.fontSize).toBe(13);
});
});
/** Connects every tile and opens its socket, with the queue drained. */
async function connectAll(tiles: Tile[]) {
const connecting = tiles.map((t) => t.connect());
await drain('first');
await Promise.all(connecting);
for (const tile of tiles) tile.ws?.open();
captures = [];
}
describe('refreshes', () => {
it('N tiles reconnecting together (a deploy restart) refresh one at a time', async () => {
const { tiles } = makeGrid(['a', 'b', 'c', 'd', 'e', 'f']);
await connectAll(tiles);
for (const tile of tiles) tile.ws?.drop(1006);
for (const tile of tiles) {
tile.reconnectNow();
tile.ws?.open();
}
await settle();
expect(inFlight()).toBe(1);
expect(await drain('after')).toBe(1);
expect(captures).toHaveLength(6);
});
it('a server {t:"r"} refresh waits its turn behind another tile, keeping its last frame meanwhile', async () => {
const { tiles } = makeGrid(['a', 'b']);
await connectAll(tiles);
const [a, b] = tiles;
a.ws?.receive({ t: 'r' });
b.ws?.receive({ t: 'r' });
await settle();
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
// b has not been cleared: it shows its last frame until its load runs.
expect(b.terminal?.writes).not.toContain('<CLEAR>');
await drain('fresh');
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
expect(b.terminal?.writes.slice(-2)).toEqual(['<CLEAR>', 'fresh']);
});
it('a history pull jumps ahead of background refreshes', async () => {
const { tiles } = makeGrid(['a', 'b', 'sh'], { modes: { sh: 'shell' } });
await connectAll(tiles);
const [a, b, sh] = tiles;
a.ws?.receive({ t: 'r' });
b.ws?.receive({ t: 'r' });
sh._maybeLoadMoreHistory();
await settle();
expect(inFlight()).toBe(1);
await drain();
expect(captures.map((c) => c.url)).toEqual([
`/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/sh/terminal?full=1&tail=${TAIL}${LINES}`,
`/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`,
]);
});
it('a close while the refresh only WAITS writes the marker at once, and once', async () => {
const { tiles } = makeGrid(['a', 'b']);
await connectAll(tiles);
const [a, b] = tiles;
a.ws?.receive({ t: 'r' });
b.ws?.receive({ t: 'r' });
await settle();
b.ws?.drop(1006);
const markers = () => (b.terminal?.writes ?? []).filter((w) => w.includes('[disconnected')).length;
expect(markers()).toBe(1);
await drain('fresh');
// Its turn cleared the screen, so the marker is written again below the replay: one on screen.
const writes = b.terminal?.writes ?? [];
expect(writes.slice(writes.lastIndexOf('<CLEAR>'))).toEqual([
'<CLEAR>',
'fresh',
expect.stringContaining('[disconnected'),
]);
});
});
describe('teardown', () => {
it("drops a destroyed tile's queued load: it never fetches", async () => {
const { tiles, queue } = makeGrid(['a', 'b', 'c']);
const connecting = tiles.map((t) => t.connect());
await settle();
tiles[1].destroy();
await drain();
await Promise.all(connecting);
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'c']);
expect(queue.size).toBe(0);
expect(tiles[1].ws).toBeNull();
});
it('destroying the tile whose load is running aborts its fetch and the queue moves on', async () => {
const { tiles } = makeGrid(['a', 'b']);
const connecting = tiles.map((t) => t.connect());
await settle();
tiles[0].destroy();
await settle();
expect(captures[0].aborted).toBe(true);
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
await drain();
await Promise.all(connecting);
});
it('destroying a tile while xterm still parses its replay settles the load, and the queue moves on', async () => {
// The replay waits for xterm's write callback (writeChunked), which a
// disposed xterm never runs: unsettled, the tile's load would hold the
// grid's one queue, and every other tile behind it, forever.
const { tiles } = makeGrid(['a', 'b']);
const connecting = tiles.map((t) => t.connect());
await settle();
tiles[0].terminal!.holdParse = true;
captures[0].answer('replay of a');
await settle();
// a's replay is still parsing: b waits its turn.
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a']);
tiles[0].destroy();
await settle();
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
captures[1].answer('b');
await settle();
await Promise.all(connecting);
});
it('a capture that never answers is cut off by its deadline, and the next tile loads', async () => {
vi.useFakeTimers();
const { tiles } = makeGrid(['a', 'b']);
const connecting = tiles.map((t) => t.connect());
await settle();
expect(captures).toHaveLength(1);
// The full-capture budget (CodemanFetchDeadline, 45 s for a TUI).
await vi.advanceTimersByTimeAsync(45_000);
await settle();
expect(captures[0].aborted).toBe(true);
expect(captures.map((c) => c.url.split('/')[3])).toEqual(['a', 'b']);
captures[1].answer('b');
await settle();
await Promise.all(connecting);
});
});
describe('TileLoadQueue on its own', () => {
it('never rejects, and moves on when a load throws', async () => {
const queue = new TileLoadQueue();
const order: string[] = [];
const first = queue.schedule({}, 'initial', async () => {
order.push('first');
throw new Error('boom');
});
const second = queue.schedule({}, 'initial', async () => {
order.push('second');
});
await expect(first).resolves.toBeUndefined();
await expect(second).resolves.toBeUndefined();
expect(order).toEqual(['first', 'second']);
expect(queue.activeTile).toBeNull();
});
it('resolves, but never runs, a load whose tile was destroyed while it waited', async () => {
const queue = new TileLoadQueue();
let release: () => void = () => {};
const busy = queue.schedule({}, 'initial', () => new Promise<void>((r) => (release = r)));
const gone = { _destroyed: false };
const run = vi.fn(async () => {});
const waiting = queue.schedule(gone, 'refresh', run);
gone._destroyed = true;
release();
await busy;
await expect(waiting).resolves.toBeUndefined();
expect(run).not.toHaveBeenCalled();
expect(queue.size).toBe(0);
});
it('drop() resolves every load still waiting for a tile', async () => {
const queue = new TileLoadQueue();
let release: () => void = () => {};
const busy = queue.schedule({}, 'initial', () => new Promise<void>((r) => (release = r)));
const tile = {};
const run = vi.fn(async () => {});
const waiting = queue.schedule(tile, 'refresh', run);
(queue as unknown as { drop(t: object): void }).drop(tile);
await expect(waiting).resolves.toBeUndefined();
release();
await busy;
expect(run).not.toHaveBeenCalled();
});
});
+310
View File
@@ -0,0 +1,310 @@
/**
* @fileoverview The grid's open and close animation (owner request: "when
* clicking on the tile button first make this animation nicer").
*
* - Opening: every tile mounts with `.tile--entering` and its place in reading
* order (`--tile-enter-index`, the CSS stagger), cleared by its own
* `tile-enter` animationend only. Its terminal stays transparent
* (`.tile--revealing`) until the load queue reports its first capture done,
* with a backstop timer.
* - The animation adds no fit and no PTY resize: a grid opened with motion
* fits and connects its tiles exactly as one opened under reduced motion.
* - Closing with the Tiles toggle (button, Ctrl+Shift+G; owner answer 4): a
* still copy of the tiles (clones, no TerminalTile) dims over the stage and
* waits until the single view's selection has settled, at most 700 ms, then
* fades and removes itself (animationend, or a fallback timer). Every other
* close cuts as before. A reopen drops a copy still showing.
* - Re-forming to another count: the old grid's copy fades at once, the new
* tiles enter staggered, the tiles that stay do not.
* - Reduced motion: no entrance, no copy, no stagger property.
* - The CSS: every new keyframe animates opacity and transform only, all of
* it stops under prefers-reduced-motion, and a web tab hides the copy.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
bySelector,
flushFrames,
main,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
const end = (el: FakeEl, animationName: string, target: FakeEl = el) => el.dispatch('animationend', { target, animationName });
function reducedMotion(on: boolean) {
windowStub.matchMedia = (q: string) => ({ matches: on && q.includes('reduce'), addEventListener: vi.fn() });
}
beforeEach(() => {
resetGridHarness();
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid';
bySelector.set('.btn-tile-grid', btn);
});
afterEach(() => {
vi.useRealTimers();
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
describe('opening', () => {
it('each tile enters, staggered in reading order', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 4), { focusedId: 's-c' });
expect(tiles().map((t) => t.classList.contains('tile--entering'))).toEqual([true, true, true, true]);
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
});
it('a tile stops entering on its own tile-enter animationend, not on a child or another animation', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const el = tiles()[0];
end(el, 'tile-needs-pulse');
end(el, 'tile-enter', new FakeEl());
expect(el.classList.contains('tile--entering')).toBe(true);
end(el, 'tile-enter');
expect(el.classList.contains('tile--entering')).toBe(false);
expect(tiles()[1].classList.contains('tile--entering')).toBe(true);
});
it('a terminal stays transparent until the load queue reports its first capture done', () => {
// A queue that hands the grid's own state callback to the test.
const Queue = windowStub.TileLoadQueue;
let report: (tile: unknown, state: string) => void = () => {};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
report = opts.onChange;
}
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
try {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
app._tileLoadQueue();
expect(tiles().every((t) => t.classList.contains('tile--revealing'))).toBe(true);
const tileA = app._tileFor('s-a');
report(tileA, 'queued');
report(tileA, 'running');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
report(tileA, 'idle');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
expect(tiles()[1].classList.contains('tile--revealing')).toBe(true);
// A later load (a reconnect refresh) never hides it again.
report(tileA, 'running');
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
} finally {
windowStub.TileLoadQueue = Queue;
}
});
it('a backstop timer shows a terminal whose load never reports back', () => {
vi.useFakeTimers();
const app = gridApp();
app.openTileGrid(['s-a']);
vi.advanceTimersByTime(14_999);
expect(tiles()[0].classList.contains('tile--revealing')).toBe(true);
vi.advanceTimersByTime(1);
expect(tiles()[0].classList.contains('tile--revealing')).toBe(false);
});
it('adds no fit and no PTY resize: tiles fit and connect exactly as under reduced motion', () => {
vi.useFakeTimers();
const counts = (motion: boolean) => {
resetGridHarness();
reducedMotion(!motion);
const app = gridApp();
app.openTileGrid(IDS);
flushFrames();
vi.advanceTimersByTime(1000);
return FakeTile.all.map((t) => [t.connect.mock.calls.length, t.fit.mock.calls.length, t.localFit.mock.calls.length]);
};
const withMotion = counts(true);
const without = counts(false);
expect(withMotion).toEqual(without);
// One connect per tile (its one resize goes out when its socket opens).
expect(withMotion.every(([connects]) => connects === 1)).toBe(true);
});
it('a tile added later enters too; one dropped on a tile enters in its place', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
for (const t of tiles()) end(t, 'tile-enter');
app.addTile('s-c');
const added = tiles().find((t) => t.dataset.sessionId === 's-c')!;
expect(added.classList.contains('tile--entering')).toBe(true);
expect(tiles().filter((t) => t.classList.contains('tile--entering'))).toHaveLength(1);
});
});
describe('closing with the Tiles toggle', () => {
it('leaves a still copy of the tiles over the stage: clones, inert, no terminals', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
const before = FakeTile.all.length;
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(ghosts()).toHaveLength(1);
const layer = ghosts()[0];
expect(layer.inert).toBe(true);
expect(layer.attrs['aria-hidden']).toBe('true');
expect(layer.children.map((g) => g.dataset.sessionId)).toEqual(['s-a', 's-b', 's-c']);
expect(layer.children.every((g) => g.classList.contains('tile--leaving'))).toBe(true);
expect(layer.children.some((g) => g.classList.contains('tile--entering'))).toBe(false);
// Copies only: no TerminalTile was made for them, the real ones are gone.
expect(FakeTile.all).toHaveLength(before);
expect(FakeTile.all.every((t) => t._destroyed)).toBe(true);
expect(tiles()).toHaveLength(0);
});
it('holds until the single view has its content, then fades and goes', async () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
let settle: () => void = () => {};
app.selectSession = vi.fn(() => new Promise<void>((r) => (settle = r)));
app.toggleTileGrid();
const layer = ghosts()[0];
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
settle();
// The selection's promise comes from another realm (the test's), so its
// settling reaches the grid after a few microtasks: a macrotask covers them.
await new Promise((r) => setTimeout(r, 0));
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
end(layer, 'tile-leave', layer.children[0]);
expect(ghosts()).toHaveLength(1);
end(layer, 'tile-leave', layer.lastElementChild!);
expect(ghosts()).toHaveLength(0);
});
it('a single view that never settles still gets it gone: 700 ms, then the fade fallback', () => {
vi.useFakeTimers();
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
const layer = ghosts()[0];
vi.advanceTimersByTime(699);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(false);
vi.advanceTimersByTime(1);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
vi.advanceTimersByTime(450);
expect(ghosts()).toHaveLength(0);
});
it('a zoomed grid leaves a copy of the zoomed tile only', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
app.zoomTile('s-b');
app.toggleTileGrid();
expect(ghosts()[0].children.map((g) => g.dataset.sessionId)).toEqual(['s-b']);
});
it('every other close cuts as before: no copy', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.closeTileGrid({ reselect: false });
expect(ghosts()).toHaveLength(0);
app.openTileGrid(IDS.slice(0, 2));
// A user pick of a tab that is not tiled leaves the grid (decision 1).
app.closeTileGrid({ keepStored: true });
expect(ghosts()).toHaveLength(0);
});
it('reopening drops a copy still showing', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 2));
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
expect(ghosts()).toHaveLength(1);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app.toggleTileGrid();
expect(ghosts()).toHaveLength(0);
expect(app._tilesOwnTerminal()).toBe(true);
});
});
describe('re-forming to another count', () => {
it('the old grid fades at once, the new tiles enter staggered, the ones that stay do not', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
for (const t of tiles()) end(t, 'tile-enter');
app.openTileCountMenu({ preventDefault: vi.fn() });
const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
menu.children.find((c) => c.dataset.count === '6')!.dispatch('click', { stopPropagation: vi.fn() });
const layer = ghosts()[0];
expect(layer.classList.contains('tile-grid-ghosts--now')).toBe(true);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
const entering = tiles().filter((t) => t.classList.contains('tile--entering'));
expect(entering.map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1', '2', '3']);
expect(entering.map((t) => t.dataset.sessionId)).not.toContain('s-a');
});
});
describe('reduced motion', () => {
it('no entrance, no stagger property, no copy on close', () => {
reducedMotion(true);
const app = gridApp();
app.openTileGrid(IDS.slice(0, 3));
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
expect(tiles().some((t) => '--tile-enter-index' in t.style)).toBe(false);
app.toggleTileGrid();
expect(ghosts()).toHaveLength(0);
});
});
describe('the CSS', () => {
const keyframes = (name: string) => {
const m = css.match(new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`));
return m ? m[1] : '';
};
it('every new keyframe animates opacity and transform only', () => {
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
const body = keyframes(name);
expect(body, name).not.toBe('');
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
expect(props.length, name).toBeGreaterThan(0);
expect(props.filter((p) => p !== 'opacity' && p !== 'transform'), name).toEqual([]);
}
});
it('the terminal reveal is an opacity transition only', () => {
expect(css).toMatch(/\.tile-body \.xterm \{\s*transition: opacity 160ms ease-out;\s*\}/);
expect(css).toMatch(/\.tile\.tile--revealing \.tile-body \.xterm \{\s*opacity: 0;\s*\}/);
});
it('stays within about 150 to 300 ms: 180 ms per tile, 24 ms apart, the last of six done at 300', () => {
expect(css).toMatch(/\.tile\.tile--entering \{\s*animation: tile-enter 180ms [^;]+;\s*animation-delay: calc\(var\(--tile-enter-index, 0\) \* 24ms\);/);
});
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
const reduced = block.slice(0, block.indexOf('\n}\n'));
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
expect(reduced).toContain(sel);
}
expect(reduced).toMatch(/animation: none;/);
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
});
});
+790
View File
@@ -0,0 +1,790 @@
/**
* @fileoverview Moving tiles (owner request): a tile dragged by its header
* onto another tile trades places with it.
*
* - One path for every move (`_reorderTiles`): the header drag, a tab dragged
* onto a tile or a slot, the Move Tile chords. Nothing is remounted,
* reconnected or reloaded (no new TerminalTile, no `connect()`, no fetch),
* no session joins or leaves, the grid stays open, the order is persisted.
* - Divider sizes belong to the cells: a tile whose cell size changed fits
* once (one PTY resize, #464), every other tile is left alone.
* - An empty slot refuses a tile (its header drag or its tab): owner, "dont
* move the tile" (a slot is always the last cell, so a move there shifted
* every tile after it). A session not tiled yet still joins there.
* - The header focuses its tile on click, never on a press (the body keeps
* press-to-focus), so a drag that is cancelled changes nothing, focus and
* idle alert included (owner: best practice).
* - The header drag is native: Escape or a drop anywhere else ends in a
* `dragend` with no drop, which moves nothing and clears what the drag
* painted. It carries a type of its own (never text) and is not
* `draggedTabId`, so neither a text field nor the tab strip takes it. The
* drop targets hold it in the capture phase, before xterm.
* - The handle is the header's free area: a press on a button or the rename
* input starts no drag, and a double-click on the name still renames.
* - Moving is off while a tile is zoomed (the header drag, a tab drag of a
* tiled session, the chords) and with a single tile.
* - Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows): the focused tile trades
* places with the neighbour the Alt+Shift+Arrow focus chords pick, checked
* against hand-written tables for every cell and direction of 2x1, 2x2, 3x2
* and the partial 2x2 and 3x2; focus stays on the moved tile. The chords
* apply (and are swallowed) only while the grid is open, zoomed included as a
* no-op, and never in a text field, where Ctrl+Shift+Arrows select by word
* (the focus chords skip text fields too: tile-grid-shortcuts.test.ts).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
documentAddEventListener,
fetchSpy,
localStore,
makeGridApp,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const FOUR = ['s-a', 's-b', 's-c', 's-d'];
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const TILE_TYPE = 'application/x-codeman-tile';
const headerOf = (id: string) => tileEl(id).children[0];
const actionsOf = (id: string) => headerOf(id).children[3];
const nameOf = (id: string) => headerOf(id).children[2].children[0];
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
const stored = () => JSON.parse(localStore.get('codeman:tile-grid') ?? 'null');
const dragEvent = () => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { effectAllowed: '', dropEffect: '', setData: vi.fn() },
});
/** A press on `target` inside the header (the capture-phase listener notes where), then the native dragstart. */
function startDrag(id: string, target: FakeEl = headerOf(id)) {
headerOf(id).dispatch('pointerdown', { target, button: 0 });
const start = dragEvent();
headerOf(id).dispatch('dragstart', start);
return start;
}
function over(el: FakeEl) {
const e = dragEvent();
el.dispatch('dragover', e);
return e;
}
function drop(el: FakeEl) {
const e = dragEvent();
el.dispatch('drop', e);
return e;
}
const end = (id: string) => headerOf(id).dispatch('dragend', dragEvent());
/** A full header drag: press, start, over and drop on `target`, end. */
function dragTileOnto(id: string, target: FakeEl) {
const start = startDrag(id);
const o = over(target);
const d = drop(target);
end(id);
return { start, over: o, drop: d };
}
function openGrid(ids: string[], focusedId = ids[0]): GridApp {
const app = makeGridApp(ids);
app.openTileGrid(ids, { focusedId });
return app;
}
/** What a move must never cost: a new tile, a connect, a destroy, a fetch. */
function snapshotCost() {
const tiles = FakeTile.all.length;
const connects = FakeTile.all.map((t) => t.connect.mock.calls.length);
fetchSpy.mockClear();
return () => {
expect(FakeTile.all.length).toBe(tiles);
expect(FakeTile.all.map((t) => t.connect.mock.calls.length)).toEqual(connects);
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
};
}
const clearFits = () => {
for (const t of FakeTile.all) t.fit.mockClear();
};
const fitCounts = (ids: string[]) => Object.fromEntries(ids.map((id) => [id, tile(id).fit.mock.calls.length]));
beforeEach(() => {
resetGridHarness();
});
describe('dragging a tile by its header', () => {
it('onto another tile: the two trade places, the dragged one takes focus, nothing remounts or reloads', () => {
const app = openGrid(FOUR, 's-b');
app.markIdleAlertSeen.mockClear();
const costsNothing = snapshotCost();
const start = startDrag('s-a');
expect(start.preventDefault).not.toHaveBeenCalled();
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(true);
const o = over(tileEl('s-d'));
expect(o.preventDefault).toHaveBeenCalled();
expect(o.dataTransfer.dropEffect).toBe('move');
expect(tileEl('s-d').classList.contains('tile--drop-target')).toBe(true);
drop(tileEl('s-d'));
end('s-a');
expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']);
expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['3', '3']);
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['1', '1']);
costsNothing();
expect(app._tilesOwnTerminal()).toBe(true);
expect([...app._tileGrid.tiles.keys()].sort()).toEqual(FOUR);
// A drag is a human action: the dropped tile takes focus, as a tab drop does.
expect(app.activeSessionId).toBe('s-a');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-a');
// What the drag painted is gone.
for (const id of FOUR) {
expect(tileEl(id).classList.contains('tile--dragging')).toBe(false);
expect(tileEl(id).classList.contains('tile--drop-target')).toBe(false);
}
expect(app._draggedTileId).toBeNull();
});
it('the new order is stored (ids only) and comes back on a reload', () => {
const app = openGrid(FOUR);
dragTileOnto('s-a', tileEl('s-c'));
expect(stored().ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
expect(stored().focused).toBe('s-a');
expect(stored().open).toBe(true);
// A reload: a fresh page on the same device restores the stored grid.
app._tileGrid.open = false;
section.children = [];
const reloaded = makeGridApp(FOUR);
expect(reloaded._restoreTileGrid()).toBe(true);
expect(reloaded._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
expect(reloaded.activeSessionId).toBe('s-a');
});
describe('onto an empty slot', () => {
// Under 1800px wide three tiles take a 2x2 with one empty slot, five a 3x2.
beforeEach(() => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
});
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
// Owner: an empty cell can be any cell, and a tile dragged onto one moves
// THERE, leaving its own cell empty; nothing else moves.
const LAYOUTS = [
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], cells: 4 },
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), cells: 6 },
];
for (const layout of LAYOUTS) {
it(`${layout.name}: from every cell into the hole wherever it is, by header drag`, () => {
const bad: string[] = [];
for (let hole = 0; hole < layout.cells; hole++) {
for (let from = 0; from < layout.cells; from++) {
if (from === hole) continue;
resetGridHarness();
section.getBoundingClientRect = () => ({
width: 1700,
height: 1000,
top: 0,
left: 0,
right: 1700,
bottom: 1000,
});
const app = openGrid(layout.ids, layout.ids[0]);
const cells: Array<string | null> = layout.ids.slice();
cells.splice(hole, 0, null);
app._tileGrid.cells = cells.slice(0, layout.cells);
app._applyTileLayout();
const moved = app._tileGrid.cells[from];
const costsNothing = snapshotCost();
expect(slots()).toHaveLength(1);
expect(slots()[0].dataset.cell).toBe(String(hole));
const { over: o } = dragTileOnto(moved, slots()[0]);
const expected = app._tileGrid.cells.slice();
const want = cells.slice(0, layout.cells);
want[hole] = moved;
want[from] = null;
const ok =
JSON.stringify(expected) === JSON.stringify(want) &&
o.dataTransfer.dropEffect === 'move' &&
app.activeSessionId === moved &&
JSON.stringify(stored().ids) === JSON.stringify(want) &&
slots().length === 1 &&
slots()[0].dataset.cell === String(from);
if (!ok) bad.push(`hole ${hole} from ${from}: got ${JSON.stringify(expected)}`);
costsNothing();
}
}
expect(bad).toEqual([]);
});
}
it('the moved tile sits in the hole (its grid place), and the slot takes its old place', () => {
const app = openGrid(SIX.slice(0, 5));
// [a b c / d e _]: c into the hole below it.
dragTileOnto('s-c', slots()[0]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-c']);
expect([tileEl('s-c').style.gridColumn, tileEl('s-c').style.gridRow]).toEqual(['5', '3']);
expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['5', '1']);
expect(slots()[0].classList.contains('tile--drop-target')).toBe(false);
});
it('a tab of a tiled session moves the same way; a session not tiled yet joins in THAT cell', () => {
const app = openGrid(SIX.slice(0, 5));
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
app.draggedTabId = 's-e';
drop(slots()[0]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', null]);
app.draggedTabId = 's-other';
expect(over(slots()[0]).dataTransfer.dropEffect).toBe('move');
drop(slots()[0]);
expect(app._tileGrid.cells).toEqual(['s-a', 's-e', 's-b', 's-c', 's-d', 's-other']);
expect(app.activeSessionId).toBe('s-other');
});
it('while a tile is zoomed there is no slot, and a move into a hole is refused', () => {
const app = openGrid(SIX.slice(0, 5));
app.zoomTile('s-a');
expect(slots()).toHaveLength(0);
expect(app._moveTileToCell('s-b', 5)).toBe(false);
expect(app._tileGrid.cells).toEqual([...SIX.slice(0, 5), null]);
});
});
it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => {
const app = openGrid(FOUR);
localStore.delete('codeman:tile-grid');
const costsNothing = snapshotCost();
clearFits();
startDrag('s-a');
over(tileEl('s-c'));
// The browser cancels (Escape) or the pointer is released outside every
// target: no drop, and not always a dragleave either.
end('s-a');
expect(app._tileGrid.ids).toEqual(FOUR);
expect(tileEl('s-c').classList.contains('tile--drop-target')).toBe(false);
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
expect(app._draggedTileId).toBeNull();
expect(localStore.has('codeman:tile-grid')).toBe(false);
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
costsNothing();
// The drag is over: a later drag that is neither a tab nor a tile (a file) is left alone.
expect(over(tileEl('s-c')).preventDefault).not.toHaveBeenCalled();
});
it('carries a type of its own, never text, and is not a tab drag (the strip ignores it)', () => {
const app = openGrid(FOUR);
const start = startDrag('s-b');
expect(start.dataTransfer.setData).toHaveBeenCalledWith(TILE_TYPE, 's-b');
expect(start.dataTransfer.setData.mock.calls.every(([type]) => type === TILE_TYPE)).toBe(true);
expect(start.dataTransfer.effectAllowed).toBe('move');
expect(app.draggedTabId).toBeFalsy();
end('s-b');
});
it('never reaches xterm: tiles and slots hold the drag in the capture phase and stop it', () => {
openGrid(FOUR);
for (const id of FOUR) {
expect(tileEl(id).captureFlags.dragover).toEqual([true]);
expect(tileEl(id).captureFlags.drop).toEqual([true]);
}
startDrag('s-a');
const o = over(tileEl('s-b'));
const d = drop(tileEl('s-b'));
for (const e of [o, d]) {
expect(e.preventDefault).toHaveBeenCalled();
expect(e.stopPropagation).toHaveBeenCalled();
}
end('s-a');
});
it('over its own tile the drag is held there too (never reaching its xterm) but refused', () => {
const app = openGrid(FOUR);
startDrag('s-a');
const o = over(tileEl('s-a'));
expect(o.preventDefault).toHaveBeenCalled();
expect(o.stopPropagation).toHaveBeenCalled();
expect(o.dataTransfer.dropEffect).toBe('none');
expect(tileEl('s-a').classList.contains('tile--drop-target')).toBe(false);
drop(tileEl('s-a'));
end('s-a');
expect(app._tileGrid.ids).toEqual(FOUR);
});
it('a tile removed mid-drag, or the grid closed, ends the drag', () => {
const app = openGrid(FOUR);
startDrag('s-a');
app.removeTile('s-a');
expect(app._draggedTileId).toBeNull();
expect(over(tileEl('s-b')).preventDefault).not.toHaveBeenCalled();
startDrag('s-b');
over(tileEl('s-c'));
app.selectSession = vi.fn();
app.closeTileGrid();
expect(app._draggedTileId).toBeNull();
});
});
describe('focus: the header focuses its tile on click, so a cancelled drag changes nothing', () => {
it('a press on the header does not focus the tile; a click does (a human selection)', () => {
const app = openGrid(FOUR, 's-a');
app.markIdleAlertSeen.mockClear();
tileEl('s-c').dispatch('pointerdown', { target: nameOf('s-c'), button: 0 });
tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 });
expect(app.activeSessionId).toBe('s-a');
headerOf('s-c').dispatch('click', {});
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
});
it('a press in the body still focuses at once (before the press reaches xterm)', () => {
const app = openGrid(FOUR, 's-a');
tileEl('s-c').dispatch('pointerdown', { target: tileEl('s-c').children[1], button: 0 });
expect(app.activeSessionId).toBe('s-c');
});
it('a drag of an unfocused tile that is cancelled leaves focus and its alert alone', () => {
const app = openGrid(FOUR, 's-a');
app.markIdleAlertSeen.mockClear();
tileEl('s-c').dispatch('pointerdown', { target: headerOf('s-c'), button: 0 });
startDrag('s-c');
over(tileEl('s-b'));
end('s-c');
expect(app._tileGrid.ids).toEqual(FOUR);
expect(app.activeSessionId).toBe('s-a');
expect(app._tileGrid.focusedId).toBe('s-a');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('a double-click on the name leaves the keyboard in the rename input, and its clicks stay in it', () => {
const app = openGrid(FOUR, 's-a');
app._queueInlineSessionName = vi.fn();
// The browser's sequence: click, click, dblclick.
headerOf('s-c').dispatch('click', {});
headerOf('s-c').dispatch('click', {});
nameOf('s-c').dispatch('dblclick', { stopPropagation: vi.fn() });
const input = headerOf('s-c').children[2].children[0];
expect(input.className).toBe('tile-rename-input');
const terminalFocus = tile('s-c').terminal.focus.mock.invocationCallOrder;
expect(input.focus).toHaveBeenCalled();
expect(input.focus.mock.invocationCallOrder.at(-1)).toBeGreaterThan(terminalFocus.at(-1) ?? 0);
const click = { stopPropagation: vi.fn() };
input.dispatch('click', click);
expect(click.stopPropagation).toHaveBeenCalled();
});
});
describe('the handle', () => {
it('is the header: draggable, with a tooltip that says so under the state', () => {
const app = openGrid(FOUR);
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app._renderTileChrome();
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
expect(headerOf('s-a').title).toBe('idle 3m\nDrag to move the tile');
});
it('a press on a header button starts no drag (and a press on the free area after it does)', () => {
const app = openGrid(FOUR);
for (const button of actionsOf('s-a').children) {
const start = startDrag('s-a', button);
expect(start.preventDefault).toHaveBeenCalled();
expect(app._draggedTileId).toBeFalsy();
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
}
const start = startDrag('s-a', nameOf('s-a'));
expect(start.preventDefault).not.toHaveBeenCalled();
expect(app._draggedTileId).toBe('s-a');
end('s-a');
});
it('a double-click on the name still renames, and no drag starts while the input is there', () => {
const app = openGrid(FOUR);
app._queueInlineSessionName = vi.fn();
nameOf('s-a').dispatch('dblclick', { stopPropagation: vi.fn() });
const entry = app._tileGrid.tiles.get('s-a');
expect(entry.renaming).toBe(true);
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
const input = headerOf('s-a').children[2].children[0];
expect(input.className).toBe('tile-rename-input');
// A press in the input, then a would-be drag.
expect(startDrag('s-a', input).preventDefault).toHaveBeenCalled();
expect(app._draggedTileId).toBeFalsy();
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
expect(entry.renaming).toBe(false);
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
});
it('a single tile has nowhere to go: not draggable, no drag hint', () => {
const app = openGrid(['s-a']);
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app._renderTileChrome();
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
expect(headerOf('s-a').title).toBe('idle 3m');
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
});
});
describe('while a tile is zoomed, moving is off', () => {
it('no header drags (draggable off, no hint), and a drag that starts anyway is refused', () => {
const app = openGrid(FOUR);
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app._renderTileChrome();
app.zoomTile('s-a');
for (const id of FOUR) {
expect(headerOf(id).getAttribute('draggable')).toBe('false');
expect(headerOf(id).title).toBe('idle 3m');
}
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
expect(app._draggedTileId).toBeFalsy();
app.zoomTile('s-a');
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
});
it('a tab of a tiled session dropped on the zoomed tile moves nothing either', () => {
const app = openGrid(FOUR);
app.zoomTile('s-a');
app.draggedTabId = 's-c';
over(tileEl('s-a'));
drop(tileEl('s-a'));
expect(app._tileGrid.ids).toEqual(FOUR);
expect(app._tileGrid.zoomedId).toBe('s-a');
});
it('an automatic zoom (the window too small for the tiles) counts too', () => {
const app = openGrid(FOUR);
section.getBoundingClientRect = () => ({ width: 700, height: 400, top: 0, left: 0, right: 700, bottom: 400 });
app._applyTileLayout();
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
expect(app._tileGrid.autoZoom).toBe(true);
expect(headerOf('s-b').getAttribute('draggable')).toBe('false');
expect(app._swapTiles('s-a', 's-b')).toBe(false);
expect(app._tileGrid.ids).toEqual(FOUR);
});
});
describe('sizes belong to the cells: only a tile whose size changed fits, once', () => {
it('equal cells: a swap fits nothing (no PTY resize anywhere)', () => {
const app = openGrid(SIX);
clearFits();
dragTileOnto('s-a', tileEl('s-f'));
expect(app._tileGrid.ids).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
});
it('a wider first column: the two swapped tiles fit once each, the other four not at all', () => {
const app = openGrid(SIX);
app._tileGrid.colFr = [2, 1, 1];
app._applyTileLayout();
clearFits();
dragTileOnto('s-a', tileEl('s-b'));
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
// The fractions stay with the columns.
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
});
it('a swap between two cells of the same size fits nothing, whatever the other sizes', () => {
const app = openGrid(SIX);
app._tileGrid.colFr = [2, 1, 1];
app._tileGrid.rowFr = [1, 3];
app._applyTileLayout();
clearFits();
// s-c (column 2, row 0) and s-b (column 1, row 0): both 1 x 1.
dragTileOnto('s-c', tileEl('s-b'));
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
});
it('a swap across rows of different heights: the two moved tiles fit, the rest not', () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
try {
const five = SIX.slice(0, 5);
const app = openGrid(five);
app._tileGrid.rowFr = [3, 1];
app._applyTileLayout();
clearFits();
// [a b c / d e _]: a (row 0) and e (row 1) trade places and heights.
dragTileOnto('s-a', tileEl('s-e'));
expect(app._tileGrid.ids).toEqual(['s-e', 's-b', 's-c', 's-d', 's-a']);
expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
} finally {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
}
});
it('no debounced refit of every tile follows a move', async () => {
const app = openGrid(SIX);
app._tileGrid.colFr = [2, 1, 1];
app._applyTileLayout();
clearFits();
dragTileOnto('s-a', tileEl('s-b'));
// Past TILE_GRID_REFIT_MS (150ms): still only the two moved tiles.
await new Promise((r) => setTimeout(r, 200));
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
});
});
describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => {
const ARROW = { left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' } as const;
type Dir = keyof typeof ARROW;
const chord = (dir: Dir, overrides: Record<string, unknown> = {}) => ({
type: 'keydown',
key: ARROW[dir],
code: ARROW[dir],
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
preventDefault: vi.fn(),
target: { closest: () => null, classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
...overrides,
});
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
if (!keydown) throw new Error('no capture-phase keydown listener');
return keydown[1];
}
// The cell next to each cell in each direction, written out by hand (null:
// an edge). A move goes to that cell: a swap when a tile is there, a move
// into it when it is empty (the partial layouts below).
const TABLES: Record<
string,
{ ids: string[]; cols: number; width?: number; next: Array<Record<Dir, number | null>> }
> = {
'2x1': {
cols: 2,
ids: ['s-a', 's-b'],
next: [
{ left: null, right: 1, up: null, down: null },
{ left: 0, right: null, up: null, down: null },
],
},
'2x2': {
cols: 2,
ids: FOUR,
next: [
{ left: null, right: 1, up: null, down: 2 },
{ left: 0, right: null, up: null, down: 3 },
{ left: null, right: 3, up: 0, down: null },
{ left: 2, right: null, up: 1, down: null },
],
},
'3x2': {
cols: 3,
ids: SIX,
next: [
{ left: null, right: 1, up: null, down: 3 },
{ left: 0, right: 2, up: null, down: 4 },
{ left: 1, right: null, up: null, down: 5 },
{ left: null, right: 4, up: 0, down: null },
{ left: 3, right: 5, up: 1, down: null },
{ left: 4, right: null, up: 2, down: null },
],
},
};
afterEach(() => {
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
for (const [layout, table] of Object.entries(TABLES)) {
it(`${layout}: every cell, every direction, swaps with the right neighbour (or nothing at an edge)`, () => {
const bad: string[] = [];
table.next.forEach((next, i) => {
for (const dir of Object.keys(ARROW) as Dir[]) {
resetGridHarness();
if (table.width) {
const w = table.width;
section.getBoundingClientRect = () => ({ width: w, height: 1000, top: 0, left: 0, right: w, bottom: 1000 });
}
const app = openGrid(table.ids, table.ids[i]);
expect(app._tileGrid.cols).toBe(table.cols);
const costsNothing = snapshotCost();
app.markIdleAlertSeen.mockClear();
const onKeydown = handlerFor(app);
const e = chord(dir);
onKeydown(e);
const expected = table.ids.slice();
const j = next[dir];
if (j !== null) [expected[i], expected[j]] = [expected[j], expected[i]];
const moved = table.ids[i];
const ok =
e.preventDefault.mock.calls.length === 1 &&
JSON.stringify(app._tileGrid.ids) === JSON.stringify(expected) &&
app.activeSessionId === moved &&
app._tileGrid.focusedId === moved &&
tileEl(moved).classList.contains('focused') &&
JSON.stringify(stored().ids) === JSON.stringify(expected) &&
stored().focused === moved;
if (!ok) bad.push(`cell ${i} ${dir}: got ${app._tileGrid.ids.join(',')} focus ${app.activeSessionId}`);
costsNothing();
// Equal cells: nothing changed size, so nothing fits.
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
}
});
expect(bad).toEqual([]);
});
}
// Owner: an empty cell can be any cell. With the hole in every cell in turn,
// every tile and every direction: into the hole, or a swap, or nothing.
const HOLED = [
{ name: '2x2 with 3 tiles', ids: ['s-a', 's-b', 's-c'], adjacency: TABLES['2x2'].next },
{ name: '3x2 with 5 tiles', ids: SIX.slice(0, 5), adjacency: TABLES['3x2'].next },
];
for (const layout of HOLED) {
it(`${layout.name}: with the hole anywhere, every tile, every direction (into the hole, a swap, or nothing)`, () => {
const bad: string[] = [];
const size = layout.adjacency.length;
for (let hole = 0; hole < size; hole++) {
const cells: Array<string | null> = layout.ids.slice();
cells.splice(hole, 0, null);
for (let i = 0; i < size; i++) {
if (i === hole) continue;
for (const dir of Object.keys(ARROW) as Dir[]) {
resetGridHarness();
section.getBoundingClientRect = () => ({
width: 1700,
height: 1000,
top: 0,
left: 0,
right: 1700,
bottom: 1000,
});
const moved = cells[i] as string;
const app = openGrid(layout.ids, moved);
app._tileGrid.cells = cells.slice();
app._applyTileLayout();
const costsNothing = snapshotCost();
const e = chord(dir);
handlerFor(app)(e);
const want = cells.slice();
const j = layout.adjacency[i][dir];
if (j !== null) [want[i], want[j]] = [want[j], want[i]];
const ok =
e.preventDefault.mock.calls.length === 1 &&
JSON.stringify(app._tileGrid.cells) === JSON.stringify(want) &&
app.activeSessionId === moved &&
app._tileGrid.focusedId === moved &&
JSON.stringify(stored().ids) === JSON.stringify(want);
if (!ok) bad.push(`hole ${hole}, cell ${i} ${dir}: got ${JSON.stringify(app._tileGrid.cells)}`);
costsNothing();
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
}
}
}
expect(bad).toEqual([]);
});
}
it('a move into a hole across columns of different widths: only the moved tile fits, once', () => {
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
const five = SIX.slice(0, 5);
const app = openGrid(five, 's-b');
app._tileGrid.colFr = [1, 1, 2];
app._applyTileLayout();
clearFits();
// [a b c / d e _]: b (column 1) down into... e is there; first move e right into the hole.
app._selectTiledSession('s-e', { auto: true });
handlerFor(app)(chord('right'));
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-e']);
expect(fitCounts(five)).toEqual({ 's-a': 0, 's-b': 0, 's-c': 0, 's-d': 0, 's-e': 1 });
});
it('focus stays on the moved tile through several moves, and the order is stored', () => {
const app = openGrid(SIX, 's-a');
const onKeydown = handlerFor(app);
for (const dir of ['right', 'right', 'down', 'left'] as Dir[]) onKeydown(chord(dir));
// a: 0 -> 1 -> 2 -> 5 -> 4
expect(app._tileGrid.ids.indexOf('s-a')).toBe(4);
expect(app.activeSessionId).toBe('s-a');
expect(stored().ids).toEqual(app._tileGrid.ids);
expect(stored().focused).toBe('s-a');
});
it('only a tile whose cell size changed fits, once', () => {
const app = openGrid(SIX, 's-a');
app._tileGrid.colFr = [2, 1, 1];
app._applyTileLayout();
clearFits();
handlerFor(app)(chord('right'));
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
});
describe('swallowed only while they apply', () => {
it('grid closed: not a tile chord, and the capture handler leaves it alone (it reaches the terminal)', () => {
const app = makeGridApp(FOUR);
// Even with the Tiles setting on (where the toggle chord would apply).
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
expect(app.tileShortcutFor(chord('right'))).toBeNull();
const e = chord('right');
handlerFor(app)(e);
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('grid open, from a terminal: applies and is swallowed', () => {
const app = openGrid(FOUR);
expect(app.tileShortcutFor(chord('right'))).toBe('move-tile-right');
expect(app.tileShortcutFor(chord('down'))).toBe('move-tile-down');
});
it('in a text field (the rename input, an editor) it is left to the field: word selection', () => {
const app = openGrid(FOUR);
const onKeydown = handlerFor(app);
for (const tagName of ['INPUT', 'TEXTAREA']) {
const e = chord('right', { target: { tagName, closest: () => null, classList: { contains: () => false } } });
expect(app.tileShortcutFor(e)).toBeNull();
onKeydown(e);
expect(e.preventDefault).not.toHaveBeenCalled();
}
const editable = chord('left', { target: { isContentEditable: true, closest: () => null } });
expect(app.tileShortcutFor(editable)).toBeNull();
expect(app._tileGrid.ids).toEqual(FOUR);
});
it('while a tile is zoomed it still applies (swallowed, never typed into the CLI) and moves nothing', () => {
const app = openGrid(FOUR);
app.zoomTile('s-a');
const e = chord('right');
expect(app.tileShortcutFor(e)).toBe('move-tile-right');
handlerFor(app)(e);
expect(e.preventDefault).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(FOUR);
expect(app._tileGrid.zoomedId).toBe('s-a');
});
it('honours a disable and a rebind from App Settings', () => {
const app = openGrid(FOUR);
app.loadAppSettingsFromStorage = () => ({
shortcutOverrides: {
'move-tile-right': { disabled: true },
'move-tile-left': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'h', code: 'KeyH' }] },
},
});
expect(app.tileShortcutFor(chord('right'))).toBeNull();
expect(app.tileShortcutFor(chord('left'))).toBeNull();
expect(app.tileShortcutFor(chord('left', { key: 'h', code: 'KeyH', shiftKey: false, altKey: true }))).toBe(
'move-tile-left'
);
});
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* @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 count menu (owner
* decision 10). With the grid open a pick re-forms it, the focused tile kept.
*
* The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts).
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
documentAddEventListener,
makeGridApp,
resetGridHarness,
windowStub,
} from './mocks/tile-grid-vm.js';
type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null;
type Helpers = { tileGridOpenSet(p: Record<string, unknown>): OpenSet; TILE_GRID_MAX: number };
// constants.js as the harness loaded it (window.CodemanTileGrid).
const T = windowStub.CodemanTileGrid as Helpers;
const INDEX_HTML = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
const order = Array.from({ length: 9 }, (_, i) => `t${i + 1}`);
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 count menu', () => {
expect(button()).toContain('onclick="app.toggleTileGrid()"');
expect(button()).toContain('oncontextmenu="app.openTileCountMenu(event)"');
expect(button()).toContain('right-click for how many');
expect(button()).not.toContain('onclick="app.openTileCountMenu');
});
it('Ctrl+Shift+G runs the same toggle', () => {
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 === 'tileCountMenu') ?? null;
const pick = (n: number) =>
picker()!
.children.find((c) => c.dataset.count === String(n))!
.dispatch('click', { stopPropagation: vi.fn() });
beforeEach(() => {
resetGridHarness();
const wrap = new FakeEl();
bySelector.set('.terminal-wrap', wrap);
});
it('a click with the grid closed shows the tiles, no menu', () => {
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 menu that a right-click left open', () => {
const app = makeGridApp(IDS);
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(picker()).not.toBeNull();
app.toggleTileGrid();
expect(picker()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('a click elsewhere closes the menu; a click inside it does not', () => {
const app = makeGridApp(IDS);
const before = documentAddEventListener.mock.calls.length;
app.openTileCountMenu({ preventDefault: vi.fn() });
const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>;
const onClick = calls.find(([type]) => type === 'click')![1];
onClick({ target: picker()!.children[0] });
expect(picker()).not.toBeNull();
onClick({ target: new FakeEl() });
expect(picker()).toBeNull();
});
it('a right-click opens the count menu and keeps the browser menu away', () => {
const app = makeGridApp(IDS);
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(picker()).not.toBeNull();
expect(app._tilesOwnTerminal()).toBe(false);
});
it('a right-click with the grid open re-forms it to the count picked, the focused tile kept', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(true);
pick(4);
// Its two first, then the open sessions in tab order.
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
expect(app.activeSessionId).toBe('s-b');
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
pick(2);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-b');
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* @fileoverview Opening the grid paints the tiles first and builds their
* terminals one per frame (owner answer: "paced connect: in").
*
* - `openTileGrid` connects no tile inside the click: every tile is mounted
* and the grid laid out first, then one tile connects per animation frame,
* the focused one first, then reading order. Each still connects once, into
* its final cell (one fit, one PTY resize).
* - The keyboard goes to the focused tile's terminal as soon as it exists
* (`focusOnConnect`): not before, never for a `focus: false` selection, and
* to the newly focused tile when focus moved while they were being built.
* - A grid closed (or opened again) meanwhile stops the old run; a tile
* removed before its turn is skipped; a tile already connected (one a
* re-form added) is never connected twice; a remount after Attach connects
* its new terminal.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
flushFrames,
makeGridApp,
rafCallbacks,
resetGridHarness,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const connected = () => FakeTile.all.filter((t) => t.connect.mock.calls.length > 0).map((t) => t.sessionId);
const tileOf = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
/** Runs the frames queued so far, once (one step of the paced connect). */
const oneFrame = () => {
for (const cb of rafCallbacks.splice(0)) cb();
};
beforeEach(() => {
resetGridHarness();
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid';
bySelector.set('.btn-tile-grid', btn);
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
describe('opening paints first, then one terminal per frame', () => {
it('nothing connects inside the click; then the focused tile first, then reading order, one per frame', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-c' });
expect(app._tilesOwnTerminal()).toBe(true);
expect(FakeTile.all).toHaveLength(4);
expect(connected()).toEqual([]);
oneFrame();
expect(connected()).toEqual(['s-c']);
oneFrame();
expect(connected()).toEqual(['s-a', 's-c']);
oneFrame();
oneFrame();
expect(connected().sort()).toEqual(IDS);
oneFrame();
expect(rafCallbacks).toHaveLength(0);
expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
});
it('every tile connects into its final cell: the grid was laid out before the first', () => {
const app = gridApp();
app.openTileGrid(IDS);
const seen: string[] = [];
for (const t of FakeTile.all) {
t.connect.mockImplementation(async () => {
seen.push(`${t.sessionId}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(t.sessionId)}`);
});
}
flushFrames();
expect(seen).toEqual(['s-a:4:0', 's-b:4:1', 's-c:4:2', 's-d:4:3']);
});
});
describe('the keyboard', () => {
it('lands in the focused tile once its terminal exists, not before', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-b' });
expect(tileOf('s-b').terminal.focus).not.toHaveBeenCalled();
oneFrame();
expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1);
flushFrames();
expect(tileOf('s-b').terminal.focus).toHaveBeenCalledTimes(1);
expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled();
});
it('follows a focus moved while the terminals are being built', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-a' });
app._selectTiledSession('s-d', {});
flushFrames();
expect(tileOf('s-d').terminal.focus).toHaveBeenCalled();
expect(tileOf('s-a').terminal.focus).not.toHaveBeenCalled();
});
it('never for a selection that asked for no focus', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-a' });
app._selectTiledSession('s-a', { focus: false });
flushFrames();
expect(FakeTile.all.some((t) => t.terminal.focus.mock.calls.length > 0)).toBe(false);
});
it('a tile already built takes the keyboard at once', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-a' });
flushFrames();
app._selectTiledSession('s-c', {});
expect(tileOf('s-c').terminal.focus).toHaveBeenCalledTimes(1);
});
});
describe('a grid that changes meanwhile', () => {
it('closed: nothing connects any more, and no frame is asked for after it', () => {
const app = gridApp();
app.openTileGrid(IDS);
oneFrame();
app.closeTileGrid({ reselect: false });
oneFrame();
expect(rafCallbacks).toHaveLength(0);
flushFrames();
expect(connected()).toEqual(['s-a']);
});
it('opened again: the old run stops at once, the new one starts with its own focused tile', () => {
const app = gridApp();
app.openTileGrid(IDS, { focusedId: 's-a' });
oneFrame();
app.closeTileGrid({ reselect: false });
app.activeSessionId = null;
app.openTileGrid(IDS, { focusedId: 's-d' });
oneFrame();
const live = () => FakeTile.all.filter((t) => !t._destroyed && t.connect.mock.calls.length).map((t) => t.sessionId);
expect(live()).toEqual(['s-d']);
});
it('closed and opened again: the old run stops, the new one connects each tile once', () => {
const app = gridApp();
app.openTileGrid(IDS);
oneFrame();
app.closeTileGrid({ reselect: false });
app.activeSessionId = null;
app.openTileGrid(IDS);
flushFrames();
const live = FakeTile.all.filter((t) => !t._destroyed);
expect(live).toHaveLength(4);
expect(live.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
});
it('a tile removed before its turn is skipped; one a re-form connected is not connected again', () => {
const app = gridApp(['s-a', 's-b', 's-c', 's-d', 's-e']);
app.openTileGrid(['s-a', 's-b', 's-c']);
app.removeTile('s-b');
app.addTile('s-e');
expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1);
flushFrames();
expect(FakeTile.all.find((t) => t.sessionId === 's-b')!.connect).not.toHaveBeenCalled();
expect(tileOf('s-e').connect).toHaveBeenCalledTimes(1);
expect(tileOf('s-a').connect).toHaveBeenCalledTimes(1);
expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1);
});
it('a tile remounted before its turn (Attach) is connected once, not again by the paced run', () => {
const app = gridApp();
app.openTileGrid(IDS);
app._remountTile('s-c');
flushFrames();
expect(tileOf('s-c').connect).toHaveBeenCalledTimes(1);
});
it('a remount after Attach connects its new terminal', () => {
const app = gridApp();
app.openTileGrid(IDS);
flushFrames();
const old = tileOf('s-b');
app._remountTile('s-b');
const fresh = tileOf('s-b');
expect(fresh).not.toBe(old);
expect(fresh.connect).toHaveBeenCalledTimes(1);
});
});
+428
View File
@@ -0,0 +1,428 @@
/**
* @fileoverview While the tile grid is open the main terminal is PARKED.
*
* Opening the grid (tile-grid.js `openTileGrid`) runs `_cleanupPreviousSession`
* once, which closes the main terminal's socket, and hides `.terminal-wrap`.
* With that socket closed `_wsReady` is false, so every SSE terminal handler
* would start writing the FOCUSED tile's output into the hidden xterm (they all
* key on `activeSessionId`), refetch captures for it, or reopen its socket onto
* a session a tile already shows. One predicate, `_tilesOwnTerminal()`, turns
* each of them into a no-op. This suite enumerates them, and checks each one
* BOTH ways (grid open: stands aside; grid closed: acts), so none can pass
* vacuously:
*
* - `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh`
* (returns false, never undefined), `_scheduleDroppedOutputRecovery`;
* - the `terminal.writeln` in `_onSessionCompletion` / `_onSessionError`;
* - `retryConnection` and `handleInit` (which reconnect the main socket):
* they re-arm the TILES instead, and handleInit keeps live tiles;
* - the backstops `sendResize`, `_maybeRefetchFullHistory`;
* - the WebGL long-task observer, which watches the WHOLE page: tile renders
* must not count toward the main terminal's sticky WebGL disable;
* - the header connection state, derived from the tile sockets.
*
* Plus the park and unpark themselves: one cleanup on enter; on exit every
* tile destroyed, `_lastResizeDims` reset, the main terminal's cached content
* for EVERY tiled id invalidated, and the focused session replayed fresh.
*
* Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
* tile-grid.js in one `vm` context, with the shared fake DOM and fake
* TerminalTile (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeTile,
advanceClock,
clockNow,
flushFrames,
localStore,
main,
makeGridApp,
perfObserverCallbacks,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
type App = GridApp;
const IDS = ['s-a', 's-b', 's-c'];
/**
* The shared app plus what these guards exercise: the REAL sendResize (the
* harness stubs it), and spies on the main terminal's own paths.
*/
function makeApp(): App {
const app = makeGridApp(IDS);
delete app.sendResize;
app._initGeneration = 1;
app.isOnline = true;
app._connectionStatus = 'connected';
app._wsState = 'disconnected';
app._lastResizeDims = { cols: 100, rows: 30 };
for (const name of [
'selectSession',
'batchTerminalWrite',
'_connectWs',
'_geometryForResizeRequest',
'updateCost',
'_notifySession',
'connectSSE',
'_updateConnectionLossUi',
]) {
app[name] = vi.fn();
}
app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 }));
return app;
}
beforeEach(() => {
resetGridHarness();
});
describe('parking the main terminal', () => {
it('opening takes no snapshot of a session that becomes a tile, and keeps it for one that does not', () => {
// Closing the grid drops the snapshot of every tiled id, so one taken of a
// tiled session at opening was always thrown away.
const tiled = makeApp();
tiled.openTileGrid(IDS);
expect(tiled._cleanupPreviousSession).toHaveBeenCalledWith('s-a', { skipSnapshot: true });
resetGridHarness();
const elsewhere = makeApp(); // s-a is active, the grid opens on the others
elsewhere.openTileGrid(['s-b', 's-c']);
expect(elsewhere._cleanupPreviousSession).toHaveBeenCalledWith('s-b', { skipSnapshot: false });
});
it('_cleanupPreviousSession skips the snapshot only when asked', () => {
const app = makeApp();
delete app._cleanupPreviousSession; // the real one
app._disconnectWs = vi.fn();
const serialize = vi.fn(() => 'snapshot of s-a\r\n'.repeat(4));
app._serializeAddon = { serialize };
app._isUsableXtermSnapshot = () => true;
app._persistXtermSnapshot = vi.fn();
app._cleanupPreviousSession('s-b', { skipSnapshot: true });
expect(serialize).not.toHaveBeenCalled();
expect(app._xtermSnapshots.has('s-a')).toBe(false);
app._cleanupPreviousSession('s-b');
expect(serialize).toHaveBeenCalledTimes(1);
expect(app._xtermSnapshots.has('s-a')).toBe(true);
});
it('opening parks it ONCE and puts a tile per session in its place, focused tile active', () => {
const app = makeApp();
expect(app.openTileGrid(IDS, { focusedId: 's-b' })).toBe(true);
expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1);
expect(main.classList.contains('tiles-active')).toBe(true);
expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS);
// Built one per frame after the click (_connectTilesPaced), each once.
flushFrames();
expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true);
expect(app.activeSessionId).toBe('s-b');
expect(app._tilesOwnTerminal()).toBe(true);
// Grid tiles: the one load queue, the smaller scrollback, a bounded load.
expect(FakeTile.all[0].opts).toMatchObject({ scrollback: 10000, boundedLoad: true });
expect(typeof FakeTile.all[0].opts.scheduleLoad).toBe('function');
});
it('the focused tile connects first, so its capture leads the queue', () => {
const app = makeApp();
const order: string[] = [];
app.openTileGrid(IDS, { focusedId: 's-c' });
for (const t of FakeTile.all) order.push(`${t.sessionId}:${t.connect.mock.invocationCallOrder[0]}`);
const first = FakeTile.all.reduce((a, b) =>
a.connect.mock.invocationCallOrder[0] < b.connect.mock.invocationCallOrder[0] ? a : b
);
expect(first.sessionId).toBe('s-c');
});
it('refuses below the desktop width, in a solo window, and with no live session', () => {
const narrow = makeApp();
windowStub.innerWidth = 1179;
expect(narrow.openTileGrid(IDS)).toBe(false);
windowStub.innerWidth = 2400;
const solo = makeApp();
solo.isSoloWindow = true;
expect(solo.openTileGrid(IDS)).toBe(false);
const none = makeApp();
expect(none.openTileGrid(['gone'])).toBe(false);
expect(none._cleanupPreviousSession).not.toHaveBeenCalled();
});
it('never tiles a detached session', () => {
const app = makeApp();
app.detachedSessions.add('s-b');
app.openTileGrid(IDS);
expect(FakeTile.all.map((t) => t.sessionId)).toEqual(['s-a', 's-c']);
});
it('closing destroys every tile and replays the focused session fresh in the single view', () => {
const app = makeApp();
app.openTileGrid(IDS, { focusedId: 's-b' });
app.closeTileGrid();
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true);
expect(main.classList.contains('tiles-active')).toBe(false);
expect(app._tilesOwnTerminal()).toBe(false);
expect(app._lastResizeDims).toBeNull();
expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true, auto: true });
});
it("closing invalidates the main terminal's cached content for EVERY tiled id, and only those", () => {
const app = makeApp();
for (const id of [...IDS, 's-other']) {
app._xtermSnapshots.set(id, 'old');
app.terminalBufferCache.set(id, 'old');
localStore.set(`codeman-xs-${id}`, 'old');
}
app.openTileGrid(IDS);
app.closeTileGrid();
for (const id of IDS) {
expect(app._xtermSnapshots.has(id)).toBe(false);
expect(app.terminalBufferCache.has(id)).toBe(false);
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
}
expect(app._xtermSnapshots.get('s-other')).toBe('old');
expect(localStore.get('codeman-xs-s-other')).toBe('old');
});
it('closing with reselect: false leaves the next selection to the caller', () => {
const app = makeApp();
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
expect(app.selectSession).not.toHaveBeenCalled();
});
});
/** An app with the grid open on IDS (focus s-a), or closed, for the paired guard checks. */
function appWithGrid(open: boolean) {
const app = makeApp();
if (open) app.openTileGrid(IDS);
return app;
}
describe('guards: each stands aside while tiles own the terminal, and acts otherwise', () => {
it.each([true, false])('_onSessionTerminal (grid open: %s)', (open) => {
const app = appWithGrid(open);
app.pendingWrites = [];
app._onSessionTerminal({ id: 's-a', data: 'output' });
expect(app.batchTerminalWrite).toHaveBeenCalledTimes(open ? 0 : 1);
});
it.each([true, false])('_onSessionNeedsRefresh (grid open: %s)', async (open) => {
const app = appWithGrid(open);
const result = await app._onSessionNeedsRefresh({ id: 's-a' });
if (open) expect(result).toBe(false);
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
});
it.each([true, false])('_onSessionClearTerminal (grid open: %s)', async (open) => {
const app = appWithGrid(open);
app._resetTerminalForReplay = vi.fn();
app.sendResize = vi.fn();
await app._onSessionClearTerminal({ id: 's-a' });
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
});
it.each([true, false])('_scheduleDroppedOutputRecovery (grid open: %s)', (open) => {
const app = appWithGrid(open);
app._scheduleDroppedOutputRecovery('s-a', 0, 1024);
expect(!!app._clientDropRecoveryTimer).toBe(!open);
clearTimeout(app._clientDropRecoveryTimer);
});
it.each([true, false])(
'_onSessionCompletion writes into the main terminal only when it is not parked (open: %s)',
(open) => {
const app = appWithGrid(open);
app.totalCost = 0;
app._onSessionCompletion({ id: 's-a', cost: 0.5 });
expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 2);
expect(app.totalCost).toBe(0.5);
}
);
it.each([true, false])(
'_onSessionError writes into the main terminal only when it is not parked (open: %s)',
(open) => {
const app = appWithGrid(open);
app._onSessionError({ id: 's-a', error: 'boom' });
expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 1);
// The notification is not the terminal's: it fires either way.
expect(app._notifySession).toHaveBeenCalledTimes(1);
}
);
it.each([true, false])('retryConnection re-arms the tiles, never the parked main socket (open: %s)', (open) => {
const app = appWithGrid(open);
app.retryConnection();
clearTimeout(app._offlineRetryTimer);
expect(app._connectWs).toHaveBeenCalledTimes(open ? 0 : 1);
if (open) expect(FakeTile.all.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true);
});
it.each([true, false])('sendResize backstop (grid open: %s)', async (open) => {
const app = appWithGrid(open);
expect(await app.sendResize('s-a')).toBe(false);
expect(app._geometryForResizeRequest).toHaveBeenCalledTimes(open ? 0 : 1);
});
it.each([true, false])('_maybeRefetchFullHistory backstop (grid open: %s)', async (open) => {
const app = appWithGrid(open);
app._fullHistoryRepullAt = new Map();
app._fullHistoryRepullUseless = new Set();
app._fetchTerminalCapture = vi.fn(async () => {
throw new Error('stop here');
});
await app._maybeRefetchFullHistory().catch(() => {});
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1);
});
it.each([true, false])(
'the WebGL long-task observer counts nothing while tiles own the terminal (open: %s)',
(open) => {
const app = appWithGrid(open);
app._webglAddon = { dispose: vi.fn() };
app._webglLongTaskObserver = null;
app._disableWebGLSticky = vi.fn();
app._scheduleTerminalRepaint = vi.fn();
app._installWebGLLongTaskGuard();
advanceClock(60_000); // past the install grace period
const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clockNow() - 100 * i }));
perfObserverCallbacks.at(-1)?.({ getEntries: () => longTasks });
expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1);
}
);
});
describe('handleInit with the grid open (SSE back after a server restart)', () => {
function init(app: App, liveIds: string[]) {
app._initGeneration = 1; // gen > 1 on this call: a reconnect, not a page load
app._resetAllAppState = vi.fn(() => app.sessions.clear());
for (const name of [
'_clearTimer',
'_updateCjkInputState',
'syncSessionOrder',
'_loadTabLayout',
'cleanupAllFloatingWindows',
'startSystemStatsPolling',
'stopSystemStatsPolling',
]) {
app[name] = vi.fn();
}
app.$ = () => null;
app._onSessionNeedsRefresh = vi.fn(async () => false);
app.handleInit({
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
scheduledRuns: [],
});
}
it('keeps live tiles (never rebuilt), reconnects them now, and never touches the main socket', () => {
const app = appWithGrid(true);
const before = [...FakeTile.all];
init(app, [...IDS, 's-other']);
expect(FakeTile.all).toEqual(before);
expect(before.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
expect(before.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true);
expect(app._connectWs).not.toHaveBeenCalled();
expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled();
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe('s-a');
});
it('removes the tile of a session that did not come back and focuses a live one', () => {
const app = appWithGrid(true);
init(app, ['s-b', 's-c', 's-other']);
const tileA = FakeTile.all.find((t) => t.sessionId === 's-a');
expect(tileA?.destroy).toHaveBeenCalledTimes(1);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app._connectWs).not.toHaveBeenCalled();
});
it('leaves an active web tab alone when the focused tile is still live (no re-select on every SSE blip)', () => {
const app = appWithGrid(true);
app.activeWebviewId = 'w1';
app._hideWebviewLayer.mockClear();
init(app, [...IDS, 's-other']);
expect(app._hideWebviewLayer).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe('s-a');
});
it('without the grid, the same reconnect resyncs the main terminal (the guard is what differs)', () => {
const app = appWithGrid(false);
init(app, [...IDS, 's-other']);
expect(app._connectWs).toHaveBeenCalledWith('s-a');
});
});
describe('the header connection state follows the tile sockets', () => {
it('reads connected only when every live tile socket is open; a stopped tile does not count', () => {
const app = appWithGrid(true);
// The main socket is parked on purpose; it must not decide the header.
app._wsState = 'disconnected';
for (const t of FakeTile.all) t._wsReady = true;
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected');
FakeTile.all[1]._wsReady = false;
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot reconnecting');
FakeTile.all[1]._stoppedCode = 4009;
expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected');
});
});
describe('panes and fonts while the grid is open', () => {
it('_focusedPane() is the focused tile even when DOM focus left every terminal', () => {
const app = appWithGrid(true);
app._noteFocusedTile(null);
const pane = app._focusedPane();
expect(pane.isPrimary).toBe(false);
expect(pane.sessionId).toBe('s-a');
});
it("the main terminal's window-resize timer leaves grid tiles to the grid's own observer", () => {
// A window resize fires both: the grid's ResizeObserver refits every tile
// (tile-grid.js _scheduleTileGridRefit) and the main terminal's trailing
// timer used to refit them again, twelve fit() calls for six tiles and
// nothing more sent (measured). The timer lives inside initTerminal(), so
// this reads its source; the split's Pane B is still refitted there.
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const start = src.indexOf('const throttledResize = () => {');
const end = src.indexOf("window.addEventListener('resize', throttledResize)", start);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
const calls = src.slice(start, end).match(/this\._forEachTile\?\.\([^;]*;/g) ?? [];
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('{ grid: false }');
});
it('_forEachTile reaches every grid tile; { grid: false } skips them', () => {
const app = appWithGrid(true);
const seen: string[] = [];
app._forEachTile((t: FakeTile) => seen.push(t.sessionId));
expect(seen).toEqual(IDS);
const none: string[] = [];
app._forEachTile((t: FakeTile) => none.push(t.sessionId), { grid: false });
expect(none).toEqual([]);
});
it('Ctrl +/- sizes the tiles (their own per-device font), refitting each, not the main terminal', () => {
const app = appWithGrid(true);
app.setFontSize = vi.fn();
app.increaseFontSize();
expect(app.setFontSize).not.toHaveBeenCalled();
expect(localStore.get('codeman-tile-font-size')).toBe('15');
expect(FakeTile.all.every((t) => t.terminal.options.fontSize === 15 && t.fit.mock.calls.length > 0)).toBe(true);
});
});
+101
View File
@@ -0,0 +1,101 @@
// test/tile-grid-per-device-setting.test.ts
// Port: none (pure static analysis, runs in CI, no browser/server).
//
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
// a PER-DEVICE setting, default OFF, with the same three-way rule as
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
// it would 400 the whole save), and never declared in the schema. Plus the
// parts that make the setting reachable: the App Settings chip, its load and
// save lines, the mobile default, and the `--hidden` marker's display:none
// rule (also covered generically by split-pane-hidden-button-css.test.ts).
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
const read = (file: string) => readFileSync(join(PUBLIC, file), 'utf8');
describe('showTileGridButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
const settingsUi = read('settings-ui.js');
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
it('is in the client-side displayKeys merge policy', () => {
const start = settingsUi.indexOf('const displayKeys = new Set([');
const displayKeys = settingsUi.slice(start, settingsUi.indexOf('])', start));
expect(displayKeys).toContain("'showTileGridButton'");
});
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
expect(settingsUi).toContain('showTileGridButton: _stg,');
});
it('is never declared in the .strict() SettingsUpdateSchema', () => {
expect(schemas).not.toContain('showTileGridButton');
});
it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => {
expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/);
expect(settingsUi).toContain(
"document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;"
);
});
it('is saved from its App Settings chip and applied to the header', () => {
expect(settingsUi).toContain(
"showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,"
);
expect(settingsUi).toContain('this._applyTileGridButtonVisibility?.(showTileGridButton);');
});
});
describe('App Settings search finds Split and Tiles', () => {
// The real filter (settings-ui.js) over the real markup: it matches each chip
// by its own data-search and its text, so the chips carry their own keywords.
const html = read('index.html');
const settingsSrc = read('settings-ui.js');
const start = settingsSrc.indexOf(' _filterSettings(query) {');
const end = settingsSrc.indexOf('\n },', start);
const dom = new JSDOM(html);
const doc = dom.window.document;
const filter = new Function('document', `return ({${settingsSrc.slice(start, end + 4)}});`)(doc) as {
_filterSettings(q: string): void;
};
const shown = (id: string) => !doc.getElementById(id)!.closest('.set-chip')!.classList.contains('set-hit-hidden');
it.each([
['tiles', true, false],
['tile grid', true, false],
['side by side', true, true],
['split', false, true],
['split pane', false, true],
])('"%s": Tiles shown %s, Split shown %s', (query, tiles, split) => {
filter._filterSettings(query);
expect(shown('appSettingsShowTileGridButton')).toBe(tiles);
expect(shown('appSettingsShowSplitButton')).toBe(split);
});
it('the Header buttons group names both in its keywords', () => {
expect(html).toMatch(/data-search="header buttons [^"]*\bsplit tiles\b[^"]*"/);
});
});
describe('markup and styles', () => {
const html = read('index.html');
const css = read('styles.css');
it('has a header chip in App Settings, beside Split', () => {
expect(html).toMatch(
/data-preview-order="11\.6"><input type="checkbox" id="appSettingsShowTileGridButton">[\s\S]*?<span>Tiles<\/span>/
);
});
it('ships the header button hidden by its marker class, which has a display:none rule', () => {
expect(html).toContain('class="btn-icon-header btn-tile-grid btn-tile-grid--hidden"');
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
*
* - What is stored: ids, focus, a zoom the user chose and the divider
* fractions, never content. Closing the grid (Tiles, a pick outside it)
* keeps it remembered as `open: false` for one-click return; the last tile
* leaving forgets it. Never written or read in a solo window.
* - The restore runs INSIDE handleInit, in place of its single-view
* `selectSession(restoreId, { auto: true })`: with a stored open grid the
* main terminal never loads on that page load (no select, no socket, no
* capture), deleted / detached / duplicate ids are dropped, and the stored
* fractions and zoom come back. A narrow window keeps the single view.
* - A `#session=` link on load wins, and leaves the stored grid remembered
* but closed.
* - Leaving the grid invalidates the main terminal's cached content for every
* tiled id (snapshot, its localStorage copy, buffer cache).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeTile, localStore, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const KEY = 'codeman:tile-grid';
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
const app = makeGridApp(IDS);
app._initGeneration = 0;
app.activeSessionId = null;
app.selectSession = vi.fn();
app._fetchTerminalCapture = vi.fn();
app._resetAllAppState = vi.fn(() => app.sessions.clear());
for (const name of [
'_clearTimer',
'_updateCjkInputState',
'syncSessionOrder',
'_loadTabLayout',
'cleanupAllFloatingWindows',
'startSystemStatsPolling',
'stopSystemStatsPolling',
'updateCost',
]) {
app[name] = vi.fn();
}
app.$ = () => null;
setup(app);
app.handleInit({
sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
scheduledRuns: [],
});
return app;
}
beforeEach(() => {
resetGridHarness();
});
describe('what is stored', () => {
it('opening the grid stores ids, focus and fractions, nothing else', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
expect(stored()).toEqual({
v: 1,
open: true,
ids: IDS,
focused: 's-b',
zoomed: null,
colFr: [1, 1],
rowFr: [1, 1],
});
});
it('focus, a zoom by hand and a divider drag are written as they happen', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.selectSession('s-c');
expect(stored().focused).toBe('s-c');
app.zoomTile('s-c');
expect(stored().zoomed).toBe('s-c');
app.zoomTile('s-c');
app._tileGrid.colFr = [2, 1];
app._persistTileGrid();
expect(stored().colFr).toEqual([2, 1]);
});
it('an automatic zoom (window too small) is not stored: it is worked out again', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app._tileGrid.zoomedId = 's-a';
app._tileGrid.autoZoom = true;
app._persistTileGrid();
expect(stored().zoomed).toBeNull();
});
it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
expect(stored()).toMatchObject({ open: false, ids: IDS });
app.openTileGrid(['s-a']);
app.removeTile('s-a');
expect(localStore.has(KEY)).toBe(false);
});
it('a solo window never writes', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
localStore.delete(KEY);
app.isSoloWindow = true;
app._persistTileGrid();
expect(localStore.has(KEY)).toBe(false);
});
});
describe('page load with a stored open grid', () => {
const storeGrid = (state: Record<string, unknown>) =>
localStore.set(KEY, JSON.stringify({ v: 1, open: true, zoomed: null, ...state }));
it('restores the grid IN PLACE of the single view: the main terminal never loads', () => {
storeGrid({ ids: IDS, focused: 's-c' });
const app = pageLoad([...IDS, 's-other']);
expect(app._tilesOwnTerminal()).toBe(true);
expect(app._tileGrid.ids).toEqual(IDS);
expect(app.activeSessionId).toBe('s-c');
expect(app.selectSession).not.toHaveBeenCalled();
expect(app._connectWs).not.toHaveBeenCalled();
expect(app._fetchTerminalCapture).not.toHaveBeenCalled();
// Restoring is the app's choice of focus: no idle alert spent.
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('drops a session that no longer exists, and stores the cleaned list', () => {
storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
expect(app.activeSessionId).toBe('s-a');
expect(stored().ids).toEqual(['s-a', 's-b']);
});
it('brings back the fractions (same layout only) and a zoom the user chose', () => {
storeGrid({ ids: IDS, focused: 's-a', zoomed: 's-b', colFr: [2, 1], rowFr: [1, 1, 1] });
const app = pageLoad(IDS);
expect(app._tileGrid.colFr).toEqual([2, 1]);
// Three row fractions do not fit a 2x2: equal rows.
expect(app._tileGrid.rowFr).toEqual([1, 1]);
expect(app._tileGrid.zoomedId).toBe('s-b');
expect(app.activeSessionId).toBe('s-b');
});
it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => {
const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`);
storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] });
const app = pageLoad(nine);
expect(app._tileGrid.ids).toEqual(nine.slice(0, 6));
expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6);
expect(app.activeSessionId).toBe('n-5');
expect(app._tileGrid.zoomedId).toBeNull();
// 3x2 now: the columns still match, the three stored rows do not.
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
expect(app._tileGrid.rowFr).toEqual([1, 1]);
expect(stored().ids).toEqual(nine.slice(0, 6));
});
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
localStore.set('codeman:tile-count', '2');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
app.activeSessionId = 's-a';
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-c');
});
it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => {
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.activeSessionId = 's-a';
app.toggleTileGrid();
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
// in tab order: every live session, fewer than the count.
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
expect(app.activeSessionId).toBe('s-c');
});
it('a stored hole is filled first when the count needs more tiles', () => {
// A 2x2 of three with the hole first; the count asks for four.
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
localStore.set('codeman:tile-count', '4');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.toggleTileGrid();
// s-a joins in the hole (packing would have put it last).
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('a reload brings back exactly the stored grid, whatever the count', () => {
localStore.set('codeman:tile-count', '6');
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
});
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
storeGrid({ ids: IDS, focused: 's-a' });
windowStub.innerWidth = 1100;
const app = pageLoad(IDS);
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalled();
expect(stored().open).toBe(true);
});
it('a solo window never restores it', () => {
storeGrid({ ids: IDS, focused: 's-a' });
const app = pageLoad(IDS, (a) => {
a.isSoloWindow = true;
a._applySoloMode = vi.fn();
});
expect(app._tileGrid?.open ?? false).toBe(false);
expect(FakeTile.all).toHaveLength(0);
});
it.each([
['another version', JSON.stringify({ v: 2, open: true, ids: IDS })],
['not JSON', '{oops'],
['not an object', '[1,2]'],
])('ignores a stored value that is %s', (_label, raw) => {
localStore.set(KEY, raw);
const app = pageLoad(IDS);
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalled();
});
it('a #session= link on load wins, and the grid stays remembered, closed', () => {
storeGrid({ ids: IDS, focused: 's-a' });
const app = pageLoad(IDS, (a) => {
a._urlSessionId = 's-d';
});
expect(app._tileGrid?.open ?? false).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-d', { auto: true, leaveTiles: true });
expect(stored()).toMatchObject({ open: false, ids: IDS });
});
});
describe('leaving the grid', () => {
it("invalidates the main terminal's cached content for every tiled id", () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
for (const id of [...IDS, 's-other']) {
app._xtermSnapshots.set(id, 'old');
app.terminalBufferCache.set(id, 'old');
localStore.set(`codeman-xs-${id}`, 'old');
}
app.openTileGrid(['s-a', 's-b']);
app.closeTileGrid();
for (const id of ['s-a', 's-b']) {
expect(app._xtermSnapshots.has(id)).toBe(false);
expect(app.terminalBufferCache.has(id)).toBe(false);
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
}
expect(app._xtermSnapshots.get('s-c')).toBe('old');
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @fileoverview The tile branch of `selectSession`, and focus moving between tiles.
*
* With the tile grid open the main terminal is parked, so a selection must
* never reach the half of selectSession that cleans it up, replays into it or
* reconnects its socket. A TILED session is focused in its tile
* (`_selectTiledSession`: an `activeSessionId` change plus `xterm.focus()`, the
* same panel refresh as a normal switch). Decision 1: a USER-initiated pick of
* a session that is NOT tiled leaves the grid for the single view (the grid is
* remembered), and so does an explicit `leaveTiles` (a followed link); an
* app-driven pick (`auto: true`) never collapses the grid.
*
* Focus and alert rules: only a human selection acknowledges an idle alert.
* Pressing a tile, Ctrl+Tab / Alt+[ ] (which cycle through the tiles) and a tab
* click are human; the delete fallback and a popped-out tile are the app's.
*
* Real code: constants.js + app.js + terminal-ui.js + tile-grid.js (the shared
* vm harness in test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
bySelector,
flushFrames,
idleCallbacks,
localStore,
makeGridApp,
resetGridHarness,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
/** A grid open on IDS (focus s-a), with selectSession REAL and the main terminal's select path tripwired. */
function openGrid(): GridApp {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
// The tiles' terminals are built one per frame (_connectTilesPaced).
flushFrames();
delete app.selectSession; // the prototype's, for real
app._cleanupPreviousSession.mockClear();
app.markIdleAlertSeen.mockClear();
// Anything past the tile branch: the main terminal's own select path.
app._shouldFocusTerminalForTabSwitch = vi.fn(() => false);
app._setTerminalLoadState = vi.fn(() => {
throw new Error('the main terminal select path ran');
});
return app;
}
beforeEach(() => {
resetGridHarness();
});
describe('selectSession with the grid open', () => {
it('focuses a tiled session in its tile and never touches the main terminal', async () => {
const app = openGrid();
await app.selectSession('s-b');
expect(app.activeSessionId).toBe('s-b');
expect(app._tileGrid.focusedId).toBe('s-b');
expect(app._cleanupPreviousSession).not.toHaveBeenCalled();
expect(app._connectWs).not.toHaveBeenCalled();
expect(app.sendResize).not.toHaveBeenCalled();
expect(FakeTile.all.find((t) => t.sessionId === 's-b')?.terminal.focus).toHaveBeenCalled();
expect(app._focusedPane().sessionId).toBe('s-b');
});
it('focus changes leave at most one glow listener on a tab, and the glow runs again once it ended', async () => {
// On every skin but OG the glow is `animation: none`: animationend never
// fires, and every focus used to add one more once-listener to the tab.
const app = openGrid();
const tab = new FakeEl();
tab.className = 'session-tab active';
bySelector.set('.session-tab.active[data-id="s-b"]', tab);
for (let i = 0; i < 5; i++) {
await app.selectSession('s-b');
await app.selectSession('s-a');
}
expect(tab.classList.contains('tab-glow')).toBe(true);
expect(tab.listeners.animationend).toHaveLength(1);
// OG: the animation ends, the class goes, and the next focus glows again.
tab.dispatch('animationend');
tab.listeners.animationend = [];
expect(tab.classList.contains('tab-glow')).toBe(false);
await app.selectSession('s-b');
expect(tab.classList.contains('tab-glow')).toBe(true);
expect(tab.listeners.animationend).toHaveLength(1);
});
it('a user-initiated focus acknowledges the idle alert; an `auto` one does not', async () => {
const app = openGrid();
await app.selectSession('s-b');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
app.markIdleAlertSeen.mockClear();
await app.selectSession('s-c', { auto: true });
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('runs the same deferred panel refresh as a normal switch, tagged with its own generation', async () => {
const app = openGrid();
idleCallbacks.length = 0;
await app.selectSession('s-b');
const genB = app._selectGeneration;
expect(app._refreshSessionPanels).not.toHaveBeenCalled(); // deferred
for (const cb of idleCallbacks.splice(0)) cb();
expect(app._refreshSessionPanels).toHaveBeenCalledWith('s-b', genB);
});
it('an `auto` selection of a session that is NOT tiled leaves the grid open and changes nothing', async () => {
const app = openGrid();
await app.selectSession('s-other', { auto: true });
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-a');
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
});
it('a user-initiated selection of a session that is NOT tiled leaves the grid, remembered', async () => {
const app = openGrid();
let activeAtCleanup: unknown = 'unset';
app._cleanupPreviousSession = vi.fn(() => {
activeAtCleanup = app.activeSessionId;
});
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._isStaleSelect = vi.fn(() => true); // stop right after the switch itself
await app.selectSession('s-other').catch(() => {});
expect(app._tilesOwnTerminal()).toBe(false);
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true);
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS, focused: 's-a' });
// The parked terminal's stale content must not be saved as s-a's snapshot.
expect(activeAtCleanup).toBeNull();
expect(app.activeSessionId).toBe('s-other');
});
it('`leaveTiles` makes even an `auto` selection leave the grid (a followed link is navigation)', async () => {
const app = openGrid();
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._isStaleSelect = vi.fn(() => true);
await app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {});
expect(app._tilesOwnTerminal()).toBe(false);
});
it('a followed `#session=` link passes leaveTiles, and a tiled one just focuses its tile', async () => {
const app = openGrid();
app._urlSessionId = 's-c';
app._selectUrlSession();
await Promise.resolve();
expect(app._tilesOwnTerminal()).toBe(true);
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
});
describe('moving focus between tiles', () => {
it('pressing a tile is a human selection of its session', async () => {
const app = openGrid();
app.selectSession = vi.fn();
const tileB = tileEl('s-b');
tileB?.dispatch('pointerdown');
expect(app.selectSession).toHaveBeenCalledWith('s-b');
});
it('Ctrl+Tab / Alt+] and Alt+[ cycle through the tiles (wrapping), user-initiated, and only the tiles', () => {
const app = openGrid();
app.selectSession = vi.fn();
// From the last tile, the next one wraps to the first tile; the tab order
// would have wrapped to s-other (first in sessionOrder, not tiled).
app.activeSessionId = 's-c';
app.nextSession();
expect(app.selectSession.mock.calls).toEqual([['s-a']]);
app.selectSession.mockClear();
app.activeSessionId = 's-a';
app.prevSession();
expect(app.selectSession.mock.calls).toEqual([['s-c']]);
});
it('without the grid they walk the tab order as before', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.nextSession();
expect(app.selectSession).toHaveBeenLastCalledWith('s-b');
});
it('a tiled session popped out to its own window leaves the grid; a neighbour takes focus', () => {
const app = openGrid();
app.$ = () => null;
app._markDetached('s-a', true);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
it('going Home leaves the grid, remembered', () => {
const app = openGrid();
app.goHome();
expect(app._tilesOwnTerminal()).toBe(false);
expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS });
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
});
+410
View File
@@ -0,0 +1,410 @@
/**
* @fileoverview The tile grid's chords: registry entries, when they apply, and
* that a chord which applies never reaches a PTY.
*
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
* the grid, Alt+Shift+Arrows move focus between tiles, Ctrl+Shift+Arrows move
* the focused tile (tile-grid-move.test.ts), Remove Focused Tile is unbound. The toggle applies wherever a grid could open; the focus and remove
* chords only while the grid is open, so outside it Alt+Shift+Arrows reach the
* terminal untouched. The capture-phase handler (app.js) dispatches a chord
* that applies; its preventDefault() does not stop xterm, so every xterm key
* handler (the main terminal's and TerminalTile's) returns false for it too,
* for every event type and before the Shift+Enter gate (which would otherwise
* send S-Enter for Alt+Shift+Enter style chords).
*
* Real code: the grid harness (test/mocks/tile-grid-vm.ts) for the registry,
* the capture handler and the actions; a second `vm` context with the real
* TerminalTile for its key handler; the main terminal's handler, which needs a
* real xterm, is pinned at the source. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
documentAddEventListener,
makeGridApp,
resetGridHarness,
windowStub as gridWindow,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const chord = (overrides: Record<string, unknown>) => ({
type: 'keydown',
key: '',
code: '',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
target: { closest: () => null },
...overrides,
});
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
/** The per-device Tiles setting on (it enables the Tiles button and the toggle chord). */
const withTilesSetting = (app: GridApp) => {
app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true });
return app;
};
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
beforeEach(() => {
resetGridHarness();
});
describe('registry', () => {
it('ships the tile chords, rebindable, with Remove Focused Tile unbound', () => {
const app = makeGridApp(IDS);
const byId = Object.fromEntries(app.getShortcutRegistry().map((s: { id: string }) => [s.id, s])) as Record<
string,
{ bindings: Array<Record<string, unknown>>; group: string }
>;
expect(byId['toggle-tile-grid'].bindings).toEqual([{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }]);
for (const dir of ['Left', 'Right', 'Up', 'Down']) {
expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` },
]);
// Moving the focused tile (tile-grid-move.test.ts).
expect(byId[`move-tile-${dir.toLowerCase()}`].bindings).toEqual([
{ modifiers: ['ctrl', 'shift'], key: `Arrow${dir}` },
]);
expect(byId[`move-tile-${dir.toLowerCase()}`].group).toBe('Tiles');
}
expect(byId['remove-tile'].bindings).toEqual([]);
expect(byId['toggle-tile-grid'].group).toBe('Tiles');
});
});
describe('when a chord applies', () => {
it('with the Tiles setting on, the toggle applies wherever a grid could open, and while one is open', () => {
const app = withTilesSetting(makeGridApp(IDS));
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
});
it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => {
const app = makeGridApp(IDS);
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid');
});
it('the toggle does not apply in a narrow window or a solo window', () => {
const app = withTilesSetting(makeGridApp(IDS));
gridWindow.innerWidth = 1000;
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
gridWindow.innerWidth = 2400;
app.isSoloWindow = true;
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
});
it('the focus chords apply only while the grid is open', () => {
const app = makeGridApp(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
});
it('the focus chords leave a text field its keys (shifted arrows select there); a terminal still gets them', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
for (const tagName of ['INPUT', 'TEXTAREA']) {
const target = { tagName, closest: () => null, classList: { contains: () => false } };
expect(app.tileShortcutFor(chord({ ...RIGHT, target }))).toBeNull();
}
expect(
app.tileShortcutFor(chord({ ...RIGHT, target: { isContentEditable: true, closest: () => null } }))
).toBeNull();
// xterm's own input is a textarea too, and the chord is the grid's there.
const xterm = {
tagName: 'TEXTAREA',
closest: () => null,
classList: { contains: (c: string) => c === 'xterm-helper-textarea' },
};
expect(app.tileShortcutFor(chord({ ...RIGHT, target: xterm }))).toBe('focus-tile-right');
// Not an arrow chord: the toggle and zoom still apply from a field.
const input = { tagName: 'INPUT', closest: () => null, classList: { contains: () => false } };
expect(app.tileShortcutFor(chord({ ...TOGGLE, target: input }))).toBe('toggle-tile-grid');
});
it('plain typing and unrelated chords never match', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord({ key: 'g', code: 'KeyG' }))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'ArrowRight', altKey: true }))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'G', code: 'KeyG', ctrlKey: true }))).toBeNull();
});
it('honours a disable and a rebind from App Settings', () => {
const app = makeGridApp(IDS);
app.loadAppSettingsFromStorage = () => ({
shortcutOverrides: {
'toggle-tile-grid': { disabled: true },
'focus-tile-right': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'l', code: 'KeyL' }] },
},
});
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
app.openTileGrid(IDS);
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
expect(app.tileShortcutFor(chord({ key: 'l', code: 'KeyL', ctrlKey: true, altKey: true }))).toBe(
'focus-tile-right'
);
});
});
describe('the capture-phase handler', () => {
function handlerFor(app: GridApp) {
app.$ = () => null;
app.setupColorPicker = vi.fn();
const before = (documentAddEventListener.mock.calls as unknown[]).length;
app.setupEventListeners();
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
if (!keydown) throw new Error('no capture-phase keydown listener');
return keydown[1];
}
it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => {
const app = makeGridApp(IDS);
const onKeydown = handlerFor(app);
const e = chord(TOGGLE);
onKeydown(e);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(app._tilesOwnTerminal()).toBe(false);
});
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));
app.selectSession = vi.fn();
const onKeydown = handlerFor(app);
const open = chord(TOGGLE);
onKeydown(open);
expect(open.preventDefault).toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-other', ...IDS]);
expect(app.activeSessionId).toBe('s-a');
onKeydown(chord(TOGGLE));
expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
});
it('Alt+Shift+Right moves focus to the tile on the right, as a human selection', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.markIdleAlertSeen.mockClear();
// The real selectSession routes a tiled id to the tile branch.
delete app.selectSession;
const onKeydown = handlerFor(app);
const e = chord(RIGHT);
onKeydown(e);
expect(e.preventDefault).toHaveBeenCalled();
expect(app.activeSessionId).toBe('s-b');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
});
it('outside the grid Alt+Shift+Right is left alone for the terminal', () => {
const app = makeGridApp(IDS);
const onKeydown = handlerFor(app);
const e = chord(RIGHT);
onKeydown(e);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(app._tilesOwnTerminal()).toBe(false);
});
});
describe('the actions', () => {
it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(IDS, { focusedId: 's-c' });
app.closeTileGrid({ reselect: false });
app.toggleTileGrid();
// Its three first, then the open sessions in tab order (the default count, 6).
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
expect(app.activeSessionId).toBe('s-c');
});
it('a remembered tile whose session is gone is not brought back', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b');
app.toggleTileGrid();
expect(app._tileGrid.ids).not.toContain('s-b');
// Its cell stays where it was, and the count fills it first (s-other, tab order).
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']);
});
it('with every remembered session gone, it opens on the open sessions in tab order instead', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-b', 's-c']);
app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b');
app.sessions.delete('s-c');
app.activeSessionId = 's-a';
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-other', 's-a']);
expect(app.activeSessionId).toBe('s-a');
});
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS, { focusedId: 's-b' });
app._apiDelete = vi.fn();
app.removeFocusedTile();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
expect(app.activeSessionId).toBe('s-c');
expect(app.sessions.has('s-b')).toBe(true);
expect(app._apiDelete).not.toHaveBeenCalled();
});
});
describe('xterm key handlers swallow a chord that applies', () => {
it("the main terminal's handler checks the chord before the Shift+Enter gate, for every event type", () => {
// That handler lives in initTerminal's closure over a real xterm, so the
// rule is pinned at the source, as terminal-copy-clean.test.ts does.
const src = read('terminal-ui.js');
const handler = src.slice(src.indexOf('this.terminal.attachCustomKeyEventHandler((ev) => {'));
const gate = handler.indexOf('if (this.tileShortcutFor?.(ev)) return false;');
const enter = handler.indexOf("if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {");
expect(gate).toBeGreaterThan(0);
expect(gate).toBeLessThan(enter);
});
describe("TerminalTile's handler (real tile, real tileShortcutFor)", () => {
class FakeTerminal {
static last: FakeTerminal;
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
textarea = { addEventListener() {}, removeEventListener() {} };
constructor(options: Record<string, unknown>) {
this.options = options;
FakeTerminal.last = this;
}
loadAddon() {}
open() {}
onData() {}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
write() {}
clear() {}
dispose() {}
}
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) }));
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
innerWidth: 2400,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: class {
static OPEN = 1;
send() {}
close() {}
},
Terminal: FakeTerminal,
FitAddon: {
FitAddon: class {
fit() {}
proposeDimensions() {
return { cols: 80, rows: 24 };
}
},
},
fetch: fetchMock,
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
`${read('terminal-split.js')}\n${read('tile-grid.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const App = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const TerminalTile = windowStub.TerminalTile as new (
id: string,
mount: unknown,
opts: object
) => { connect(): Promise<void>; destroy(): void };
async function tileHandler(gridOpen: boolean) {
const app = Object.create(App.prototype) as GridApp;
app.loadAppSettingsFromStorage = () => ({});
app.isSoloWindow = false;
app._tileGrid = { open: gridOpen };
windowStub.app = app;
const tile = new TerminalTile('s1', { addEventListener() {}, removeEventListener() {} }, { mode: 'claude' });
await tile.connect();
const handler = FakeTerminal.last.keyHandler!;
tile.destroy();
return handler;
}
it.each(['keydown', 'keypress', 'keyup'])(
'returns false for Alt+Shift+Right (%s) while the grid is open',
async (type) => {
const handler = await tileHandler(true);
expect(handler({ ...chord(RIGHT), type })).toBe(false);
}
);
it('lets Alt+Shift+Right through to the PTY when no grid is open', async () => {
const handler = await tileHandler(false);
expect(handler(chord(RIGHT))).toBe(true);
});
const MOVE_RIGHT = { key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true, shiftKey: true };
it.each(['keydown', 'keypress', 'keyup'])(
'returns false for Ctrl+Shift+Right, Move Tile Right (%s), while the grid is open',
async (type) => {
const handler = await tileHandler(true);
expect(handler({ ...chord(MOVE_RIGHT), type })).toBe(false);
}
);
it('lets Ctrl+Shift+Right through to the PTY when no grid is open', async () => {
const handler = await tileHandler(false);
expect(handler(chord(MOVE_RIGHT))).toBe(true);
});
it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => {
const handler = await tileHandler(true);
fetchMock.mockClear();
expect(handler(chord({ key: 'Enter', code: 'Enter', altKey: true, shiftKey: true }))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/send-key'))).toBe(false);
});
it('returns false for the toggle chord', async () => {
const handler = await tileHandler(true);
expect(handler(chord(TOGGLE))).toBe(false);
});
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* @fileoverview The tile grid and the split pane are never open together.
*
* Both run on TerminalTile, and the split's wrappers (`selectSession`,
* `_onSessionDeleted` in terminal-split.js) key on `this._splitPane`, so they
* would fight the grid over the same terminal area if both were ever up:
*
* - opening the grid while a split is open closes the split first and seeds
* the grid with both of its sessions, Pane A focused and Pane B beside it;
* - while the grid is open, `openSplitPicker` and `openSplitPane` refuse, and
* the Split button says so (`aria-disabled`, a title);
* - closing the grid never reopens a split;
* - the split's wrappers stay inert while the grid is open.
*
* Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
* tile-grid.js (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
bySelector,
localStore,
main,
makeGridApp,
resetGridHarness,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
function makeSplitButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-split';
bySelector.set('.btn-split', btn);
return btn;
}
/** s-a in the main pane, s-b in Pane B (the split's real openSplitPane). */
function openSplit(app: GridApp) {
app.openSplitPane('s-b');
expect(app._splitSessionId).toBe('s-b');
// closeSplitPane finds its container by selector.
const container = main.querySelector('.terminal-split-container');
if (container) bySelector.set('.terminal-split-container', container);
return FakeTile.all.at(-1) as FakeTile;
}
beforeEach(() => {
resetGridHarness();
});
describe('opening the grid over an open split', () => {
it('closes the split and seeds the grid with both of its sessions, Pane A focused', () => {
const app = makeGridApp(IDS);
const paneB = openSplit(app);
app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
// The split's two first, then tab order up to the count (default 6).
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
expect(app.activeSessionId).toBe('s-a');
// Pane A is about to park: no closing resize for it.
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
});
it("with a count of 2, exactly the split's two", () => {
const app = makeGridApp(IDS);
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
it('a remembered grid wins over an open split: its tiles first, the split closed and not merged', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-c']);
app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
const paneB = openSplit(app);
app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull();
// Filled from tab order (s-other first), not seeded with the split's two.
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
expect(app.activeSessionId).toBe('s-c');
});
it('an explicit open over a split keeps both split sessions first', () => {
const app = makeGridApp(IDS);
openSplit(app);
app.openTileGrid(['s-c']);
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-a');
});
});
describe('while the grid is open', () => {
it('the split refuses to open, from the picker or directly', () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
const before = FakeTile.all.length;
app.openSplitPicker({ stopPropagation: vi.fn() });
app.openSplitPane('s-b');
expect(app._splitPane ?? null).toBeNull();
expect(FakeTile.all.length).toBe(before);
// The picker itself never opened either.
expect(body.children).toHaveLength(0);
expect(app._splitPickerDismissHandlers ?? null).toBeNull();
});
it('the Split button is marked unavailable, and back to normal once the grid closes', () => {
const app = makeGridApp(IDS);
const btn = makeSplitButton();
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-disabled')).toBe('true');
expect(btn.classList.contains('btn-split--blocked')).toBe(true);
app.closeTileGrid({ reselect: false });
expect(btn.getAttribute('aria-disabled')).toBe('false');
expect(btn.classList.contains('btn-split--blocked')).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
});
it("the split's wrappers do nothing (no split to close or promote)", () => {
const app = makeGridApp(IDS);
app.openTileGrid(IDS);
app.closeSplitPane = vi.fn();
app._onSessionDeleted({ id: 's-c' });
expect(app.closeSplitPane).not.toHaveBeenCalled();
});
});
describe('closing the grid', () => {
it('never reopens the split it replaced', () => {
const app = makeGridApp(IDS);
openSplit(app);
app.toggleTileGrid();
app.closeTileGrid({ reselect: false });
expect(app._splitPane).toBeNull();
expect(app._tilesOwnTerminal()).toBe(false);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview While the tile grid owns the terminal, the page's SSE filter
* names TILE_GRID_SSE_FILTER (constants.js), which no session matches.
*
* The SSE filter gates only session:terminal batches (server side, pinned by
* test/sse-tile-grid-filter.test.ts). With the grid open those frames were for
* the focused tile alone and were only parsed to be dropped: the main terminal
* is parked and the tiles carry their own output over their own sockets. Both
* places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe
* (`_updateSseSubscription`, run by every tile focus) and the connect URL
* (`connectSSE`, rebuilt by every SSE reconnect with the grid still open).
* Leaving the grid gives the filter back to the session shown.
*
* Real code: the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import { fetchSpy, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const FILTER = (windowStub.CodemanTileGrid as { TILE_GRID_SSE_FILTER: string }).TILE_GRID_SSE_FILTER;
/** The app with the REAL _updateSseSubscription, and the filters it posted, in order. */
function makeApp(): GridApp {
const app = makeGridApp(IDS);
delete app._updateSseSubscription;
app._clientId = 'client-1';
return app;
}
const posted = () =>
fetchSpy.mock.calls
.filter(([url]) => url === '/api/events/subscribe')
.map(([, init]) => JSON.parse((init as { body: string }).body).sessions);
beforeEach(() => {
resetGridHarness();
fetchSpy.mockClear();
});
describe('the SSE filter while tiles own the terminal', () => {
it('is a fixed id no session can take', () => {
expect(FILTER).toBe('tile-grid');
// Session ids are UUIDs.
expect(FILTER).not.toMatch(/^[0-9a-f-]{36}$/);
});
it('opening the grid and every tile focus subscribe with it, never a session id', async () => {
const app = makeApp();
app.openTileGrid(IDS);
delete app.selectSession;
await app.selectSession('s-b');
await app.selectSession('s-c');
expect(posted().length).toBeGreaterThanOrEqual(3);
expect(new Set(posted().map((s: string[]) => s.join()))).toEqual(new Set([FILTER]));
});
it('leaving the grid gives the filter back to the session shown', () => {
const app = makeApp();
app.openTileGrid(IDS);
expect(app._sseFilterSessionId('s-a')).toBe(FILTER);
app.closeTileGrid({ keepStored: true, reselect: false });
// What the single view's selectSession (app.js) then posts.
app._updateSseSubscription('s-a');
expect(posted().at(-1)).toEqual(['s-a']);
expect(app._sseFilterSessionId('s-b')).toBe('s-b');
});
it('a page with no grid open (a reload into the single view) filters on the session as before', () => {
const app = makeApp();
expect(app._sseFilterSessionId('s-a')).toBe('s-a');
expect(app._sseFilterSessionId(null)).toBe(null);
app._updateSseSubscription('s-a');
expect(posted()).toEqual([['s-a']]);
});
it('the connect URL asks the same question, so an SSE reconnect with the grid open keeps the filter', () => {
// connectSSE builds an EventSource, which this harness has none of, so its
// URL building is read from source: one place, through the helper.
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = app.indexOf('const _sseParams = new URLSearchParams(');
expect(start).toBeGreaterThan(-1);
const block = app.slice(start, app.indexOf('this.eventSource = new EventSource(', start));
expect(block).toContain('this._sseFilterSessionId(this.activeSessionId)');
expect(block).not.toMatch(/set\('sessions', this\.activeSessionId\)/);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* @fileoverview Zooming a tile (tmux zoom).
*
* `⤢` (or Alt+Shift+Enter) makes one tile fill the grid; the others stay
* connected but hidden, so they measure nothing and send no resize. Pressing it
* again restores the grid, and every tile is refitted (the hidden ones have a
* stale size). Moving focus to another tile restores the grid, as selecting a
* pane does in tmux; removing the zoomed tile does too.
*
* When the window cannot fit the tiles' minimum size, the grid zooms the
* focused tile by itself, with a hint; that automatic zoom follows focus and
* lifts once the window fits again. A zoom the user chose is left alone.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
makeGridApp,
resetGridHarness,
section,
type GridApp,
tileEl,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const zoomButton = (id: string) =>
tileEl(id)
.querySelector('.tile-actions')!
.children.find((b) => b.className.includes('tile-zoom')) as FakeEl;
function openGrid(ids = IDS, focus = ids[0]): GridApp {
const app = makeGridApp(ids);
app.openTileGrid(ids, { focusedId: focus });
app.markIdleAlertSeen.mockClear();
// selectSession is real: a tiled id goes to the tile branch.
return app;
}
/** Runs the trailing refit (and its layout pass). */
function settleRefit() {
vi.advanceTimersByTime(200);
}
beforeEach(() => {
resetGridHarness();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
});
describe('zoom and restore', () => {
it('⤢ fills the grid with that tile; the others are hidden, not disconnected', () => {
const app = openGrid();
zoomButton('s-a').dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.zoomedId).toBe('s-a');
expect(section.classList.contains('tile-grid--zoomed')).toBe(true);
expect(tileEl('s-a').classList.contains('tile--zoomed')).toBe(true);
expect(tileEl('s-b').classList.contains('tile--zoomed')).toBe(false);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr)');
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('true');
});
it('pressing it again restores the grid and refits EVERY tile', () => {
const app = openGrid();
app.zoomTile('s-a');
settleRefit();
for (const t of FakeTile.all) t.fit.mockClear();
app.zoomTile('s-a');
settleRefit();
expect(app._tileGrid.zoomedId).toBeNull();
expect(section.classList.contains('tile-grid--zoomed')).toBe(false);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 1)).toBe(true);
expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('false');
});
it('zooming a tile that is not focused focuses it first, as a human selection', () => {
const app = openGrid();
app.zoomTile('s-c');
expect(app.activeSessionId).toBe('s-c');
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c');
expect(app._tileGrid.zoomedId).toBe('s-c');
});
it('Alt+Shift+Enter toggles the focused tile, only while the grid is open', () => {
const app = openGrid();
const ev = { key: 'Enter', code: 'Enter', altKey: true, shiftKey: true, ctrlKey: false, metaKey: false };
expect(app.tileShortcutFor(ev)).toBe('zoom-tile');
app.runTileShortcut('zoom-tile');
expect(app._tileGrid.zoomedId).toBe('s-a');
app.runTileShortcut('zoom-tile');
expect(app._tileGrid.zoomedId).toBeNull();
const closed = makeGridApp(IDS);
expect(closed.tileShortcutFor(ev)).toBeNull();
});
it('the registry ships Alt+Shift+Enter for it', () => {
const app = openGrid();
const zoom = app.getShortcutRegistry().find((s: { id: string }) => s.id === 'zoom-tile');
expect(zoom.bindings).toEqual([{ modifiers: ['alt', 'shift'], key: 'Enter' }]);
});
});
describe('what restores the grid', () => {
it('moving focus to another tile (tmux select-pane)', () => {
const app = openGrid();
app.zoomTile('s-a');
app.selectSession('s-b');
expect(app._tileGrid.zoomedId).toBeNull();
expect(section.classList.contains('tile-grid--zoomed')).toBe(false);
});
it('removing the zoomed tile; its neighbour takes focus in the grid', () => {
const app = openGrid();
app.zoomTile('s-a');
app.removeTile('s-a');
expect(app._tileGrid.zoomedId).toBeNull();
expect(app.activeSessionId).toBe('s-b');
});
it('removing the zoomed tile without moving focus (a close from this tab) leaves no zoom behind', () => {
const app = openGrid();
app.zoomTile('s-a');
app.removeTile('s-a', { refocus: false });
expect(app._tileGrid.zoomedId).toBeNull();
expect(section.classList.contains('tile-grid--zoomed')).toBe(false);
});
it('adding a tile while one is zoomed by hand', () => {
const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b', 's-c']);
app.zoomTile('s-a');
expect(app.addTile('s-d')).toBe(true);
expect(app._tileGrid.zoomedId).toBeNull();
});
it('closing the grid forgets the zoom', () => {
const app = openGrid();
app.selectSession = vi.fn();
app.zoomTile('s-a');
app.closeTileGrid({ reselect: false });
expect(app._tileGrid.zoomedId).toBeNull();
expect(section.classList.contains('tile-grid--zoomed')).toBe(false);
});
});
describe('a window too small for the tiles', () => {
const small = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 });
const large = () => ({ width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 });
it('zooms the focused tile with a hint, and the grid comes back once it fits', () => {
const app = openGrid();
(section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = small;
app._scheduleTileGridRefit();
settleRefit();
expect(app._tileGrid.zoomedId).toBe('s-a');
expect(app._tileGrid.autoZoom).toBe(true);
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too small'), 'info');
(section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = large;
app._scheduleTileGridRefit();
settleRefit();
expect(app._tileGrid.zoomedId).toBeNull();
expect(app._tileGrid.autoZoom).toBe(false);
});
it('the automatic zoom follows focus instead of restoring a grid that does not fit', () => {
const app = openGrid();
(section as unknown as { getBoundingClientRect: () => object }).getBoundingClientRect = small;
app._scheduleTileGridRefit();
settleRefit();
app.selectSession('s-c');
expect(app._tileGrid.zoomedId).toBe('s-c');
expect(tileEl('s-c').classList.contains('tile--zoomed')).toBe(true);
// Moved, not lifted and re-applied: the hint is not repeated.
expect(app.showToast).toHaveBeenCalledTimes(1);
});
it('a zoom the user chose is not lifted when the window fits', () => {
const app = openGrid();
app.zoomTile('s-b');
app._scheduleTileGridRefit();
settleRefit();
expect(app._tileGrid.zoomedId).toBe('s-b');
});
});
describe('styles', () => {
it('hides every tile but the zoomed one', () => {
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
expect(css).toMatch(/\.tile-grid\.tile-grid--zoomed \.tile:not\(\.tile--zoomed\)\s*\{\s*display: none;/);
});
});
+3 -3
View File
@@ -136,7 +136,7 @@ describe('dashboard handling of a #session=<id> link', () => {
const app = make(['a']);
app._urlSessionId = 'a';
expect(app._selectUrlSession()).toBe(true);
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true, leaveTiles: true });
expect(app._urlSessionId).toBeNull();
});
@@ -149,7 +149,7 @@ describe('dashboard handling of a #session=<id> link', () => {
app._onSessionCreated({ id: 'other', name: 'other' });
expect(app.selectSession).not.toHaveBeenCalled();
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true });
expect(app._urlSessionId).toBeNull();
});
@@ -234,7 +234,7 @@ describe('a #session=<id> link that is still waiting', () => {
app._selectUrlSession();
vi.advanceTimersByTime(waitMs - 1);
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true, leaveTiles: true });
expect(app._urlSessionWaitTimer).toBeNull();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();