Commit Graph
1207 Commits
Author SHA1 Message Date
Codeman maintainer bd3c368512 fix(header): keep open Tiles and Split pressed in the boxed header styles
#538's Compact and Tiles header styles give every header icon button a box
through `html[data-header-stats] .header-right > .btn-icon-header` (0,3,1,
hover 0,4,1), which sets background, border and colour. That outranks the
open-state accent of #561's Tiles button (`.tiles-open`, 0,3,0) and of the
Split button (`.split-open`), hover included, so under the default Tiles
style an open grid or split looked exactly like a closed one and nothing
said the next click would close it.

The boxed styles now restate the pressed look for both buttons, hover
included (accent fill, accent border, accent ink), as comma-grouped
selectors built on the box rule's own selector so they always outrank it.
Classic is untouched and keeps the buttons' own rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer 42147d30c0 fix(header): keep the header stats styles out of the glyph motion
#538's Compact and Tiles header styles carried two glyph rules of their own:
a `transform var(--transition-smooth)` transition on every header button's
svg, and `.btn-settings:hover > svg { rotate(45deg) }`. #561 had meanwhile
dropped the global button rotate and moved the hover motion onto the glyphs
(gear turn, folder, Tiles squares) behind `(hover: hover)` and a
prefers-reduced-motion off switch. The #538 rules out-specified those
guarded rules (they reach the glyph through `html[data-header-stats]
.header-right`), so under the default Tiles style a reduced-motion user
still saw the gear turn, a touch tap left it stuck at 45deg, and the 0.2s
ease replaced #561's spring.

Both rules go; #538 keeps only its glyph sizes, and the guarded motion now
applies the same in all three header styles. The hover test scanned only
selectors naming .btn-icon-header, which is how this slipped through: it
now scans every rule that reaches a header glyph and pins the one spring
transition and the hover guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer e308e99f05 fix(tabs): wrap case clusters box by box and never route a lineage line through a tab
With tabArrangement 'case' at 1440px (nine tabs in four cases), the lineage
routes ran horizontally through the middle of tabs, cluster labels and box
borders. #538's clusters are `max-width: 100%` boxes that may shrink, so in
the one-line strip every box squeezed and wrapped inside itself (a one-tab
case's swatch alone on a line above its tab). The strip then never
overflowed, so updateTabOverflowMode() never wrapped it, and #544's routing
room (`.lineage-tree.tabs-auto-wrap`: row gap and spine channel) never
applied. computeLineageRows grouped the squeezed tabs by top alone into rows
that overlap (10-40, 20-50, 42-74), and gapUnder put the first row's gap at
y 30, inside the tabs below it.

- On the desktop header strip a cluster keeps its width, so clusters that do
  not fit overflow and the strip wraps box by box; the boxes' own gaps read
  --lineage-row-gap, so the routes run between box rows and through the
  spine channel. A case wider than the whole strip still wraps inside its
  box, and now wraps the strip too (`_tabClustersWrapInside`, the new
  `innerWrap` input of shouldAutoWrapTabs), so its inner rows get the gap.
- computeLineageRows never returns overlapping rows: tabs whose spans
  overlap are one row. gapUnder returns null when there is no real gap, and
  a route with no gap is not drawn. A final pass drops any route with a
  segment inside a tab, so no arrangement can draw a line through a tab
  (a layout without the room loses lines instead). Pinned with the squeezed
  rects measured live.

At 1920px, where the clusters fit on one line, nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer 382d7dd406 fix(tabs): route the lineage spine between the state labels and the tabs
With tabArrangement 'state' (the default) and lineage lines on (the desktop
default), the spine ran straight through the WORKING / WAITING / NEEDS YOU
label of every row it passed. #538's
`.session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)` pads the
strip by the label gutter and pulls each label to the strip's edge with a
negative margin; it outranks #544's `.lineage-tree.tabs-auto-wrap`
`padding-left: 20px`, so the spine channel disappeared, and the spine,
anchored at the strip's left edge (computeLineageTree), sat in the label
column.

The channel now opens BETWEEN the label column and the tabs: with lineage,
the state strip pads by gutter + --lineage-spine-channel, every label keeps
its column at the edge (still under the brand) and hands the channel back
after itself, and the lead label still starts right after the brand.
computeLineageTree takes the channel's left edge as `spineLeft`, which
session-lineage.js reads back from the padding the CSS laid out (content edge
minus the channel), so the two cannot drift; without it, or where the channel
is the padding itself (classic, ledger, case), the spine stays at the strip's
edge exactly as before. The spine's clamp now only considers tabs in rows the
spine can run beside (every row after the first), so a first row that starts
left of the channel (a quiet idle group right after the brand) no longer
drags the spine back over the labels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer f5acf19a11 fix(tabs): one lineage row gap for every tab arrangement, no double gap at state breaks
#544 reserves a 12px row gap for the lineage routes on
`.session-tabs.lineage-tree.tabs-auto-wrap`, and #538's arrangements set
their own row spacing at an equal or higher specificity later in the file:

- The ledger grid's `gap: 4px 6px` and the case strip's `gap: 6px` won, so
  the lanes packed onto the cell borders (y 39/40/41 in a 4px gap).
- In the state rows every `.tab-triage-break` is a zero-height flex line of
  its own, so each group boundary cost two row gaps: rows 54px apart instead
  of 42, and the header 12px taller per group (including a trailing one after
  the last group). A negative margin on the break cannot cancel it, because a
  flex line's cross size is clamped at zero (measured in Chromium).

The lineage row gap is now one custom property, `--lineage-row-gap`, set only
by the lineage rule. The ledger and both cluster gaps read it with their own
fallback, and the wrapped state strip spaces its rows with a bottom margin on
every item except the breaks (row-gap 0), its last row's margin replacing the
bottom padding. Rows are now one gap apart in every case, with lineage and
without (4px then, where a group boundary used to be 8px).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer 3904428a4f fix(terminal): keep the terminal resize observer alive across SSE init
initTerminal() creates the ResizeObserver on #terminalContainer once per page,
and _resetAllAppState() (run by handleInit on EVERY SSE init, page load
included) disconnected and dropped it. From the first init on, only a window
resize refit the terminal, so anything that resized just the terminal box left
xterm at its old row count with the bottom rows clipped behind the toolbar.

Pre-existing since the March app.js module split, but this release makes it
constant: #538's state rows grow and shrink the header whenever a session
starts or stops working, and #544 reserves lineage room when the first child
appears. Measured on the beta: header 115 -> 170 px, container 743 -> 688 px,
xterm stayed at 35 rows (32 fit) until a tab switch. initTerminal() already
disconnects any previous observer before creating one, so the reset has
nothing to clean up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:06:53 +02:00
Codeman maintainer aa8e06c162 fix(toolbar,mobile): #428 landing follow-ups
Two pieces the #428 merge left out or lost:

- Spacing: the removed #shellCount stepper's 0.25rem margins were the only
  space between Run Shell and the case picker (the desktop .toolbar-group has
  gap: 0), so the two touched. The case picker now keeps the same 4px itself
  above 768px; at 768px and below mobile.css already spaces the group with gap.
- The phone case sheet's keyboard lift from #428's search commit. #488 shipped
  the sheet's search field without it, so on iOS (which does not shrink the
  layout viewport) the keyboard opens over the sheet and hides its own search
  box. KeyboardHandler now lifts an open sheet like the toolbar (translateY on
  phones, bottom on iPads), resetLayout() clears any sheet, and the list is
  capped while the keyboard is up so the search row and Create stay on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:01:45 +02:00
Codeman maintainer 9b28c277f0 fix(settings): keep shortcutOverrides out of the settings PUT
The Shortcuts tab stores overrides in the per-device localStorage blob, and
saveAppSettings() carries them over from the previous blob, but the strip
before the PUT never removed them. SettingsUpdateSchema is .strict() and does
not declare the key, so once a device had any override (even the empty {}
that Reset leaves behind) every App Settings save got a 400 and no synced
setting reached the server again, while the toast still read "Settings
saved" (_apiPut resolves on a 400). Pre-existing, but #560 now points users
at the Shortcuts tab to bind Close Session again, so it would be hit often.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:00:10 +02:00
Codeman maintainer f7a41b3fae Merge #428: one toolbar instance stepper, not two
Only the stepper half of #428 lands here. Its other half, the phone case
picker search, shipped separately in #488 (1.33.1) and is kept as it is on
master, so every file of this merge starts from ours and only the stepper
changes are ported onto it:

- index.html: the second `− 1 +` group (#shellCount) after Run Shell is gone.
- session-ui.js: incrementShellCount/decrementShellCount are removed and
  runShell() reads the toolbar's one stepper through _readTabCount(), the
  helper master grew since #428 was opened (same clamp and absent-element
  fallback as #428's _toolbarInstanceCount()).
- run-mode-ui tests stub #tabCount instead of #shellCount, plus #428's
  "toolbar instance count" block adapted to _readTabCount() and a markup check.
- wiki: "the instance counter", singular.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:45:12 +02:00
Codeman maintainer d630e62114 Merge #538: Tab Layout (by state, by case, ledger, classic) and three header stats styles (Discussion #426) 2026-10-08 19:44:22 +02:00
Codeman maintainer b1def488c4 Merge #544: lineage lines as routed trees, every family shown, the selected one emphasized 2026-10-08 19:44:20 +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 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 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 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 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 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 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 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 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 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 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 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