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
Codeman maintainer bcdccd14c4 fix(shortcuts): Ctrl+W no longer closes a session
Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.

Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 15:46:49 +02:00
Codeman maintainer 9e032bdc3e feat(split): Pane B reconnects as soon as the server is back
When SSE comes back after a server restart, handleInit's reconnect
branch already re-opens the primary pane's socket; it now also calls
the split pane tile's reconnectNow(), so Pane B no longer waits out its
backoff (up to 10 s between tries) after every deploy. Live: Pane B was
back 4.6 s after the server process respawned, i.e. as soon as it
listened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:03:09 +02:00
Codeman maintainer 497711a05e docs: Pane B is a TerminalTile (reconnect, exactly-once input, focus)
CLAUDE.md, architecture-invariants#split-pane-sessions and the split-pane
spec described Pane B as having no reconnect, seq-less input and xterm's
own Ctrl+V. Updated for TerminalTile (terminal-tile.js, load order 7.4):
reconnect and stop codes, the input-socket map and which input is kept
out of the persisted queue, image paste, the geometry rules, and
_focusedPane() with its Ctrl+W exception. The tile-grid spec now records
PR 1 as built (no key handler factory; scheduleLoad and scrollback move
to PR 2 with their first user).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:46:33 +02:00
Codeman maintainer fe9b209f67 feat(split): terminal shortcuts, voice and paste follow the focused pane
With the split open, every app-level terminal action resolved against
Pane A: Ctrl+L typed into Pane B cleared Pane A's display while xterm
sent the ^L into Pane B's PTY, and Ctrl+Shift+R restored Pane A's size.

_focusedPane() now answers with the terminal focused LAST (a mic or
header click moves DOM focus but not the user's pane): Pane B claims it
from its own textarea's focus, the primary terminal's focus gives it
back, and a destroyed pane never holds it. Ctrl+L, Ctrl+Shift+R, voice
dictation and image paste act on the focused pane. Ctrl+W deliberately
still closes the active session: it kills with no confirm, so moving it
is an owner decision (docs/tile-grid-plan.md, decision 5).

Also destroys every tile after each terminal-tile-input test: a real
reconnect timer from one test opened a socket in a later one and flaked
under full-suite load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:45:16 +02:00
Codeman maintainer fbd69e62aa feat(split): clickable file paths and image paste in Pane B
- Pane B registers the primary pane's file-path/URL link provider on
  its own terminal, so a path an agent prints there opens the file
  preview or log viewer for Pane B's session (it was plain text).
- Ctrl+V/Cmd+V in Pane B goes through the primary pane's paste trap,
  aimed at Pane B: a pasted image uploads to Pane B's session and its
  path is typed there; text keeps its bracketed-paste markers. xterm's
  default handled text only.

Tests pin both the tile wiring and the targeted link provider itself
(registered on the target terminal, opening against the target's
session at click time, the primary's tap-path provider untouched).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:20:25 +02:00
Codeman maintainer a54ad81684 fix(split): Pane B and its PTY never disagree about size (#464)
- Font size, family and weight changes refit Pane B AND tell its PTY.
  They used to reflow the xterm only, leaving the CLI wrapping at the old
  column count, the garbled-redraw class #464 fixed for the primary pane.
- The resize frame reports the size the xterm actually holds, with no
  40x10 floor (the divider's 20% clamp leaves about 28 columns), skips
  an unchanged size, and is always re-sent on a fresh socket so it
  re-registers as a desktop viewer.
- The server's {t:'zc'} geometry report is handled: a different column
  count is adopted, rows stay local, using the primary pane's own
  reconcilePtyGeometry verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:07:40 +02:00
Codeman maintainer 0ec17633ba feat(split): Pane B reconnects after a drop instead of staying dead
A Codeman restart (every deploy) or a network blip used to leave Pane B
dead, with a marker asking the user to close and reopen the split.
TerminalTile now reconnects:

- A transient close reconnects on the primary pane's backoff ladder
  (CodemanWsReconnect) plus jitter; the attempt count resets only on a
  successful open. The redelivery sweep's forced close (1005) counts as
  transient.
- On reopen the closed state is cleared before the buffer refresh that
  closes the output gap, so no stale marker lands under a healthy pane.
- 4003/4004/4009/4010 stop the pane for good and report once through a
  new onExit(code) callback; the marker says why.
- Sockets are replaced race-free: the old one is detached before a new
  one opens, and every handler ignores events from a socket that is no
  longer current. destroy() cancels a pending reconnect.
- reconnectNow() lets an owner skip the backoff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:54:57 +02:00
Codeman maintainer 5e3dbf2057 feat(split): Pane B input goes through the exactly-once queue
TerminalTile used to send every xterm onData chunk as a bare {t:'i'}
frame: no seq, no ACK, silently dropped while its socket was down, and
typing never acknowledged the session's idle alert. Keystrokes and
pastes now go through app._sendInputAsync over the tile's own socket,
registered in the input-socket map while open (HTTP fallback while
not), so they are ACKed, persisted until delivered, redelivered after a
drop, and the ACK clears the idle alert.

What xterm generates on its own stays out of that persisted queue: a
query reply (DA/CPR/OSC) is dropped, as the primary pane drops it, and a
focus or mouse report goes out once via _sendInputEphemeral. The tile's
socket carries the tab identity with a :tile suffix so it can never
evict the primary pane's socket.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:41:10 +02:00
Codeman maintainer d1bbb4cc26 refactor(split): move the pane class into terminal-tile.js as TerminalTile
Pure move and rename, no behavior change. The split pane's second
terminal (SplitTerminalPane) moves out of terminal-split.js into its own
terminal-tile.js (load order 7.4) as TerminalTile, so the tile grid can
reuse it. terminal-split.js keeps the split orchestration (picker,
divider, auto-collapse) and constructs a TerminalTile for Pane B.

Tests follow the class: split-pane-terminal-unit becomes
terminal-tile-unit, and the Shift+Enter guard and the two browser suites
read terminal-tile.js / window.TerminalTile. The browser suites match
master (one pre-existing environmental failure in both).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:28:08 +02:00
Codeman maintainer e2f56dc077 fix(input): image paste and dictation land in the session they started in
Both read activeSessionId at the END of an async gap, so switching tabs
in between sent the input to the wrong session:

- An image upload inserted its paths with sendInput(), which re-reads
  activeSessionId after the uploads finish. It now inserts into the
  session the batch was uploaded to, through the same durable queue.
- Voice dictation read the target when the transcript arrived and again
  when the send button or the compose overlay's Send was pressed. The
  target is now captured in start() (via _focusedPane()), the local-echo
  overlay is only used when that target is the active session, and a
  target that closed meanwhile gets a toast instead of a 404.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:09:51 +02:00
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:

- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
  exactly-once input queue, its ACK handling and the redelivery sweep now
  deliver over any registered socket bound to a session, not only
  this._ws. ACKs are routed by the receiving socket's session; silence is
  judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
  copyTerminalSelection and _handleImagePaste take an optional target
  terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
  (primary only, for now); _forEachTile() replaces the _splitPane special
  cases in the font, family, weight, skin and resize paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:55:05 +02:00
Codeman maintainer f1537a7887 docs: tile grid design spec (two-PR plan: TerminalTile foundation, then the grid)
Plans a grid of up to nine live sessions side by side. All tiles are
equal TerminalTiles, the main terminal is parked while the grid is
open, and activeSessionId follows the focused tile. The split pane stays
and shares the tile class. PR 1 builds the seams and TerminalTile, so
the split's second pane gains reconnect, exactly-once input, links,
image paste and focus-following shortcuts. PR 2 adds the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:35:14 +02:00
Codeman maintainer ac94f339ac chore: version packages (1.35.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:01:26 +02:00
Codeman maintainer 88f5a43a9f fix(cases): bounded path probe landing fixes (#516)
- hooks-config: a probe the bulk cap refused gets ONE bounded re-probe past the
  cap (probeBeforeTouching), and whatever is still unknown is skipped. The
  per-spawn hook and statusLine helpers used to fall back to an unbounded
  lstat/readFile there, which on a dead workspace never settled and could take
  the last threadpool workers (and hang the boot hook sweep). New test: cap
  engaged, stat/lstat/readFile hanging on two more paths; both helpers return.
- describeUnknownPath()/unknownPathReason(): POST /api/sessions, quick-start and
  GET /api/cases/:name now say a folder was not checked (other mounts are still
  not answering) instead of blaming a healthy folder at the stall ceiling.
  errorCodes unchanged.
- #535 x #516: Create in a custom folder probes the parent through the bounded
  probe before realpath/stat/lstat/readdir touch it; an unknown parent is 422
  OPERATION_FAILED (UNREACHABLE) within the probe timeout. New test.
- Docs: MAX_STALLED default is 2 (follows UV_THREADPOOL_SIZE), CaseInfo
  .unreachable covers a refused probe, the boot sweep skips an unanswering
  workspace, a CLAUDE.md gotcha for bounded probes, verbs.md documents the 422
  (plugin mirror synced), api-reference documents the custom-folder 422.
- Tests: the launcher case-lookup describe is no longer nested in the Grok
  block, and the cap-below-ceiling test no longer depends on an inherited
  UV_THREADPOOL_SIZE / CODEMAN_PATH_PROBE_MAX_STALLED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 20:00:30 +02:00
Codeman maintainer aca23aa404 chore: changeset wording, the in-flight rename repaint is #526's own 2026-10-05 19:53:10 +02:00
Codeman maintainer f16f294576 chore: add #516 to the landing changeset 2026-10-05 19:53:03 +02:00
Codeman maintainer 737a2527d6 fix(tabs): report an edit dropped behind an in-flight save, respect the group cap (#525 landing)
- createEditCoordinator's finally block rebases the edits queued during a write; one that the write's 409 made inapplicable was dropped with no toast. It is now reported once, like the main loop and adoptExternal do (found by the PR bot's re-review; regression test fails without it).
- At the 32-group server cap the row and group menus no longer offer a new group, which could only fail with an untranslated 'group limit reached'. MAX_GROUPS is exported from tab-layout-browser.js.
- CLAUDE.md names the pagehide keepalive as the one deliberate exception to 'never PUT the layout outside the coordinator'.
- The Dashboard wiki page describes tab groups in the vertical rail row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 6f88e40b77 chore: changeset for the #525, #526, #534, #535, #536, #537 landing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 2c38e77f8a fix(git-status): landing fixes (#537)
- A cached list of repositories below a folder is re-checked against the
  Docker case workspaces as they are now, so a repository linked as a Docker
  workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
  output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
  (unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
  that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
  repository list (findWorkspaceRepo) and refreshes only that repository,
  instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
  any full status, so an unrelated repository above the workspace costs one
  process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer cf26853390 fix(doctor): Diagnostics landing fixes (#536)
- The doctor now judges candidates like the run mode's resolver: the PATH
  hit, then each search dir, each one version-checked on its own and
  skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides
  the real one in a search dir). A search-dir candidate must be an
  absolute path to an executable regular file, so a relative dir or a
  file without the x bit reads as missing, as it does in the Run menu.
  `isExecutableRegularFile` is exported from cli-executable-resolver.ts
  and reused rather than copied.
- Every doctor probe passes killSignal: 'SIGKILL'; a --version that
  ignores SIGTERM held the probe for its full runtime (15 s vs 5 s
  measured with a TERM-trapping script).
- README no longer claims parity with the Run menu or nvm prefixes.
- The Diagnostics panel marks a missing optional tool with ○, a missing
  required one with ✗, as the terminal doctor does.
- expandSearchDir names its twin, expandHome() in cli-resolver.ts.
- test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only
  `which` and `node`, and a clis.json that drops the registry's absolute
  search dirs, so it never runs the machine's installed agent CLIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 192a5994e0 fix(cases): custom-folder create landing fixes (#535)
- Route test hygiene: each test works in its own mkdtemp folder, every
  deletion goes through safeRmHomeTree, and the suite refuses to start
  outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
  delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
  resolved home, data dir and system roots (home reached through a link,
  macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
  directory, pointing at plain Create New (it would list twice, and
  deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
  lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
  "under ~/codeman-cases" blurb and name hint change while a custom
  folder is ticked, a "/" parent previews and sends /<name> instead of
  an empty path, and the new labels have zh-CN entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer 566365e127 test(tabs): static CI guard that no rail or sidebar clamp out-ranks the rename unclamp (#534 landing)
The behavioural check for the detailed-rail rename clamp (#534, #526) lives in test/inline-rename.test.ts, a browser suite the CI gate does not run. This pins the cascade from styles.css itself, from computed selector specificity and source order, so a later clamp rule cannot silently out-rank the shared unclamp again. Mutation-checked: deleting the detailed-rail twin fails exactly that case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 19:51:59 +02:00
Codeman maintainer ff94637718 Merge pull request #516 from aakhter/pr/bounded-path-probe
fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server

# Conflicts:
#	src/web/routes/case-routes.ts
2026-10-05 19:51:43 +02:00
Codeman maintainer 2063d15c20 Merge pull request #537 from opticon454/feat/git-status-indicator
feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work

# Conflicts:
#	config/test-suites.ts
#	docs/api-reference.md
2026-10-05 19:51:43 +02:00
Codeman maintainer fed3a0897a Merge pull request #536 from opticon454/feat/doctor-in-settings
feat(settings): codeman doctor in Settings → System → Diagnostics

# Conflicts:
#	config/test-suites.ts
2026-10-05 19:51:42 +02:00
Codeman maintainer d9c760609f Merge pull request #535 from opticon454/feat/case-custom-path
feat(cases): create a new case in a custom folder
2026-10-05 19:51:41 +02:00
Codeman maintainer 74e8015783 Merge pull request #534 from opticon454/fix/rail-rename-unclamp
fix(rail): keep the inline rename editor unclamped in the detailed tab rail

# Conflicts:
#	src/web/public/styles.css
#	test/inline-rename.test.ts
2026-10-05 19:51:41 +02:00
Codeman maintainer fea5626efc Merge pull request #526 from aakhter/pr/grouped-rail-rename-fixes
fix(tabs): grouped rail interaction fixes for inline rename
2026-10-05 19:51:40 +02:00
Codeman maintainer bd109d3b16 Merge pull request #525 from aakhter/pr/grouped-rail-edit
feat(tabs): edit groups in the vertical rail
2026-10-05 19:51:40 +02:00
Aamer Akhter 9fa44109b8 fix(cases): keep deleted workspaces deleted, cap pastCap, scope stalls to network mounts
- applyWorkspaceHooks: an "unknown" probe that is not near a stalled path
  (refused by the stall cap, or an unexpected stat error) no longer reads as
  "go ahead". It checks existence with pathExistsForWrite first, so a deleted
  workspace is not recreated by the mkdir -p in ensureCodemanHooks.
- pastCap gets a hard ceiling, PATH_PROBE_STALL_CEILING = UV_THREADPOOL_SIZE
  (default 4) minus one, so explicit requests against several dead paths can
  never take the last libuv worker. The bulk cap now defaults to one below the
  ceiling (2 with the default pool), leaving a slot for an explicit request.
- A stall widens to its mount only for network and FUSE filesystem types read
  from /proc/self/mounts; on a local mount (a path typed under a local /home
  that reaches a NAS through a symlink) it narrows to the stalled path.
- GET /api/cases/:name probes CLAUDE.md with pastCap, like the folder probe.
- Comment in config/path-probe.ts describes the mount-scoped stall.
2026-10-05 09:51:27 -04:00
Aamer Akhter 3e768e1b5b fix(tabs): show the in-flight name when an unchanged rename is confirmed 2026-10-05 09:47:08 -04:00
Aamer Akhter 92f51fa619 fix(tabs): inline rename review fixes
Reopening the editor over a rename still in flight filled it from the name
the server had not replaced yet, so dismissing it (blur commits) queued the
old name behind the new one and undid the rename. The queue now records the
newest queued name per session (_inlineRenamePending, cleared with the queue
entry), and a reopened editor takes its prefix, input and "unchanged"
comparison from it. An untouched confirm sends nothing more.

A failed write only toasted while its editor was still current. The queue
reports the failure itself now, and the editor only puts its label back.

One rejected task blocked every later rename of that session until reload.
Each task now chains from a settled predecessor, the local apply after a
successful PUT is guarded, and the queue entry is cleaned up on either
outcome.

The rail and sidebar editor's 4rem floor moves from a stylesheet
`!important` into the inline min-width startInlineRename already writes per
layout (0 in the header strip, 4rem in the rail and sidebar).

Tests: the reopened-editor case now expects only "First" to be sent; new
cases cover a 500 answered after the editor is gone and a throw in
updateSubagentParentNames; the long-prefix check runs in the sidebar and
detailed sidebar too and asserts the inline floor; the header strip editor
keeps min-width 0.
2026-10-05 09:45:24 -04:00
Aamer Akhter 06aba94ef1 fix(tabs): grouped rail interaction fixes for inline rename
Two problems with renaming a tab in the vertical rail, both easier to hit now
that the grouped rail has its own inline editor beside the session one.

Writes. A committed rename PUT its name and only applied the answer if the
same editor was still open when it came back. Reopening the editor before the
PUT answered (F2 or right-click again, or starting a group rename, which
cancels the session editor) threw the confirmed name away, so the tab kept
showing the old name until an SSE frame happened to repaint it. Two quick
renames also raced as two concurrent PUTs. Inline renames now go through a
per-session queue: one PUT at a time in the order they were made, the
confirmed name applied to app.sessions whatever happened to the editor, and
the "already that name" check made when the write runs rather than when Enter
is pressed, so confirming the name still on screen over a write in flight is
a real write.

Layout. The editor (a flex row) could not shrink below the input's intrinsic
width, so a long w<n>-<case> prefix pushed the label past its row: the prefix
slid out of view in the detailed rows and the input was clipped mid-word in
the compact rail. The label now has min-width 0, the prefix gives way first
(down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact
rail the row's adornments step aside while the name is edited. The detailed
rows' three-line clamp also outranked the shared unclamp rule, which is what
the existing "unclamped editor" browser test caught; it is restated there.

Header strip, sidebar and flat-rail markup are unchanged.

Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for
simple and detailed rows; a write-ordering describe covers ordering, a
reopened editor cancelled over a confirmed write, a re-sent unchanged name and
a group rename taking over; a long-prefix describe drives real rows from a
live session in simple, detailed and compact rails.
2026-10-05 09:45:24 -04:00
Aamer Akhter ea80c5f471 docs(tabs): correct the tab-layout constructor comment 2026-10-05 09:45:06 -04:00
DevvynandClaude Sonnet 5.5 0c4bb5169f fix(git-status): address #537 review (docker workspaces, gone upstream, in-flight reset, docs)
- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 11:49:38 +08:00
DevvynandClaude Sonnet 5.5 b2423c90ce test(git-status): real-git rename and conflict diffs, tree setting round-trip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:57:30 +08:00
DevvynandClaude Sonnet 5.5 4152ee1015 test(doctor): minimal-PATH searchDirs regression and the non-admin gate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:56:10 +08:00
DevvynandClaude Sonnet 5.5 90dfa328a8 docs(cases): README entry for creating a case in a custom folder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:50:47 +08:00
DevvynandClaude Sonnet 5.5 294ce0a667 docs(doctor): README entry for Settings → System → Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:50:24 +08:00
DevvynandClaude Sonnet 5.5 58b52fceff docs(git-status): document the diff view, folder grouping and collapsed repositories
README, Working With Files (new Git changes section), Settings Reference, The Dashboard and the changeset.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:49:58 +08:00
DevvynandClaude Sonnet 5.5 4fc75d494f feat(git-status): repositories start collapsed when several are listed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:46:35 +08:00
DevvynandClaude Sonnet 5.5 948c7c54dd feat(git-status): group changed files under collapsible folders (setting, default on)
The Git window shows each group's files under their folders, collapsed until clicked, with single-child folder chains merged and open folders surviving the refresh. App Settings → Bottom bar → 'Git status: group files by folder' (per device) switches back to the flat list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:41:03 +08:00
DevvynandClaude Sonnet 5.5 cd9218c23e feat(git-status): click a file in the Git panel to see its diff
Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:14:07 +08:00
DevvynandClaude Sonnet 5.5 db9a39405b fix(doctor): resolve CLIs via searchDirs, single-flight runs, admin-gate the group (#536 review)
- doctor probes each CLI's discovery.searchDirs when which misses and runs --version on the resolved path, so a service with a minimal PATH no longer reports installed CLIs as missing
- GET /api/doctor shares one in-flight run per category
- Diagnostics group hidden from non-admins in multi-user mode (_applyDoctorAdminGate)
- 500 uses INTERNAL_ERROR; a killed child reports 'timed out after 30 s'
- browser test blocks service workers so page.route() is reliable
- wiki: Diagnostics sentence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 09:11:10 +08:00
Aamer Akhter d1bfbb4fcf fix(cases): tell an unreachable path from an absent one, scope the stall cap
The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
2026-10-04 20:30:40 -04:00
Aamer Akhter bd4a1e9886 fix(tabs): grouped rail editing review fixes
- Pointer drag: a press released outside the rail no longer lingers. The
  release is heard on window while a press is pending, a move with the
  primary button up cancels it, a new press cancels any previous drag, and
  an existing Escape listener is removed before another is added, so no
  orphaned capture listener can swallow Escape before the terminal.
- Inline group rename: a commit by blur leaves focus where the user put it;
  Enter and Escape still return focus to the header.
- A failed layout read while edits are pending keeps the held layout and the
  editor and re-reads once the write settles, so a 409 is still rebased.
  Dropping unsaved work now always says so in a toast.
- "Move to <group>" quotes the group name (with a matching zh-CN pattern), so
  a group named "New group" or "ungrouped" no longer reads or translates like
  the fixed entries.
- The group menu glyph stays visible under (hover: none).
- The sessionStorage replay copy carries { owner, baseVersion, savedAt } and is
  ignored for another owner, after 60 s, or against an older layout. A move
  with no anchor carries no index, so a replay keeps the row last.
- A 400 that survives the re-read is reported as "Could not save tab groups."
- closeTabRailActionMenu() no longer removes the group menu's DOM.
- Cancelling "Delete group" returns focus to the header.
- Stale comments updated.
2026-10-04 20:21:44 -04:00
Aamer Akhter 97cb5b5799 feat(tabs): edit groups in the vertical rail
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.

Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
  reorderGroup, moveRef) applied to the rail at once, mirroring the server
  model: a moved session takes the sessions that still follow it, and a
  hand-moved child is marked placement 'manual'. normalizeLayout now keeps
  placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
  made in the same turn share a write; edits made while one is in flight go
  out on the version it returns. A 409 replays the operations onto the
  layout the server returned and retries (bounded); an operation that no
  longer applies is dropped and reported. A 400 re-reads first; any other
  failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
  that changes nothing.

Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
  Ungrouped and Move to new group in the vertical rail. Before the first
  group exists it offers only "Move to new group", which is how a flat rail
  becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
  a hover glyph (a non-focusable aria-hidden span, so the treeitem still
  holds no interactive child): Rename, New group, Move group up/down,
  Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
  plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
  back to its row or header), a pointer outside, Tab, focus leaving it, a
  resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
  the current editor releases the render guard. Enter or blur commits,
  Escape cancels, IME composition keys are left to the IME, and the label
  becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
  row or into a group, a header drag reorders groups. Escape cancels; the
  click that ends a drag neither selects nor toggles. The flat rail and the
  header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
  once it settles; a read otherwise rebases unsaved edits. On pagehide,
  unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
  replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.

Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.

Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
2026-10-04 20:10:32 -04:00
Saqeb Akhter 00b935abe6 fix(cases): bound linked-workspace path probes so an unreachable mount cannot freeze the server
A linked case can live on a network mount. When that mount goes away, a
hard mount makes stat() wait indefinitely, and the existsSync() probes in
the case routes and the workspace hook/statusline helpers ran on the event
loop, so a single GET /api/cases (or a session create in that workspace)
froze the whole web server until the mount came back.

Add boundedPathExists() (src/utils/bounded-path-probe.ts): an async stat
that answers "absent" after 1.5 s, shares one in-flight probe per path,
remembers a timed-out path until its stat finally settles, and refuses to
start new probes while two stalled ones still hold libuv threadpool
workers. Route the read-side probes in case-routes.ts and hooks-config.ts
through it. The settings writers in hooks-config.ts use an async lstat
that treats only ENOENT as missing, so an unreachable workspace is never
mistaken for an empty one and has its settings recreated.
2026-10-04 20:08:05 -04:00
DevvynandClaude Sonnet 5.5 bfc164a262 feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work
Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is
read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its
lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files,
commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window.

Which repositories: the enclosing one when there is one; otherwise every repository up to two
levels below the working directory (capped, skipping dot-folders and node_modules, never
following symlinks), each in a collapsible section, with the indicator summing them. A repository
that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is
ignored. Git-supplied text is only ever written with textContent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-05 07:19:11 +08:00
DevvynandClaude Sonnet 5.5 1b89d7a387 docs(doctor): api-reference and changeset
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:55 +08:00
DevvynandClaude Sonnet 5.5 d9174a7a03 feat(settings): codeman doctor in Settings -> System -> Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:40 +08:00
DevvynandClaude Sonnet 5.5 4150707a6b feat(cases): create a new case in a custom folder
POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:03:10 +08:00
DevvynandClaude Sonnet 5.5 6944f842c7 fix(rail): keep the inline rename editor unclamped in the detailed rail
The card-row rule (line-clamp: 3) out-ranked the shared unclamp-while-renaming
override. Restate it at the same weight; the test now covers both rail layouts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:02:47 +08:00
152 changed files with 30805 additions and 1158 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.34.0",
"version": "1.35.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+22
View File
@@ -1,5 +1,27 @@
# aicodeman
## 1.35.0
### Minor Changes
- 6f88e40: ### Thanks
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
## 1.34.0
### Minor Changes
+14 -9
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -444,8 +444,11 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
## More Features
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
+4
View File
@@ -21,6 +21,7 @@
export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -35,6 +36,9 @@ export const BROWSER_TEST_GLOBS = [
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/case-custom-path.browser.test.ts',
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
+82
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,
@@ -700,6 +740,44 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).
## Creating a case in a custom folder
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
`data` is `{ state, repos, reposTruncated, checkedAt }`:
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
@@ -748,6 +826,10 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
## Diagnostics
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
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
+8
View File
@@ -4,6 +4,14 @@
**Author**: Claude (session with Tim), 2026-09-15
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
> reconnects after a drop, delivers input exactly once, has clickable paths
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
> closes anything (Close Session has no default key).
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
## Problem
Codeman's terminal area shows exactly one active session (pane) at a time —
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
+20 -1
View File
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+W` | Kill the active session. |
| `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
use it to delete the previous word. **Close Session** has no key by default; close a session
from its tab, or bind a key to it in App Settings → Shortcuts.
## Terminal
| Shortcut | Action |
@@ -36,6 +39,22 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
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 |
+2 -2
View File
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
| To do this | Do that |
| ------------------------- | ------------------------------------------------------------------- |
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
| Close one session | `Ctrl+W`, or the tab's close control. |
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
| Stop everything | `tmux -L codeman kill-server`. |
+17 -4
View File
@@ -57,14 +57,23 @@ 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.
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
by folder** (per device, on by default) shows changed files under collapsed folders in that
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.
@@ -145,8 +154,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
**Users** administration entry is injected here.
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
## Session Options
@@ -181,6 +192,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
## Gotchas
+10 -2
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
| Close | `Ctrl+W` |
| Close | The tab's close control (no key by default) |
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
Tabs can also be dragged to reorder.
@@ -118,12 +118,20 @@ 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. |
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
## Bottom bar
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
Panels → Bottom bar**. Click it for the Git window. See
[Working With Files](Working-With-Files#git-changes).
## Connection state
The dot in the header is the quick read. Two louder surfaces exist because a cached page
+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.
+35
View File
@@ -163,6 +163,41 @@ HEIC images from an iPhone are converted to JPEG on the way in.
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
surface as an artifact attachment rather than a path you have to go and find.
## Git changes
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
conflicts, `✓` when everything is committed and pushed.
Click it for a draggable window, in the style of the File Viewer:
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
not on remote"), which counts every commit on no remote rather than showing a green tick.
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
list of full paths instead.
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
coloured. Staged files show index versus last commit, not-staged files show working tree
versus index, untracked files show as all additions and deleted files as all removals.
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
note instead, and a diff over 400 KB is cut short.
- A session folder that holds several projects gets one collapsible section per repository
found up to two levels down. They all start collapsed (each summary line shows its branch and
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
in your home folder) is ignored.
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
workspace is skipped even from a local session (a container can write there, and git would run
that repository's own configuration on the host). The
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Gotchas
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
+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)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.34.0",
"version": "1.35.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.34.0",
"version": "1.35.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.34.0",
"version": "1.35.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
one that does not answer or cannot be read (an unreachable network mount, a
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
+3
View File
@@ -95,6 +95,9 @@ Differences from `quick-start` worth knowing before you debug one:
- the id is at `.data.session.id`, not `.data.sessionId`;
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
one that does not answer or cannot be read (an unreachable network mount, a
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
create a replacement for it;
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
`SESSION_BUSY` for the identical condition.
+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.
+21
View File
@@ -7,6 +7,8 @@
* @module config/dependency-registry
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';
@@ -30,6 +32,24 @@ export interface PathResolver {
* there and a false "installed" contradicts the run mode's own resolver.
*/
requireVersionMatch?: boolean;
/**
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
* prefix is invisible to `which` while the run mode, which falls back to the registry's
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
*/
searchDirs?: string[];
}
/**
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
* would pull in the whole resolver chain; keep the two in step.
*/
function expandSearchDir(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}
/** Resolve a Windows-installed app reachable from win32 or WSL. */
@@ -131,6 +151,7 @@ function cliDependencyEntries(): ToolDependency[] {
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
},
},
],
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
*
* A linked case can live on a network mount, and a hard mount that went away makes
* `stat()` wait until the mount comes back. The probe gives up on such a path after
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
*
* Both are env-overridable, in the same style as the other config modules. A slow
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
* a longer timeout. The stall limits follow `UV_THREADPOOL_SIZE` on their own, so a
* server started with a larger pool gets a higher ceiling without further setup.
*
* @module config/path-probe
*/
function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
/** How long a caller waits for one path probe before the answer is "unknown". */
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);
/**
* Hard ceiling on timed-out probes left pending, for every caller, `pastCap` ones
* included: the threadpool size minus one, so a dead mount can never take the last
* worker. libuv sizes the pool from `UV_THREADPOOL_SIZE` (4 when unset). A pool of
* one cannot keep a worker free at all, so the ceiling never drops below one.
*/
export const PATH_PROBE_STALL_CEILING = Math.max(1, (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1);
/**
* Timed-out probes allowed to stay pending before new BULK probes are refused
* (answered "unknown" without a stat). This is a backstop, not the main defence: a
* stalled path on a network or FUSE mount already takes the rest of that mount out
* of probing (a stall anywhere else takes out only the stalled path), so the cap
* only engages once that many UNRELATED places have stopped answering. It defaults
* to one below {@link PATH_PROBE_STALL_CEILING} (2 with the default pool), leaving a
* slot a `pastCap` probe may still use, and is never allowed above the ceiling.
*/
export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
+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);
}
+761
View File
@@ -0,0 +1,761 @@
/**
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
* visible without leaving Codeman.
*
* Split so the parts that matter test without a repo:
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
* `parseCommitLog`
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
* single-flight cache so several tabs polling one repo cost one set of git processes
*
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
* to the outer one, and is not scanned;
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
* symlinks), each reported separately;
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
*
* Rules the code keeps and the tests pin:
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
* refreshing the index, so polling cannot contend with the agent's own git commands.
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
* revision range.
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
* Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
* case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
* the container could have planted that config and git here would run it on the host.
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
* through `redactGitCredentials`.
*
* @module git-workspace-status
*/
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join, relative, sep } from 'node:path';
import { promisify } from 'node:util';
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
/** Max file rows returned. The counts stay exact. */
export const MAX_FILES = 300;
/** Max unpushed commits listed. The count stays exact. */
export const MAX_COMMITS = 50;
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
const CACHE_TTL_MS = 4000;
const CACHE_MAX_ENTRIES = 64;
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
export interface GitFileEntry {
/** Path relative to the repository root, as git reports it. */
path: string;
/** Rename/copy source, when the entry is one. */
origPath?: string;
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
index: string;
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
worktree: string;
kind: GitFileKind;
}
export interface GitCommitEntry {
hash: string;
author: string;
/** Seconds since the epoch. */
time: number;
subject: string;
}
export interface GitWorkspaceStatus {
/**
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
branch: string | null;
detached: boolean;
upstream: string | null;
/**
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
* cloning an empty repository and committing): nothing is tracked.
*/
upstreamGone: boolean;
ahead: number;
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
behind: number;
/** Whether the repository has any remote at all. */
hasRemote: boolean;
counts: {
staged: number;
unstaged: number;
untracked: number;
conflicted: number;
/** Distinct paths that are not committed. */
uncommitted: number;
stashes: number;
};
files: GitFileEntry[];
filesTruncated: boolean;
/** Commits on this branch that no remote has: exact. */
unpushedCount: number;
unpushed: GitCommitEntry[];
checkedAt: number;
}
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
hasRemote: false,
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
files: [],
filesTruncated: false,
unpushedCount: 0,
unpushed: [],
};
export const emptyStatus = (
state: GitWorkspaceStatus['state'],
extra: Partial<GitWorkspaceStatus> = {}
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
// ---------------------------------------------------------------------------
// Pure parsing
// ---------------------------------------------------------------------------
export interface ParsedStatus {
branch: string | null;
detached: boolean;
upstream: string | null;
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
upstreamGone: boolean;
ahead: number;
behind: number;
files: GitFileEntry[];
}
/**
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
* one more NUL-terminated token holding the original path.
*/
export function parsePorcelainV2(text: string): ParsedStatus {
const out: ParsedStatus = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
files: [],
};
let sawAb = false;
const tokens = text.split('\0');
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (!t) continue;
if (t.startsWith('# ')) {
const [key, ...rest] = t.slice(2).split(' ');
const value = rest.join(' ');
if (key === 'branch.head') {
out.detached = value === '(detached)';
out.branch = out.detached ? null : value;
} else if (key === 'branch.upstream') {
out.upstream = value;
} else if (key === 'branch.ab') {
sawAb = true;
const m = /^\+(\d+) -(\d+)$/.exec(value);
if (m) {
out.ahead = Number(m[1]);
out.behind = Number(m[2]);
}
}
continue;
}
const type = t[0];
if (type === '1') {
// 1 XY sub mH mI mW hH hI path
const f = t.split(' ');
const xy = f[1] ?? '..';
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
} else if (type === '2') {
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
const f = t.split(' ');
const xy = f[1] ?? '..';
const path = f.slice(9).join(' ');
const origPath = tokens[++i] ?? '';
out.files.push(...entriesFor(xy, path, origPath));
} else if (type === 'u') {
// u XY sub m1 m2 m3 mW h1 h2 h3 path
const f = t.split(' ');
out.files.push({
path: f.slice(10).join(' '),
index: f[1]?.[0] ?? 'U',
worktree: f[1]?.[1] ?? 'U',
kind: 'conflicted',
});
} else if (type === '?') {
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
}
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
}
out.upstreamGone = out.upstream !== null && !sawAb;
return out;
}
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
const index = xy[0] ?? '.';
const worktree = xy[1] ?? '.';
const rows: GitFileEntry[] = [];
const base = origPath ? { path, origPath } : { path };
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
return rows;
}
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
export function parseCommitLog(text: string): GitCommitEntry[] {
const out: GitCommitEntry[] = [];
for (const record of text.split('\x1e')) {
const r = record.replace(/^\n+/, '');
if (!r) continue;
const [hash, author, time, ...subject] = r.split('\x1f');
if (!hash) continue;
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
}
return out;
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
export const runGit: GitRunner = async (cwd, args) => {
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
// a configured gpg.program to verify signatures.
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
}
);
return stdout;
};
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
const e = err as { code?: unknown; stderr?: unknown; message?: string };
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
return { notARepo: false, message: 'git timed out' };
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
}
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
let statusText: string;
try {
statusText = await git(cwd, [
'status',
'--porcelain=v2',
'--branch',
'-z',
'--untracked-files=normal',
'--ignore-submodules=dirty',
]);
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
}
const parsed = parsePorcelainV2(statusText);
const safe = async (args: string[]): Promise<string> => {
try {
return await git(cwd, args);
} catch {
return '';
}
};
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
const [root, remotes, stash, countText, logText] = await Promise.all([
safe(['rev-parse', '--show-toplevel']),
safe(['remote']),
safe(['stash', 'list', '--format=%gd']),
safe(['rev-list', '--count', ...range]),
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
]);
const hasRemote = remotes.trim().length > 0;
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
const distinct = new Set<string>();
for (const f of parsed.files) {
counts[f.kind]++;
distinct.add(f.path);
}
counts.uncommitted = distinct.size;
counts.stashes = stash.split('\n').filter(Boolean).length;
return {
state: 'ok',
repoRoot: root.trim() || undefined,
branch: parsed.branch,
detached: parsed.detached,
upstream: parsed.upstream,
upstreamGone: parsed.upstreamGone,
ahead: parsed.ahead,
behind: parsed.behind,
hasRemote,
counts,
files: parsed.files.slice(0, MAX_FILES),
filesTruncated: parsed.files.length > MAX_FILES,
unpushedCount,
unpushed,
checkedAt: Date.now(),
};
}
interface CacheEntry<T> {
at: number;
value?: T;
inflight?: Promise<T>;
}
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
/** For tests. */
export function clearGitStatusCache(): void {
cache.clear();
toplevelCache.clear();
discoveryCache.clear();
}
/**
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
* Refresh and expects the truth) but still joins a computation that is already running, which is as
* current as a new one would be.
*/
async function singleFlight<T>(
map: Map<string, CacheEntry<T>>,
key: string,
opts: { now: () => number; fresh?: boolean },
compute: () => Promise<T>
): Promise<T> {
const hit = map.get(key);
if (hit?.inflight) return hit.inflight;
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
const inflight = compute();
map.set(key, { at: opts.now(), inflight });
try {
const value = await inflight;
map.set(key, { at: opts.now(), value });
if (map.size > CACHE_MAX_ENTRIES) {
for (const [k, v] of map) {
if (map.size <= CACHE_MAX_ENTRIES) break;
if (k !== key && !v.inflight) map.delete(k);
}
}
return value;
} catch (err) {
map.delete(key);
throw err;
}
}
/**
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
*/
export async function getGitWorkspaceStatus(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit;
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
}
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
function enclosingRepoRoot(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
): Promise<RepoToplevel> {
const git = opts.git ?? runGit;
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
try {
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
}
});
}
// ---------------------------------------------------------------------------
// Which repositories: the overview
// ---------------------------------------------------------------------------
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
const DISCOVERY_MAX_DEPTH = 2;
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
const DISCOVERY_MAX_ENTRIES = 300;
/** Repositories reported for one workspace. */
export const MAX_REPOS = 12;
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
const DISCOVERY_TTL_MS = 30_000;
/** Folders that are never worth descending into when looking for projects. */
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
/** Status calls in flight at once for one overview: each is several git processes. */
const STATUS_CONCURRENCY = 4;
export interface GitRepoEntry {
/** Folder name of the repository (its root's basename). */
name: string;
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
path: string;
status: GitWorkspaceStatus;
}
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repos: GitRepoEntry[];
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
reposTruncated: boolean;
checkedAt: number;
}
export const emptyOverview = (
state: GitWorkspaceOverview['state'],
extra: Partial<GitWorkspaceOverview> = {}
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return p;
}
};
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
/**
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
* workspaces: a container can write there, so git must not run on its behalf on the host.
*/
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
if (!realRoots.length) return false;
const real = await realOr(path);
return realRoots.some((r) => isWithin(real, r));
}
/**
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
*/
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
if (root === here) return false;
return root === sep || h === root || h.startsWith(root + sep);
}
async function hasDotGit(dir: string): Promise<boolean> {
try {
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
return true;
} catch {
return false;
}
}
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
const DISCOVERY_MAX_SCAN = 5000;
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
let handle;
try {
handle = await fs.opendir(dir);
} catch {
return null;
}
const out: import('node:fs').Dirent[] = [];
try {
for await (const e of handle) {
out.push(e);
if (out.length >= DISCOVERY_MAX_SCAN) break;
}
} catch {
/* a folder that fails mid-read: use what was read */
} finally {
await handle.close().catch(() => {});
}
return out;
}
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
export async function discoverChildRepos(
cwd: string,
excludeRealRoots: string[] = []
): Promise<{ dirs: string[]; truncated: boolean }> {
const found: string[] = [];
let level = [cwd];
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
const next: string[] = [];
for (const dir of level) {
const entries = await readDirBounded(dir);
if (!entries) continue;
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
for (const e of entries) {
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
const child = join(dir, e.name);
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
if (await isInsideAny(child, excludeRealRoots)) continue;
if (await hasDotGit(child)) found.push(child);
else next.push(child);
}
}
level = next;
}
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
}
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
export interface GitOverviewOptions {
git?: GitRunner;
now?: () => number;
fresh?: boolean;
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
}
type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
| { kind: 'children'; dirs: string[]; truncated: boolean };
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
*/
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
// could carry config (a clean filter) that runs on the host.
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
// The enclosing repository is identified before its full status runs, so an unrelated one above the
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
// repositories below.
const top = await enclosingRepoRoot(cwd, opts);
if (top.state === 'error') return { kind: 'error', error: top.error };
if (top.state === 'ok') {
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
return { kind: 'enclosing', root: top.root };
}
// Not inside a repository of this workspace: look below for projects.
const hit = discoveryCache.get(cwd);
let found: { dirs: string[]; truncated: boolean };
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
else {
found = await discoverChildRepos(cwd, dockerRoots);
discoveryCache.set(cwd, { at: now(), value: found });
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
}
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
const dirs: string[] = [];
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
return { kind: 'children', dirs, truncated: found.truncated };
}
/**
* Everything git knows about the session's workspace: the enclosing repository when there is one,
* otherwise each repository found below the working directory. See the module header for the rules.
*/
export async function getGitWorkspaceOverview(
cwd: string,
opts: GitOverviewOptions = {}
): Promise<GitWorkspaceOverview> {
const where = await resolveWorkspaceRepos(cwd, opts);
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
if (where.kind === 'enclosing') {
const primary = await getGitWorkspaceStatus(cwd, opts);
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
const root = primary.repoRoot ?? where.root;
return {
state: 'ok',
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
reposTruncated: false,
checkedAt: primary.checkedAt,
};
}
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
const repos: GitRepoEntry[] = [];
where.dirs.forEach((dir, i) => {
const status = statuses[i];
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
});
if (!repos.length) return emptyOverview('not-a-repo');
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
}
/**
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
* repository with this rather than recomputing every repository's status.
*/
export async function findWorkspaceRepo(
cwd: string,
repo: string,
opts: GitOverviewOptions = {}
): Promise<string | null> {
const where = await resolveWorkspaceRepos(cwd, opts);
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
return null;
}
// ── Per-file diff ──────────────────────────────────────────────────────────
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
export const MAX_DIFF_BYTES = 400 * 1024;
export interface GitFileDiff {
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
diff: string;
truncated: boolean;
binary: boolean;
}
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
export function isSafeRepoRelativePath(p: string): boolean {
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
return !p.split('/').includes('..');
}
/**
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
* `git diff`, which is why a container-writable repository never reaches this function.
*/
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
}
const git = opts.git ?? runGit;
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
let args: string[];
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
else {
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
}
let out: string;
let cutShort = false;
try {
out = await git(repoRoot, args);
} catch (err) {
const e = err as { code?: unknown; stdout?: unknown };
// `--no-index` exits 1 when the files differ, which is the normal case for it.
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
// any oversized diff.
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
out = e.stdout;
cutShort = true;
} else throw err;
}
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
}
+80 -13
View File
@@ -30,7 +30,6 @@
*/
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
@@ -40,6 +39,52 @@ import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
* which gives up after a timeout and answers "unknown", this waits for the real
* answer: only ENOENT reads as absent, anything else throws, so a
* stalled or unreadable workspace can never be mistaken for an empty one and
* have its settings recreated over the top. It is async, so a dead mount ties
* up a threadpool worker rather than the event loop.
*/
async function pathExistsForWrite(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/**
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
* stalled probe (the bulk cap refused it, or the stat failed with something other
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
* on a dead mount those never settle, and each would hold a threadpool worker the
* probe's ceiling does not count.
*/
async function probeBeforeTouching(path: string) {
const state = await probePath(path);
if (state !== 'unknown' || isNearStalledPath(path)) return state;
return probePath(path, { pastCap: true });
}
/**
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
* it did not answer (a mount that is not responding, a refused probe, an unreadable
* path). See `probeBeforeTouching` for why "unknown" is a skip.
*/
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
const state = await probeBeforeTouching(path);
if (state === 'absent') return 'absent';
if (state === 'unknown') return 'unreachable';
return false;
}
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -558,7 +603,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;
await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
if (!existsSync(settingsPath)) return;
if (!(await pathExistsForWrite(settingsPath))) return;
let existing: Record<string, unknown>;
try {
@@ -590,7 +635,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -621,7 +666,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -650,7 +695,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -698,7 +743,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
*/
export async function ensureCodemanHooks(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}
@@ -738,7 +783,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
let existing: Record<string, unknown>;
try {
@@ -820,7 +865,17 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
if (!existsSync(workspace)) return;
// "absent" stays absent: the install below would mkdir -p a deleted repo back
// into being. "unknown" is skipped too, never asked again with an unbounded
// lstat (see probeBeforeTouching).
const state = await probeBeforeTouching(workspace);
if (state === 'absent') return;
if (state === 'unknown') {
console.warn(
`[hooks] ${workspace} is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
);
return;
}
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
} catch {
@@ -883,7 +938,7 @@ export function generateStatusLineCommand(): string {
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
if (await pathExistsForWrite(settingsPath)) {
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
@@ -898,7 +953,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
const desired = generateStatusLineCommand();
if (isOurs && current?.command === desired) return; // already current — skip rewrite
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
if (!(await pathExistsForWrite(claudeDir))) await mkdir(claudeDir, { recursive: true });
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
} else {
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
@@ -957,7 +1012,7 @@ function statusLineExporterScriptContent(): string {
}
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
if (!existsSync(settingsPath)) return undefined;
if (await absentOrUnreachable(settingsPath)) return undefined;
try {
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
const current = parsed.statusLine as { command?: unknown } | undefined;
@@ -1103,9 +1158,21 @@ export async function resolveStatusLineCliCommand(
): Promise<string | undefined> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
let userHasOwnStatusLine = false;
if (existsSync(settingsPath)) {
const skip = await absentOrUnreachable(settingsPath);
// Unreachable: whether the user configured their own statusLine there cannot be
// told, and this must never override a real one, so inject nothing.
if (skip === 'unreachable') return undefined;
if (!skip) {
let raw: string;
try {
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
raw = await readFile(settingsPath, 'utf-8');
} catch (err) {
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
raw = '';
}
try {
const existing = raw ? JSON.parse(raw) : {};
const current = existing.statusLine as { command?: unknown } | undefined;
if (current && typeof current.command === 'string') {
if (current.command.includes(STATUSLINE_MARKER)) {
+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.
*
+7
View File
@@ -157,6 +157,13 @@ export interface CaseInfo {
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/**
* The case folder did not answer (an unreachable network mount, or an error other
* than "no such file"), or its probe was refused because folders on other unreachable
* mounts are still not answering, so whether it still exists is unknown. A refused
* probe can set this on a healthy linked case. Absent = it answered.
*/
unreachable?: boolean;
/**
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
* (the packaged skill's workers, or any spawn naming a parent session), read back
+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[];
/**
+247
View File
@@ -0,0 +1,247 @@
/**
* @fileoverview Bounded existence probe for user-chosen paths.
*
* A linked case can live on a network mount (NFS, SMB, sshfs). When that mount
* goes unreachable, a hard mount makes `stat()` wait forever. A synchronous
* probe (`existsSync`) on such a path blocks the event loop and freezes the
* whole web server; even an async `stat()` never settles and permanently holds
* one of libuv's few threadpool workers, which every other `fs`, `dns.lookup`
* and `crypto` call in the process shares.
*
* The probe therefore answers one of THREE things, never two:
* - `'present'` / `'absent'`: the filesystem answered (ENOENT and ENOTDIR are
* the only errors that mean absent);
* - `'unknown'`: it did not answer in `PATH_PROBE_TIMEOUT_MS`, it answered with
* some other error (EIO from a soft mount that gave up, EACCES), or the probe
* was refused (below). "Unknown" is NOT "absent": a caller that would create,
* scaffold or 404 on absence must not do so on unknown.
*
* And it keeps a dead mount from draining the threadpool:
* - one in-flight probe per path, shared by concurrent callers;
* - a path whose probe timed out is "stalled" until that stat finally settles.
* Paths NEAR a stalled one are answered "unknown" without a new stat, so one
* dead mount costs one worker, not one per case and file on it. "Near" means on
* the same mount when that mount is a network or FUSE filesystem (NFS, SMB,
* sshfs and the like): under the deepest mount point holding the stalled path,
* with its type, read from `/proc/self/mounts` (procfs, which never waits on the
* dead filesystem). Otherwise it narrows to the stalled path and everything under
* it: when the deepest mount is local (a path typed under a local `/home` can
* reach a NAS through a symlink, and must not take the rest of `/home` with it),
* is `/`, or the table is unavailable (not Linux). Unrelated paths are probed
* normally;
* - once `MAX_STALLED_PATH_PROBES` stalled stats are pending, new probes are
* refused process-wide (answered "unknown"), since each would risk another
* worker. Probes merely in flight do not count, so concurrent healthy probes
* never get refused. A caller acting on ONE path at a user's explicit request
* (opening a case, starting a session in it) may pass `{ pastCap: true }`: its
* probe is still bounded and still recorded as stalled if it hangs (so a dead
* path costs at most one worker however often it is retried), but it is not
* refused just because unrelated mounts are dead. Bulk scans (the case list)
* keep the cap; the per-spawn hook and statusLine helpers retry one refused
* probe past it and then skip a path that still answers "unknown", rather than
* touch it with an unbounded call. `pastCap` still stops at
* `PATH_PROBE_STALL_CEILING` (the threadpool size minus one), so explicit
* requests against several dead paths can never take the last worker.
*
* Both events are logged once (`console.warn`): a path's first stall, and the
* cap engaging, so "my case vanished" and "hooks stopped firing" leave a trace.
*
* Writers should not use this at all: a writer that must tell "missing" apart
* from "unreachable" wants an ENOENT-aware async `lstat` (see
* `pathExistsForWrite` in hooks-config.ts).
*
* @module utils/bounded-path-probe
*/
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../config/path-probe.js';
/** What a probe could establish about a path. */
export type PathProbeState = 'present' | 'absent' | 'unknown';
/** Like {@link PathProbeState}, with "present" split by whether it is a directory. */
export type PathProbeKind = 'directory' | 'file' | 'absent' | 'unknown';
const inFlight = new Map<string, Promise<PathProbeKind>>();
/** Stalled path -> the directory whose subtree is answered "unknown" while it stays stalled. */
const stalled = new Map<string, string>();
let capWarned = false;
async function statKind(path: string): Promise<PathProbeKind> {
try {
return (await fs.stat(path)).isDirectory() ? 'directory' : 'file';
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unknown';
}
}
function isWithin(path: string, root: string): boolean {
if (path === root) return true;
return path.startsWith(root.endsWith(sep) ? root : root + sep);
}
/** Filesystem types whose stall means the whole mount is gone (network and FUSE). */
const REMOTE_FS_TYPES = new Set([
'nfs',
'nfs4',
'cifs',
'smb3',
'smbfs',
'9p',
'ceph',
'glusterfs',
'afs',
'lustre',
'davfs',
]);
function isRemoteFsType(fsType: string): boolean {
return REMOTE_FS_TYPES.has(fsType) || fsType.startsWith('fuse.');
}
/** Deepest mount holding `abs`, from the kernel's mount table; undefined when unreadable. */
function mountOf(abs: string): { mountPoint: string; fsType: string } | undefined {
let table: string;
try {
table = readFileSync('/proc/self/mounts', 'utf-8');
} catch {
return undefined;
}
let best: { mountPoint: string; fsType: string } | undefined;
for (const line of table.split('\n')) {
const [, field, fsType] = line.split(' ');
if (!field || !fsType) continue;
// The table octal-escapes space, tab, newline and backslash in mount points.
const mountPoint = field.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)));
if (isWithin(abs, mountPoint) && (!best || mountPoint.length > best.mountPoint.length)) {
best = { mountPoint, fsType };
}
}
return best;
}
/**
* The subtree a stalled path takes down with it (see the module comment): its
* mount when that is a network or FUSE filesystem, else just the path itself.
*/
function stallScope(abs: string): string {
const mount = mountOf(abs);
return mount && mount.mountPoint !== '/' && isRemoteFsType(mount.fsType) ? mount.mountPoint : abs;
}
/**
* Whether `path` is near a path whose probe is still stalled (see the module
* comment), i.e. whether the probe would answer "unknown" for it without a stat.
* Lets a caller tell "this workspace sits on the dead mount" apart from "the
* probe was refused for capacity".
*/
export function isNearStalledPath(path: string): boolean {
const abs = resolve(path);
for (const scope of stalled.values()) {
if (isWithin(abs, scope)) return true;
}
return false;
}
/** Options for {@link probePathKind} / {@link probePath}. */
export interface PathProbeOptions {
/** Probe even while the stall cap is engaged (see the module comment). */
pastCap?: boolean;
}
/**
* Probe `path` without letting an unresponsive filesystem block the caller for
* longer than `PATH_PROBE_TIMEOUT_MS`. Follows symlinks, like `stat()`.
*/
export async function probePathKind(path: string, options: PathProbeOptions = {}): Promise<PathProbeKind> {
const abs = resolve(path);
if (isNearStalledPath(abs)) return 'unknown';
let probe = inFlight.get(abs);
if (!probe) {
// pastCap lifts the bulk cap, never the ceiling that keeps one worker free.
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) {
if (!capWarned) {
capWarned = true;
console.warn(
`[path-probe] ${stalled.size} path probes are stalled on unresponsive filesystems; ` +
'not starting new ones until one answers (paths read as unknown meanwhile)'
);
}
return 'unknown';
}
probe = statKind(abs);
const started = probe;
inFlight.set(abs, started);
void started.finally(() => {
inFlight.delete(abs);
stalled.delete(abs);
if (stalled.size < MAX_STALLED_PATH_PROBES) capWarned = false;
});
}
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
probe,
new Promise<PathProbeKind>((resolveTimeout) => {
timer = setTimeout(() => {
if (inFlight.get(abs) === probe && !stalled.has(abs)) {
stalled.set(abs, stallScope(abs));
console.warn(
`[path-probe] ${abs} did not answer within ${PATH_PROBE_TIMEOUT_MS} ms ` +
'(unreachable mount?); treating it and its neighbours as unknown until it does'
);
}
resolveTimeout('unknown');
}, PATH_PROBE_TIMEOUT_MS);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Why a probe of `path` answers "unknown" right now: its mount is not answering
* (`'stalled'`, it is near a stalled probe), new probes are refused because enough
* UNRELATED paths are stalled (`'refused'`; `pastCap` picks which limit applies), or
* neither, so the filesystem answered with an error such as EACCES or EIO
* (`'unreadable'`). For messages only: it reads the state now, not at probe time.
*/
export function unknownPathReason(path: string, options: PathProbeOptions = {}): 'stalled' | 'refused' | 'unreadable' {
if (isNearStalledPath(path)) return 'stalled';
if (stalled.size >= (options.pastCap ? PATH_PROBE_STALL_CEILING : MAX_STALLED_PATH_PROBES)) return 'refused';
return 'unreadable';
}
/**
* User-facing sentence for an "unknown" probe of `path` (`label` names it, e.g.
* "workingDir"). A refused probe says so, rather than blaming a folder that was never
* checked: at the ceiling every new folder reads "unknown" until a dead mount answers.
*/
export function describeUnknownPath(label: string, path: string, options: PathProbeOptions = {}): string {
return unknownPathReason(path, options) === 'refused'
? `${label} was not checked: folders on other unreachable mounts are still not answering, ` +
`so Codeman is not checking new folders until one does (see the server log): ${path}`
: `${label} is not responding or not readable: ${path}`;
}
/** Tri-state probe of `path`; see the module comment for what "unknown" means. */
export async function probePath(path: string, options: PathProbeOptions = {}): Promise<PathProbeState> {
const kind = await probePathKind(path, options);
return kind === 'directory' || kind === 'file' ? 'present' : kind;
}
/**
* `true` only when `path` is known to exist. For DISPLAY decisions only (does a
* case have a CLAUDE.md): it folds "unknown" into `false`, so never use it to
* decide that something is absent and may be created, scaffolded or reported
* missing; use {@link probePath} for that.
*/
export async function boundedPathExists(path: string): Promise<boolean> {
return (await probePath(path)) === 'present';
}
+2 -1
View File
@@ -122,7 +122,8 @@ export interface ProductionCliResolverHostOptions {
allowRealIoUnderVitest?: boolean;
}
function isExecutableRegularFile(path: string): boolean {
/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */
export function isExecutableRegularFile(path: string): boolean {
try {
if (!statSync(path).isFile()) return false;
accessSync(path, constants.X_OK);
+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 [];
+36 -9
View File
@@ -8,7 +8,9 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { isExecutableRegularFile } from './cli-executable-resolver.js';
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
export interface EnvDetectionInputs {
@@ -62,6 +64,8 @@ export interface ProbeHost {
environment: ProbeEnvironment;
which(bin: string): string | null;
fileExists(path: string): boolean;
/** An executable regular file, the run mode's own test for a `searchDirs` candidate. */
isExecutableFile(path: string): boolean;
runVersion(bin: string, args: string[]): string | null;
windowsProgramRoots(): string[];
windowsFileVersion(winPath: string): string | null;
@@ -94,18 +98,33 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
// The same candidate order and the same per-candidate test as the run mode's resolver
// (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under
// a service the PATH is minimal and the run mode finds the CLI through those dirs, so
// the doctor must too. A search-dir candidate counts only as an absolute path to an
// executable regular file, so a relative dir from a custom clis.json or a file without
// the x bit reads as missing here exactly as it does in the Run menu.
const candidates: string[] = [];
const onPath = host.which(bin);
if (onPath && isAbsolute(onPath)) candidates.push(onPath);
for (const dir of searchDirs ?? []) {
const candidate = join(dir, bin);
if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH
if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate);
}
for (const candidate of candidates) {
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
const out = host.runVersion(candidate, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
// A generic binary name that prints the wrong thing is some OTHER program (see
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; the
// alternative is claiming a tool is installed that the feature's own resolver
// rejects, which reads as "the mode is broken" rather than "install it".
// PathResolver.requireVersionMatch). Try the next candidate, then report MISSING;
// the alternative is claiming a tool is installed that the feature's own resolver
// rejects, or missing one it accepts (an npm squatter on the PATH in front of the
// real grok in ~/.grok/bin), which reads as "the mode is broken".
if (requireVersionMatch && !version) continue;
return finalize(base, tool, resolved, version);
return finalize(base, tool, candidate, version);
}
}
return { ...base, status: 'missing', installHint };
@@ -132,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul
return registry.map((tool) => checkTool(tool, host));
}
// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then
// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold
// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned.
// Same reasoning as the resolver host in cli-executable-resolver.ts.
function safeWhich(bin: string): string | null {
try {
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
killSignal: 'SIGKILL',
}).trim();
const first = out.split(/\r?\n/)[0]?.trim();
return first && existsSync(first) ? first : null;
@@ -151,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
killSignal: 'SIGKILL',
});
} catch (err: unknown) {
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
@@ -187,11 +212,12 @@ function readWindowsFileVersion(winPath: string): string | null {
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
killSignal: 'SIGKILL',
}).trim();
const out = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' }
).trim();
return out || null;
} catch {
@@ -209,6 +235,7 @@ export function createRealHost(): ProbeHost {
environment,
which: safeWhich,
fileExists: existsSync,
isExecutableFile: isExecutableRegularFile,
runVersion: safeRunVersion,
windowsProgramRoots: listWindowsProgramRoots,
windowsFileVersion: readWindowsFileVersion,
+9
View File
@@ -68,3 +68,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
export {
boundedPathExists,
describeUnknownPath,
probePath,
probePathKind,
isNearStalledPath,
unknownPathReason,
} from './bounded-path-probe.js';
export type { PathProbeState, PathProbeKind, PathProbeOptions } from './bounded-path-probe.js';
+197
View File
@@ -0,0 +1,197 @@
/**
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
* judged before anything is created:
*
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
* around it;
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
* folder listed both as a local case and as a linked one would make deleting it remove files;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
* - it must not be a symlink.
*
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
* so it can be tested without a disk.
*
* @module web/case-path
*/
import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { isValidWorkingDir } from './schemas.js';
import { describeUnknownPath, probePath } from '../utils/index.js';
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib32',
'/lib64',
'/proc',
'/run',
'/sbin',
'/sys',
'/usr',
];
/** Home-relative trees that hold credentials or other tools' own configuration. */
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];
export interface NewCasePathContext {
home: string;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir: string;
/**
* The cases directories (the caller's own and the shared one). A folder in one of them is already
* listed as a local case, so it must not be registered as a linked one too.
*/
casesDirs?: readonly string[];
}
export type NewCasePathResult =
| { ok: true; path: string; existedEmpty: boolean }
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS' | 'UNREACHABLE'; reason: string };
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
export function expandHome(raw: string, home: string): string {
if (raw === '~') return home;
if (raw.startsWith('~/')) return join(home, raw.slice(2));
return raw;
}
/**
* Why a case may not live at this (already absolute and normalised) path, or null. `systemRoots`
* defaults to the system trees as spelled; pass their symlink-resolved forms to judge a resolved path.
*/
export function blockedReason(
absPath: string,
ctx: NewCasePathContext,
systemRoots: readonly string[] = BLOCKED_SYSTEM_ROOTS
): string | null {
if (absPath === sep) return 'The filesystem root cannot be a case';
for (const root of systemRoots) {
if (isWithin(absPath, root)) return `${root} is a system directory`;
}
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
for (const dir of BLOCKED_HOME_DIRS) {
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
}
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
// the one this process uses.
if (absPath.startsWith(ctx.home + sep)) {
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
for (const dir of ctx.casesDirs ?? []) {
if (isWithin(absPath, dir)) {
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
}
}
return null;
}
/** `p` with its symlinks resolved, or `p` itself when it does not exist (or cannot be read). */
async function realpathOr(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch {
return p;
}
}
/** The context and system roots with their symlinks resolved, for judging a resolved path. */
async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathContext, string[]]> {
const [home, dataDir, casesDirs, systemRoots] = await Promise.all([
realpathOr(ctx.home),
realpathOr(ctx.dataDir),
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
]);
return [{ home, dataDir, casesDirs }, systemRoots];
}
/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
*/
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
const typed = raw.trim();
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
const expanded = expandHome(typed, ctx.home);
if (!isValidWorkingDir(expanded)) {
return {
ok: false,
code: 'INVALID',
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
};
}
const target = resolve(expanded);
const typedBlock = blockedReason(target, ctx);
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
// Bounded first: the parent can sit on a network mount that stopped answering, where the
// realpath/stat/lstat/readdir below would each hold a threadpool worker until it returns.
// It is one folder the user named, so the probe may pass the bulk cap (never the ceiling).
const parentState = await probePath(dirname(target), { pastCap: true });
if (parentState === 'absent') {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
if (parentState === 'unknown') {
return {
ok: false,
code: 'UNREACHABLE',
reason: describeUnknownPath('The parent folder', dirname(target), { pastCap: true }),
};
}
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent: string;
try {
realParent = await fs.realpath(dirname(target));
if (!(await fs.stat(realParent)).isDirectory()) {
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
}
} catch {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
const real = join(realParent, basename(target));
// The resolved path against the roots as given AND as resolved: with home reached through a link, a
// link to <real home>/.ssh is only caught by the resolved home; on macOS /etc is /private/etc.
const [resolvedCtx, resolvedSystemRoots] = await resolvedPolicy(ctx);
const realBlock = blockedReason(real, ctx) ?? blockedReason(real, resolvedCtx, resolvedSystemRoots);
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
try {
const st = await fs.lstat(real);
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
if ((await fs.readdir(real)).length > 0) {
return {
ok: false,
code: 'EXISTS',
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
};
}
return { ok: true, path: real, existedEmpty: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
}
}
+1128 -124
View File
File diff suppressed because it is too large Load Diff
+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 SplitTerminalPane._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. SplitTerminalPane 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,
+673
View File
@@ -0,0 +1,673 @@
/**
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
*
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
* Files window listing exactly which files are uncommitted and which commits are not pushed.
*
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The route is read-only and offline: it never fetches
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
* only ever written with `textContent`, never `innerHTML`.
*
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
* tablet only by construction.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency panels-ui.js (openFilePreview)
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
*/
/** How often the active session's repository is re-read while the indicator is on. */
const GIT_STATUS_POLL_MS = 15000;
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
const GIT_STATUS_TICK_MS = 2000;
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
const GIT_STATUS_MIN_REFRESH_MS = 3000;
const GIT_STATUS_BADGE_TITLE = {
M: 'Modified',
A: 'Added',
D: 'Deleted',
R: 'Renamed',
C: 'Copied',
T: 'Type changed',
U: 'Unmerged',
'?': 'Untracked',
};
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isGitStatusEnabled() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
},
/**
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
* runs on boot and after every settings save, so a live toggle needs no reload.
*/
applyGitStatusVisibility() {
const on = this.isGitStatusEnabled();
if (on && !this._gitStatusTimer) {
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
this._gitStatusOnVisible = () => {
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
};
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
window.addEventListener('focus', this._gitStatusOnVisible);
this.refreshGitStatus();
} else if (!on && this._gitStatusTimer) {
clearInterval(this._gitStatusTimer);
this._gitStatusTimer = null;
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
window.removeEventListener('focus', this._gitStatusOnVisible);
this._gitStatusOnVisible = null;
}
if (!on) {
this._gitStatus = null;
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
// turning the setting back on would skip every refresh for this session until a reload.
this._gitStatusInFlight = false;
this.closeGitStatusPanel();
}
this._renderGitStatusButton();
},
_gitStatusTick() {
if (document.hidden) return;
const sid = this.activeSessionId || null;
if (sid !== this._gitStatusSessionId) {
// The active session changed (or the first one opened): show nothing stale, read now.
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
this.refreshGitStatus();
return;
}
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
},
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
if (!this.isGitStatusEnabled()) return;
const sid = this.activeSessionId || null;
this._gitStatusSessionId = sid;
if (!sid) {
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
return;
}
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
// A read for THIS session is already running: let it finish. One for another session is not worth
// waiting for (its answer is dropped below), so a session switch is never left blank.
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
this._gitStatusInFlight = true;
this._gitStatusInFlightSid = sid;
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
this._gitStatusFetchedAt = Date.now();
try {
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
this._gitStatus = data ? { sessionId: sid, data } : null;
} catch {
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
} finally {
// Only the newest request owns the flag: an older one finishing late must not clear it.
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
}
if (epoch !== this._gitStatusEpoch) return;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
},
/** Whether the Git window groups changed files under collapsible folders (default on). */
isGitStatusTree() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true;
},
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
_currentGitStatus() {
const s = this._gitStatus;
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
},
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
_gitStatusSummary(overview) {
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
let uncommitted = 0;
let unpushed = 0;
let conflicted = 0;
for (const r of overview.repos) {
uncommitted += r.status.counts.uncommitted;
unpushed += r.status.unpushedCount;
conflicted += r.status.counts.conflicted;
}
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
},
/** One sentence for the tooltip and the screen-reader label. */
_gitStatusSentence(overview) {
const sum = this._gitStatusSummary(overview);
if (!sum) return '';
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const bits = [];
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
if (!bits.length) bits.push('everything is committed and pushed');
let where;
if (sum.repos > 1) where = `${sum.repos} repositories`;
else {
const d = overview.repos[0].status;
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
}
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
},
_renderGitStatusButton() {
const btn = this.$('gitStatusBtn');
if (!btn) return;
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
const sum = this._gitStatusSummary(data);
btn.hidden = !sum;
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
const label = btn.querySelector('.git-status-label');
if (!sum) {
if (label) label.textContent = '';
return;
}
const parts = [];
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
if (!parts.length) parts.push('✓');
if (label) label.textContent = parts.join(' ');
const sentence = this._gitStatusSentence(data);
btn.title = sentence;
btn.setAttribute('aria-label', sentence);
},
// ── Panel ───────────────────────────────────────────────────────────────
_isGitStatusPanelOpen() {
return !!this.$('gitStatusPanel')?.classList.contains('visible');
},
toggleGitStatusPanel() {
if (this._isGitStatusPanelOpen()) {
this.closeGitStatusPanel();
return;
}
const panel = this.$('gitStatusPanel');
if (!panel) return;
panel.classList.add('visible');
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
this._ensureGitStatusPanelDrag();
this._renderGitStatusPanel();
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
},
closeGitStatusPanel() {
this._gitDiffView = null;
const panel = this.$('gitStatusPanel');
if (panel) {
panel.classList.remove('visible');
// Reset a dragged position so it reopens at the default spot.
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
}
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
},
refreshGitStatusNow() {
this._gitStatusFetchedAt = 0;
this._gitStatusInFlight = false;
return this.refreshGitStatus({ fresh: true });
},
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
_ensureGitStatusPanelDrag() {
const panel = this.$('gitStatusPanel');
const handle = panel?.querySelector('.git-status-header');
if (!panel || !handle || handle._dragReady) return;
handle._dragReady = true;
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
const maxX = window.innerWidth - panel.offsetWidth - 4;
const maxY = window.innerHeight - panel.offsetHeight - 4;
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
});
const end = (e) => {
drag = null;
handle.releasePointerCapture?.(e.pointerId);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
},
_gitEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
},
_renderGitStatusPanel() {
const body = this.$('gitStatusBody');
const head = this.$('gitStatusBranch');
const foot = this.$('gitStatusFooter');
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
: null;
const view = this._gitDiffView;
if (view && view.sessionId === this.activeSessionId) {
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
this._renderGitDiffView(body, view);
if (head) head.textContent = '';
if (foot) foot.textContent = '';
return;
}
this._gitDiffView = null;
body.replaceChildren();
const clearChrome = () => {
if (head) head.textContent = '';
if (foot) foot.textContent = '';
};
if (!this.activeSessionId) {
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
clearChrome();
return;
}
if (!overview) {
body.append(
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
);
return;
}
if (overview.state !== 'ok') {
const why =
overview.state === 'not-a-repo'
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
: overview.state === 'unsupported'
? overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
: `Could not read the repository: ${overview.error || 'git failed'}`;
body.append(el('div', 'git-status-empty', why));
clearChrome();
return;
}
const repos = overview.repos;
if (repos.length === 1) {
// One repository: the panel is that repository, as it always was.
const d = repos[0].status;
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
this._renderGitRepoInto(body, d);
} else {
if (head) head.textContent = `${repos.length} repositories`;
for (const r of repos) body.append(this._gitRepoSection(r));
if (overview.reposTruncated) {
body.append(
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
);
}
}
if (foot) {
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/**
* One repository of several: a collapsible section, collapsed by default (the summary line already
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
*/
_gitRepoSection(r) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const d = r.status;
const section = el('details', 'git-status-repo');
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const repoKey = `repo|${d.repoRoot || r.path}`;
section.open = openRepos.has(repoKey);
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
const summary = el('summary', 'git-status-repo-summary');
summary.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
const bits = [];
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
const state = el(
'span',
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
bits.join(' ') || '✓'
);
summary.append(state);
section.append(summary);
const inner = el('div', 'git-status-repo-body');
this._renderGitRepoInto(inner, d);
section.append(inner);
return section;
},
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
_renderGitRepoInto(body, data) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// Branch / upstream line.
const line = el('div', 'git-status-branchline');
if (data.upstream && data.upstreamGone) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
gone.title =
'The upstream branch does not exist on the remote (never pushed, or deleted and pruned), so the commits below are on no remote.';
line.append(gone);
} else if (data.upstream) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
behind.title = 'As of the last git fetch: Codeman never fetches.';
line.append(behind);
}
} else if (data.hasRemote) {
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
} else {
line.append(el('span', 'git-status-chip', 'No remote configured'));
}
if (data.counts.stashes) {
line.append(
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
);
}
body.append(line);
// Uncommitted changes.
const filesSection = el('section', 'git-status-section');
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
if (!data.files.length) {
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
} else {
const groups = [
['conflicted', 'Merge conflicts'],
['staged', 'Staged'],
['unstaged', 'Not staged'],
['untracked', 'Untracked'],
];
for (const [kind, label] of groups) {
const rows = data.files.filter((f) => f.kind === kind);
if (!rows.length) continue;
const group = el('div', `git-status-group git-status-group--${kind}`);
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind));
else for (const f of rows) group.append(this._gitFileRow(f, data));
filesSection.append(group);
}
if (data.filesTruncated) {
filesSection.append(
el(
'div',
'git-status-more',
`Showing the first ${data.files.length} entries; the counts above include every file.`
)
);
}
}
body.append(filesSection);
// Commits not pushed.
const pushSection = el('section', 'git-status-section');
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
if (!data.unpushedCount) {
pushSection.append(
el(
'div',
'git-status-ok',
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
)
);
} else {
if (!data.upstream || data.upstreamGone) {
pushSection.append(
el(
'div',
'git-status-note',
data.upstreamGone
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
: 'This branch has no upstream, so these commits are on no remote yet.'
)
);
}
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
if (data.unpushedCount > data.unpushed.length) {
pushSection.append(
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
);
}
}
body.append(pushSection);
},
/**
* `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and
* nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five.
* Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder).
*/
_gitFileTree(rows, data, kind) {
const root = { dirs: new Map(), files: [] };
for (const f of rows) {
const trailing = f.path.endsWith('/');
const parts = f.path.replace(/\/$/, '').split('/');
const leaf = parts.pop() + (trailing ? '/' : '');
let node = root;
for (const part of parts) {
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
node = node.dirs.get(part);
}
node.files.push({ f, leaf });
}
const open = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0);
const build = (node, prefix) => {
const out = [];
for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
let name = name0;
let child = child0;
while (child.files.length === 0 && child.dirs.size === 1) {
const [n, c] = [...child.dirs][0];
name += `/${n}`;
child = c;
}
const key = `${data.repoRoot}|${kind}|${prefix}${name}`;
const dir = this._gitEl('details', 'git-tree-dir');
dir.open = open.has(key);
dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key)));
const summary = this._gitEl('summary', 'git-tree-summary');
summary.append(this._gitEl('span', 'git-tree-name', `${name}/`));
summary.append(this._gitEl('span', 'git-tree-count', String(count(child))));
dir.append(summary);
const inner = this._gitEl('div', 'git-tree-children');
inner.append(...build(child, `${prefix}${name}/`));
dir.append(inner);
out.push(dir);
}
for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) {
out.push(this._gitFileRow(f, data, leaf));
}
return out;
};
return build(root, '');
},
_gitFileRow(f, data, displayName) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-file');
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
const letter =
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
row.dataset.gitKey = `${f.kind}|${f.path}`;
row.append(badge);
const name = el('span', 'git-status-path', displayName ?? f.path);
if (displayName) name.title = f.path;
row.append(name);
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
// An untracked folder has no single diff; every other row opens its changes.
if (!f.path.endsWith('/') && data.repoRoot) {
row.classList.add('git-status-file--clickable');
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.title = 'Show what changed';
const open = () => this.openGitDiff(data.repoRoot, f, letter);
row.addEventListener('click', open);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
});
}
return row;
},
// ── Diff view ───────────────────────────────────────────────────────────
/** Show `file`'s changes in the panel (a Back button returns to the list). */
async openGitDiff(repoRoot, file, letter) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
this._gitDiffView = view;
this._renderGitStatusPanel();
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
// Back, another file or another session while this was in flight: drop the answer.
if (this._gitDiffView !== view) return;
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (this._gitDiffView !== view) return;
if (res && res.ok && body?.success) {
view.state = 'ok';
view.result = body.data;
} else {
view.state = 'error';
view.error = body?.error || 'Could not read the diff.';
}
this._renderGitStatusPanel();
},
closeGitDiff() {
this._gitDiffView = null;
this._renderGitStatusPanel();
},
_renderGitDiffView(body, view) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
body.replaceChildren();
const bar = el('div', 'git-diff-bar');
const back = el('button', 'btn-toolbar btn-sm', '← Back');
back.type = 'button';
back.addEventListener('click', () => this.closeGitDiff());
bar.append(back);
bar.append(el('span', 'git-diff-path', view.file.path));
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
if (view.letter !== 'D') {
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
open.type = 'button';
open.addEventListener('click', () =>
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
);
bar.append(open);
}
body.append(bar);
if (view.state === 'loading') {
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
return;
}
if (view.state === 'error') {
body.append(el('div', 'git-status-empty', view.error));
return;
}
const { diff, truncated, binary } = view.result;
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
if (!diff.trim()) {
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
return;
}
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of diff.split('\n')) {
let cls = 'git-diff-line';
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
)
cls += ' git-diff-line--meta';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
body.append(pre);
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
},
_gitCommitRow(c) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-commit');
row.append(el('span', 'git-status-hash', c.hash));
row.append(el('span', 'git-status-subject', c.subject));
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
row.append(el('span', 'git-status-commit-meta', meta));
return row;
},
});
+215
View File
@@ -68,6 +68,24 @@
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Group actions': '分组操作',
'Group name': '分组名称',
'Web tab actions': '网页标签操作',
'Web tab settings': '网页标签设置',
'New group': '新建分组',
'Rename group': '重命名分组',
'Move group up': '上移分组',
'Move group down': '下移分组',
'Delete group': '删除分组',
'Move up': '上移',
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -78,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: '通知',
@@ -770,6 +862,22 @@
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
'Create in a custom folder': '在自定义文件夹中创建',
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
'Parent Folder': '父文件夹',
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
'Not available for a Docker case': 'Docker 案例不可用',
'Not available with a custom folder': '使用自定义文件夹时不可用',
'Browse…': '浏览…',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
@@ -917,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`],
@@ -934,6 +1053,78 @@
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
[
/^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);
@@ -992,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));
@@ -1087,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);
+18 -8
View File
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() {
// `target` names the terminal the Ctrl+V came from and its session; both
// default to the primary pane. A second terminal (the split pane) passes its
// own, so text pastes into THAT xterm and images upload to THAT session.
_handleImagePaste(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
const sessionId = target.sessionId || this.activeSessionId;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal
if (self.terminal) self.terminal.focus();
if (terminal) terminal.focus();
}, 0);
if (imageFiles.length > 0) {
self._uploadAndInsertImages(imageFiles);
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
} else {
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (text && self.terminal) self.terminal.paste(text);
if (text && terminal) terminal.paste(text);
}
});
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
/** Upload a batch and normally insert its paths into the active terminal.
* The prompt composer passes `{ insert: false }` so it can put those paths
* into its textarea instead. Returns successful paths in selection order. */
* into its textarea instead. `options.sessionId` names the session to upload
* to (default: the active one). Returns successful paths in selection order. */
async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = this.activeSessionId;
const sessionId = options.sessionId || this.activeSessionId;
if (!sessionId) return [];
let files = Array.from(fileList || []);
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
const paths = results.filter(Boolean);
if (paths.length > 0 && options.insert !== false) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
// Insert all paths in one shot, space-separated, in selection order, into
// the session the batch was uploaded TO. Not sendInput(): it re-reads
// activeSessionId, and after the awaits above that is whatever tab the
// user switched to mid-upload, so the paths landed in the wrong session.
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
}
// Final status: successes, plus any failures / cap so nothing is silent.
+97 -9
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. -->
@@ -567,6 +574,19 @@
<div class="file-browser-status" id="fileBrowserStatus"></div>
</div>
<!-- Git status panel (git-status-ui.js): what the active session's repo has not committed or pushed. -->
<div class="git-status-panel" id="gitStatusPanel" role="dialog" aria-label="Git status">
<div class="git-status-header">
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
<div class="git-status-actions">
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">&times;</button>
</div>
</div>
<div class="git-status-body" id="gitStatusBody" data-i18n-skip></div>
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
</div>
<!-- File Preview Overlay -->
<div class="file-preview-overlay" id="filePreviewOverlay">
<div class="file-preview-window">
@@ -771,6 +791,13 @@
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<!-- Git status of the active session's repository (git-status-ui.js). Optional and per-device
(App Settings → Header & Panels → Bottom bar, default OFF), so it is hidden until that
setting is on AND the session is a local git repository. -->
<button type="button" class="btn-toolbar btn-sm btn-git-status" id="gitStatusBtn" hidden aria-expanded="false" aria-controls="gitStatusPanel" onclick="app.toggleGitStatusPanel()">
<svg width="13" height="13" 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="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>
<span class="git-status-label" data-i18n-skip></span>
</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -787,7 +814,6 @@
<section class="shortcut-section">
<h4>Session</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
@@ -802,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">
@@ -1884,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>
@@ -1894,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>
@@ -1935,6 +1974,26 @@
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Bottom bar</h4></div>
<div class="set-group-body">
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
<div class="set-row-text">
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git status folders tree flat list collapsed expand files">
<div class="set-row-text">
<span class="set-row-label">Git status: group files by folder <span class="set-scope">device</span></span>
<span class="set-row-desc">In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Subagent windows</h4></div>
<div class="set-group-body">
@@ -2829,6 +2888,20 @@
</div>
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>
<div class="set-group" id="doctorGroup">
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
<div class="set-row-text">
<span class="set-row-label">Check this machine</span>
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
</div>
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
</div>
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
@@ -2969,18 +3042,30 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
<h2>Create New</h2>
</div>
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<p class="set-section-blurb" id="newCaseBlurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<span class="form-hint" id="newCaseNameHint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
</div>
<div class="form-row">
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row" id="newCaseCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
</div>
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
</div>
@@ -3853,7 +3938,9 @@
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<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>
@@ -3870,6 +3957,7 @@
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+181 -23
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?.();
@@ -1874,10 +1878,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2084,10 +2092,14 @@ Object.assign(CodemanApp.prototype, {
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
let caseData = (await caseRes.json())?.data ?? {};
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
// Create the case if it doesn't exist
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2589,6 +2601,62 @@ Object.assign(CodemanApp.prototype, {
return typeof confirmed === 'string' ? confirmed : name;
},
/**
* Write an inline rename, one PUT per session at a time, in the order the
* user made them. The editor can be reopened (or cancelled, or replaced by a
* group rename) while a PUT is in flight, so the write lives here rather than
* in the editor: a confirmed name is applied locally even after its editor is
* gone, and the "already that name" check runs only once the earlier writes
* have landed, so confirming the name still on screen is a real write.
* Resolves { status: 'confirmed' | 'failed' | 'deleted' }; never rejects,
* and reports a failed write itself, since its editor may be gone by then.
* `_inlineRenamePending` holds the newest queued name per session, so an
* editor reopened over a write in flight starts from that name rather than
* the one the server has not replaced yet.
*/
_queueInlineSessionName(sessionId, desiredName) {
this._inlineRenameWrites ??= new Map();
this._inlineRenamePending ??= new Map();
const writes = this._inlineRenameWrites;
const pending = this._inlineRenamePending;
pending.set(sessionId, desiredName);
// Chained from a settled promise, so one rejected write cannot stop the
// writes queued behind it.
const prev = (writes.get(sessionId) || Promise.resolve()).catch(() => {});
const task = prev.then(async () => {
const session = this.sessions.get(sessionId);
if (!session) return { status: 'deleted' };
if (session.name === desiredName) return { status: 'confirmed' };
let confirmed = null;
try {
confirmed = await this._putSessionName(sessionId, desiredName);
} catch {
// A failure is a value, so a later write in the chain still runs.
}
if (!this.sessions.has(sessionId)) return { status: 'deleted' };
if (confirmed === null) {
this.showToast('Failed to rename', 'error');
return { status: 'failed' };
}
try {
this._applyLocalSessionName(sessionId, confirmed);
this.renderSessionTabs();
} catch (err) {
// The server holds the name; a local repaint failing is not a failed write.
console.error('[rename] applying the confirmed name failed', err);
}
return { status: 'confirmed' };
});
writes.set(sessionId, task);
const cleanup = () => {
if (writes.get(sessionId) !== task) return;
writes.delete(sessionId);
pending.delete(sessionId);
};
task.then(cleanup, cleanup);
return task;
},
async saveSessionName() {
if (!this.editingSessionId) return;
// Captured: the modal can be closed (or switched to another session) while
@@ -2877,7 +2945,11 @@ Object.assign(CodemanApp.prototype, {
tabName.classList.add('tab-name-renaming');
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
// A rename still in flight is the user's last word, not the name the
// server has yet to replace: start from it, and compare against it below.
const shownName = this._inlineRenamePending?.get(sessionId) ?? session.name;
const renameInFlight = shownName !== session.name;
const parsed = parseSessionPrefix(shownName);
const originalContent = tabName.textContent;
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
const restoreOriginalChildren = () => {
@@ -2898,13 +2970,17 @@ Object.assign(CodemanApp.prototype, {
const input = document.createElement('input');
input.type = 'text';
input.value = parsed ? parsed.suffix : (session.name || '');
input.value = parsed ? parsed.suffix : (shownName || '');
input.placeholder = parsed ? 'Add description...' : currentName;
input.className = 'tab-rename-input';
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
// should give the whole line to the input.
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
// should give the whole line to the input. The header editor may shrink to
// nothing, while a rail or sidebar row always keeps room to type.
const inRail = !!tabName.closest('.tab-rail');
const inSidebar = !inRail && !!this.isSessionSidebarActive?.();
const renameWidth = inRail ? 'auto' : inSidebar ? '100%' : '80px';
const renameMinWidth = inRail || inSidebar ? '4rem' : '0';
input.style.cssText = `width: ${renameWidth}; min-width: ${renameMinWidth}; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
tabName.appendChild(input);
input.focus();
@@ -2954,22 +3030,20 @@ Object.assign(CodemanApp.prototype, {
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
if (fullName === session.name) restoreOriginalChildren();
// An unchanged confirm puts the old label back, unless the editor opened
// over a rename in flight: that label was repainted from the server's
// older name, so show the in-flight name rather than make it look lost.
if (fullName === shownName && !renameInFlight) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName);
// Skip the API call if the session vanished between focus and blur. The
// queue applies the confirmed name to this.sessions before the re-render
// below repaints from it (see _applyLocalSessionName()).
if (this.sessions.has(sessionId)) {
const result = await this._queueInlineSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) {
restoreOriginalChildren();
this.showToast('Failed to rename', 'error');
} else {
// The re-render below repaints from this.sessions, so the new name has
// to be in the map before it runs (see _applyLocalSessionName()).
this._applyLocalSessionName(sessionId, confirmed);
}
// The queue reports a failure itself; the editor only puts its label back.
if (result.status === 'failed') restoreOriginalChildren();
}
// Re-render tabs to restore full tab structure
completeCurrentRename();
@@ -3098,6 +3172,16 @@ Object.assign(CodemanApp.prototype, {
showCreateCaseModal() {
document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = '';
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
// server refuses it (it writes outside the cases directory and into the shared registry).
const customToggle = document.getElementById('newCaseCustomPathToggle');
if (customToggle) customToggle.checked = false;
const customPath = document.getElementById('newCasePath');
if (customPath) customPath.value = '';
const me = window.__codemanUser || {};
const customRow = document.getElementById('newCaseCustomPathToggleRow');
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
this.toggleNewCaseCustomPath();
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
@@ -3265,6 +3349,71 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Custom-folder row for Create New: shows or hides the parent-folder field, and keeps it and the
* Docker option mutually exclusive (a Docker case has its own workspace flow, and the quick-create
* route has no `path`).
*/
toggleNewCaseCustomPath() {
const custom = document.getElementById('newCaseCustomPathToggle');
const docker = document.getElementById('newCaseDocker');
const row = document.getElementById('newCaseCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
// The "under ~/codeman-cases" wording is wrong while a custom folder is picked.
const blurb = document.getElementById('newCaseBlurb');
if (blurb) {
blurb.textContent = custom.checked
? 'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.'
: 'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.';
}
const nameHint = document.getElementById('newCaseNameHint');
if (nameHint) {
nameHint.textContent = custom.checked
? 'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.'
: 'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/';
}
custom.disabled = !!docker?.checked;
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
if (docker) {
docker.disabled = custom.checked;
docker.title = custom.checked ? 'Not available with a custom folder' : '';
}
this.updateNewCasePathPreview();
},
/** The folder the case would be created in: the parent field plus the case name. */
_newCaseTargetPath() {
const rawParent = (document.getElementById('newCasePath')?.value || '').trim();
const name = (document.getElementById('newCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
const parent = rawParent.replace(/\/+$/, '');
return `${parent}/${name}`;
},
updateNewCasePathPreview() {
const hint = document.getElementById('newCasePathPreview');
if (!hint) return;
const target = this._newCaseTargetPath();
hint.textContent = target ? `Will create: ${target}` : 'Pick the folder the new case folder should be created inside.';
},
openNewCasePathPicker() {
const input = document.getElementById('newCasePath');
PathPicker.open({
title: 'Choose the folder to create the case in',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
input.value = path;
this.updateNewCasePathPreview();
input.focus();
input.setSelectionRange(path.length, path.length);
},
});
},
async createCase() {
const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim();
@@ -3282,9 +3431,16 @@ Object.assign(CodemanApp.prototype, {
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const customFolder = !inDocker && document.getElementById('newCaseCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('newCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to create the case in', 'error');
return;
}
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: customFolder
? { name, description, path: this._newCaseTargetPath() }
: { name, description };
try {
@@ -3307,7 +3463,9 @@ Object.assign(CodemanApp.prototype, {
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
// The server's path is the folder actually created (~ expanded, symlinks resolved).
const createdIn = data.data?.case?.path || payload.path;
this.showToast(customFolder ? `Case "${name}" created in ${createdIn}` : `Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
+100 -1
View File
@@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this._applyDoctorAdminGate();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
@@ -429,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
@@ -450,6 +452,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -1192,6 +1196,18 @@ Object.assign(CodemanApp.prototype, {
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/**
* GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a
* non-admin gets no Diagnostics group instead of a button that can only answer 403. Also
* wired to `codeman:me` for the same late-resolving role as the groups above.
*/
_applyDoctorAdminGate() {
const group = document.getElementById('doctorGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
@@ -1364,6 +1380,69 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
*/
async runDoctor() {
const out = document.getElementById('doctorResult');
const btn = document.getElementById('doctorRunBtn');
if (!out) return;
const say = (text) => {
out.replaceChildren(document.createTextNode(text));
out.style.display = 'block';
};
if (btn) btn.disabled = true;
say('Checking…');
try {
const res = await this._api('/api/doctor');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
say(body?.error || 'The check failed.');
return;
}
const { tools, summary, platform } = body.data;
const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
const list = document.createElement('ul');
list.style.margin = '0';
list.style.paddingLeft = '1.2em';
for (const t of tools) {
const li = document.createElement('li');
const strong = document.createElement('b');
// As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗.
const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?';
strong.textContent = `${mark} ${t.label}`;
li.append(strong);
const bits = [t.status];
if (t.version) bits.push(t.version);
if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
if (t.reason) bits.push(t.reason);
li.append(document.createTextNode(` ${bits.join(' · ')}`));
if (t.path) {
const p = document.createElement('div');
p.className = 'mono';
p.textContent = t.path;
li.append(p);
}
if (t.status === 'missing' && t.installHint) {
const h = document.createElement('div');
h.textContent = `Install: ${t.installHint}`;
li.append(h);
}
list.append(li);
}
const head = document.createElement('p');
head.textContent =
`${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
` (${platform.environment})`;
out.replaceChildren(head, list);
out.style.display = 'block';
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2414,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,
@@ -2422,6 +2502,8 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2650,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
@@ -2674,6 +2759,9 @@ Object.assign(CodemanApp.prototype, {
showSessionButton: _ssb,
showAwayDigestButton: _adb,
showCronButton: _crb,
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
showGitStatus: _sgs,
gitStatusTree: _gst,
showTabDetachButton: _tdb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
@@ -3337,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.
@@ -3549,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.
@@ -3613,6 +3706,10 @@ Object.assign(CodemanApp.prototype, {
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
}
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
// match the setting, so a live toggle needs no reload.
this.applyGitStatusVisibility?.();
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -3961,11 +4058,12 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
'showTabDetachButton',
'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
@@ -4373,4 +4471,5 @@ document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
window.app?._applyDoctorAdminGate?.();
});
+1386 -55
View File
File diff suppressed because one or more lines are too long
+402 -6
View File
@@ -1,9 +1,9 @@
/**
* @fileoverview Read-only browser projection of the owner tab layout.
* @fileoverview Browser projection and editing of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
* module is a small, dependency-free mirror that owns four things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
@@ -12,6 +12,10 @@
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
* row) applied optimistically and saved through ONE serialized
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
@@ -34,8 +38,16 @@
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
// `placement: 'manual'` must survive the round trip: the browser writes whole
// layouts back, and dropping it would re-attach a hand-placed child session to
// its parent's subtree on the next save.
const copyRef = (r) =>
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
const MAX_GROUPS = 32;
const MAX_NAME_LENGTH = 60;
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
@@ -46,6 +58,7 @@
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
@@ -276,10 +289,17 @@
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<span class="tab-layout-group-count">${section.count}</span>` +
// Pointer path to the group menu (right-click on the header works too).
// Deliberately NOT a button and not focusable: a treeitem holds no
// interactive children, and the keyboard path is Shift+F10 /
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
// the header's accessible name.
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">&#x22EF;</span></div>' +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
@@ -340,6 +360,375 @@
};
}
// ─── Editing ────────────────────────────────────────────────────────────
//
// The browser edits through NAMED operations, not by diffing arrays: a write
// that loses a version race (409) is rebased by replaying the same operations
// on the layout the server returned, so a concurrent edit elsewhere survives.
// The server stays the authority: it re-validates and normalizes every PUT.
function editError(message) {
throw new Error(`Tab layout edit failed: ${message}`);
}
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
function groupName(value) {
const name = typeof value === 'string' ? value.trim() : '';
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
return name;
}
function refLocations(layout) {
return [
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
];
}
function containerRefs(layout, groupId) {
if (groupId === null) return layout.ungrouped;
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) editError('unknown group');
return group.refs;
}
/**
* The rows that move together with `ref`: the session plus every descendant
* that still follows its parent (non-manual, parent stored). Mirrors the
* server's moveRef block so the optimistic rail matches what it will store.
* `parents` maps a session id to its parent session id.
*/
function lineageBlock(layout, ref, parents) {
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const children = new Map();
for (const [childId, parentId] of Object.entries(parents || {})) {
const child = stored.get(`session:${childId}`);
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(childId);
}
const keys = new Set();
const visit = (key) => {
if (keys.has(key)) return;
keys.add(key);
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
};
visit(refKey(ref));
return keys;
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
* end when there is no anchor.
*/
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
const layout = normalizeLayout(layoutInput);
const block = lineageBlock(layout, ref, parents);
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
// No anchor means "at the end", and an operation with no index keeps
// meaning that when it is replayed onto a layout that has changed since.
if (!anchor) return { groupId };
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
if (at < 0) return { groupId };
return { groupId, index: placement === 'after' ? at + 1 : at };
}
/**
* Map a finished drag to ONE operation (or null for a drop that changes
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
*
* source: { type: 'ref', ref } | { type: 'group', groupId }
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
* | { type: 'group', groupId } (a named group's header or empty body)
* | { type: 'ungrouped' }
*
* A group dropped on another group (or any row in it) takes that group's slot;
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
* before/after it, on a header it is appended to that group.
*/
function dropOperation(layoutInput, source, target, parents) {
const layout = normalizeLayout(layoutInput);
if (!source || !target) return null;
if (source.type === 'group') {
const from = layout.groups.findIndex((group) => group.id === source.groupId);
if (from < 0) return null;
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
if (to < 0 || to === from) return null;
return { type: 'reorderGroup', groupId: source.groupId, index: to };
}
if (source.type !== 'ref' || !validRef(source.ref)) return null;
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
if (!location) return null;
let groupId;
let anchor = null;
let placement = 'before';
if (target.type === 'ref' && validRef(target.ref)) {
// Onto itself or onto a row that moves with it: nowhere to go.
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
groupId = target.groupId ?? null;
anchor = target.ref;
placement = target.placement === 'after' ? 'after' : 'before';
} else if (target.type === 'group') {
groupId = target.groupId ?? null;
if (groupId === location.groupId) return null;
} else if (target.type === 'ungrouped') {
groupId = null;
if (location.groupId === null) return null;
} else return null;
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
const operation = {
type: 'moveRef',
ref: { kind: source.ref.kind, id: source.ref.id },
groupId: destination.groupId,
...(destination.index === undefined ? {} : { index: destination.index }),
parents: parents || {},
};
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
}
/**
* Apply one operation to a copy of the layout. Throws when the operation no
* longer makes sense (an unknown group or row); a rebase drops that one
* operation and keeps the rest. Replays are idempotent where it matters for
* recovery: creating a group that already exists is a no-op.
*/
function applyOperation(layoutInput, operation) {
const layout = normalizeLayout(layoutInput);
const op = operation || {};
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
switch (op.type) {
case 'createGroup': {
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
const name = groupName(op.name);
if (layout.groups.some((group) => group.id === op.id)) return layout;
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
return layout;
}
case 'renameGroup':
if (groupIndex < 0) editError('unknown group');
layout.groups[groupIndex].name = groupName(op.name);
return layout;
case 'deleteGroup': {
// Already gone (deleted elsewhere): nothing left to do.
if (groupIndex < 0) return layout;
const [removed] = layout.groups.splice(groupIndex, 1);
layout.ungrouped.push(...removed.refs);
return layout;
}
case 'reorderGroup': {
if (groupIndex < 0) editError('unknown group');
const [moved] = layout.groups.splice(groupIndex, 1);
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
const destinationId = op.groupId ?? null;
containerRefs(layout, destinationId);
const keys = lineageBlock(layout, op.ref, op.parents);
const block = refLocations(layout)
.filter((item) => keys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
// A hand-moved child stops following its parent (server moveRef does the same).
const head = block.find((item) => refKey(item) === targetKey);
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
const destination = containerRefs(layout, destinationId);
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
}
/** Layout content without version metadata: equal keys mean "nothing to save". */
function contentKey(layoutInput) {
const layout = normalizeLayout(layoutInput);
return JSON.stringify([layout.groups, layout.ungrouped]);
}
/** Replay operations, dropping (and counting) the ones that no longer apply. */
function replayOperations(base, operations) {
let layout = normalizeLayout(base);
const kept = [];
let dropped = 0;
for (const operation of operations) {
try {
layout = applyOperation(layout, operation);
kept.push(operation);
} catch (_error) {
dropped++;
}
}
return { layout, kept, dropped };
}
/**
* Serialized, optimistic writer for `PUT /api/tab-layout`.
*
* - enqueue() applies an operation at once (the rail repaints optimistically)
* and schedules a flush; operations enqueued in the same turn share a PUT.
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
* sent on top of the version that write returns.
* - A 409 carries the server's current layout: the in-flight operations are
* replayed onto it and re-sent with its version (bounded attempts). A 400
* (a row vanished between read and write) re-reads and rebases the same way.
* - Anything else, or attempts exhausted, drops the batch and reports it; the
* caller re-reads so the rail shows the server's truth.
*
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
*/
function createEditCoordinator(options) {
let authoritative = normalizeLayout(options.initialLayout);
let optimistic = authoritative;
let pending = [];
let inFlight = [];
let writing = false;
let timer = null;
let disposed = false;
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
const cancel = options.cancel || ((handle) => clearTimeout(handle));
const maxAttempts = options.maxAttempts || 3;
const report = (message) => options.reportError?.(message);
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
const queue = () => {
if (timer === null) timer = schedule(flush);
};
async function flush() {
timer = null;
if (disposed || writing || pending.length === 0) return;
writing = true;
inFlight = pending;
pending = [];
let failed = false;
let reportedDrop = false;
let rereadFor400 = false;
try {
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
const desired = replayOperations(authoritative, inFlight);
inFlight = desired.kept;
if (desired.dropped && !reportedDrop) {
reportedDrop = true;
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
// Nothing left to change (dropped, or already true on the server).
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
inFlight = [];
break;
}
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
if (disposed) return;
if (response?.ok && response.layout) {
authoritative = normalizeLayout(response.layout);
inFlight = [];
} else if (response?.status === 409 && response.layout) {
authoritative = normalizeLayout(response.layout);
} else if (response?.status === 400 && options.fetchLayout && !rereadFor400) {
// Maybe our base was stale in a way the server reports as invalid:
// re-read once. A 400 that survives that is a refusal, not a race.
rereadFor400 = true;
authoritative = normalizeLayout(await options.fetchLayout());
if (disposed) return;
} else {
throw new Error('Tab layout save failed');
}
}
if (inFlight.length) {
failed = true;
report('Tab groups kept changing elsewhere; your edit was not saved.');
}
} catch (_error) {
failed = true;
report('Could not save tab groups.');
} finally {
inFlight = [];
writing = false;
if (!disposed) {
const rebased = replayOperations(authoritative, pending);
// Edits made while the write was in flight are rebased here, so one the
// conflict made inapplicable is dropped here too, and says so (once).
if (rebased.dropped && !failed && !reportedDrop) {
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true });
if (failed) options.onFailure?.();
if (pending.length) queue();
else options.onSettled?.();
}
}
}
return {
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
enqueue(operation) {
optimistic = applyOperation(optimistic, operation);
pending.push(operation);
publish({ optimistic: true });
queue();
return normalizeLayout(optimistic);
},
/**
* Re-apply operations recovered after a reload. Returns false (and queues
* nothing) when the layout already reflects them, e.g. the keepalive save
* landed before the page went away.
*/
restore(operations) {
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
optimistic = replayed.layout;
pending.push(...replayed.kept);
publish({ optimistic: true });
queue();
return true;
},
/**
* Adopt a layout read from the server (SSE reload). Pending operations are
* rebased onto it. Refused while a write is in flight (its result decides)
* and for a layout older than the one already held.
*/
adoptExternal(layout) {
if (disposed || writing) return false;
const next = normalizeLayout(layout);
if (next.version < authoritative.version) return false;
authoritative = next;
const rebased = replayOperations(next, pending);
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true, external: true });
return true;
},
flush,
isWriting: () => writing,
hasPending: () => writing || pending.length > 0,
/** Every operation not yet confirmed by the server, oldest first. */
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
baseVersion: () => authoritative.version,
getLayout: () => normalizeLayout(optimistic),
dispose() {
disposed = true;
if (timer !== null) cancel(timer);
timer = null;
pending = [];
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
@@ -347,8 +736,15 @@
hiddenGroupAlerts,
structureKey,
renderProjection,
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
dropOperation,
contentKey,
createEditCoordinator,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
MAX_GROUPS,
};
})(typeof window !== 'undefined' ? window : globalThis);
+5 -1
View File
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
// The group menu borrows this class for its look but has its own owner
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
@@ -325,6 +327,8 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
+146 -649
View File
@@ -1,640 +1,16 @@
// src/web/public/terminal-split.js
/**
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
* feature by nature — see docs/split-pane-sessions-plan.md.
* @fileoverview Split-pane orchestration: opens a second live session
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
* a draggable divider, a session picker, and auto-collapse when either
* session ends. Desktop-only; see 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 terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
* @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 tile-grid.js
*/
(function (global) {
// How long a scroll-to-top history pull may hold Pane B's live output.
const HISTORY_PULL_TIMEOUT_MS = 10000;
/**
* 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.
*/
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.
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
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();
}
};
writeNext();
});
}
class SplitTerminalPane {
constructor(sessionId, mountEl, opts = {}) {
this.sessionId = sessionId;
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.
this.detachedSessions = opts.detachedSessions;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
this._wsClosed = false;
this._destroyed = false;
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
// 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
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
this._installWheelListener();
this.terminal.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ t: 'i', d: 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 is deliberately left on xterm's own
// default (plain-text paste): Pane B has no image-paste trap to route it
// to, so intercepting it here would only break paste.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
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.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey &&
this.sessionMode !== 'shell'
) {
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
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B 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') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
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.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
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()) {
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');
});
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).
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.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;
}
}
return true;
});
// 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.
await this._loadBuffer();
if (this._destroyed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this._wsReady = true;
this._sendResize();
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
this._onLiveOutput(msg.d);
} else if (msg.t === 'c') {
this._onLiveClear();
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
// buffer loader for the same reason connect() does.
this._refreshBuffer();
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
// must go false on a drop or fit()/_sendResize() silently no-ops on a
// closed socket per the WebSocket spec (no exception, no log). No
// reconnect logic here — Pane B is deliberately plainer than the
// primary pane (see the fileoverview above); a drop just stops
// resizing until the parent recreates the pane. But onData already
// silently drops keystrokes while _wsReady is false (below), so
// without a visible marker a dropped socket left Pane B looking
// normal while it quietly ate everything typed into it. v1 scope is
// "say so", not reconnect — collapsing the split would lose the
// user's place in Pane B's scrollback for a transient blip.
this.ws.onclose = () => this._onSocketClosed();
this.ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
};
}
// The socket's close, split out of connect() so the tests can drive it.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
// 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
// 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.
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _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.
//
// 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() {
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);
}
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// Ends a single-flight load (initial, refresh or history pull): clears the
// flag, then runs the ONE trailing refresh that arrived while it was busy.
_endBufferLoad() {
this._bufferLoading = false;
if (this._bufferRefreshPending && !this._destroyed) {
this._bufferRefreshPending = false;
this._refreshBuffer();
}
}
// Live terminal output. Written straight through, except while a history
// pull is replaying: a capture is current only up to the instant tmux took
// it, so a frame arriving mid-replay is held with its arrival time and
// replayed behind the snapshot by _pullHistory() (the primary pane's
// _finishBufferLoad `since` rule), never written underneath it.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
}
// The server's `{t:'c'}` clear frame takes the same route as output, for the
// same reason: clearing straight away, mid-replay, would wipe the half-written
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
// one it is restoring. Queued, it lands in order with the frames around it.
_onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
else this.terminal?.clear();
}
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Passive: this only observes,
// xterm keeps doing the scrolling.
_installWheelListener() {
this._onWheel = (ev) => {
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
};
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
}
// 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.
_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.
if (this.detachedSessions?.has(this.sessionId)) return;
const active = this.terminal.buffer.active;
if (active.type !== 'normal' || active.viewportY !== 0) return;
// Momentum scrolling fires this dozens of times per flick, so cooldown
// rather than latch; a pull that could only have downgraded the pane
// waits far longer.
const cooldown = this._historyPullUseless ? 60000 : 4000;
const now = Date.now();
if (now - this._historyPullAt < cooldown) return;
this._historyPullAt = now;
void this._pullHistory();
}
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
this._bufferLoading = true;
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
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,
});
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
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
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.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
const rowsBefore = term.buffer.active.length;
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
// lines, so a window of short lines can carry more rows than this pane
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
const scrollbackCap = term.options?.scrollback || 0;
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
// Nothing to gain (this also covers a downgrade, which would delete
// history mid-scroll), and a reset+rewrite would jump the viewport. An
// untruncated window IS all of tmux's history and the next burst can add
// more, so keep the 4 s cooldown. A truncated window can never reach past
// what the pane shows, and every ask costs the server a capture-pane of
// the whole history (`tail` is cut after it): back off to 60 s, as the
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
// off too, since no window can ever fit in it.
if (rowsIncoming <= rowsBefore || paneFull) {
if (payload.truncated || paneFull) this._historyPullUseless = true;
return;
}
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
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.
await new Promise((resolve) => this.terminal.write('', resolve));
if (this._destroyed || !this.terminal) return;
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
// rows down; land there and the recovered history sits above it.
const delta = this.terminal.buffer.active.length - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
// earlier ones are already in it. With no replay, every held frame is.
const cutoff = replayed ? capturedAt : 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to 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,
// and no further frame is coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
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();
}
// 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
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
}
fit() {
this.localFit();
this._sendResize();
}
_sendResize() {
if (!this._wsReady || !this.fitAddon) 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.
if (this.detachedSessions?.has(this.sessionId)) return;
const dims = this.fitAddon.proposeDimensions();
if (!dims) return;
// Send the real proposed dimensions unclamped, matching the primary
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
// A 40/10 floor here misreported Pane B's real width to the PTY at the
// divider's own reachable 20% floor position, causing real
// output-wrapping bugs.
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
}
destroy() {
this._destroyed = true;
if (this._onWheel) {
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;
}
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran
// its "disconnected" write against a pane already torn down.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}
global.SplitTerminalPane = SplitTerminalPane;
})(window);
Object.assign(CodemanApp.prototype, {
/**
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
@@ -670,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;
@@ -763,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
@@ -777,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
// B's own session tab while split can otherwise land here with
// sessionId === activeSessionId: two live WebSockets to the same
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
// resize frame. Refuse before creating any DOM or TerminalTile.
if (sessionId === this.activeSessionId) return;
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
// re-applied here: the menu can sit open while a listed session's CLI
@@ -803,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.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions,
@@ -828,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
@@ -855,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');
@@ -865,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
@@ -880,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;
@@ -900,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 (SplitTerminalPane 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
@@ -1039,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) {
File diff suppressed because it is too large Load Diff
+157 -44
View File
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
// Focus coming back to the primary terminal ends a second pane's claim on
// the keyboard (see _focusedPane).
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
// Let xterm's CompositionHelper own IME key events. In particular, a
// non-composing keyCode 229 is how an active IME commits numbers and
@@ -587,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
@@ -1325,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
@@ -1332,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
@@ -1415,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._splitPane?.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
};
@@ -1827,9 +1843,24 @@ Object.assign(CodemanApp.prototype, {
* Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable.
* When clicked, opens a floating log viewer window with live streaming.
*
* `target` defaults to the primary terminal and the active session. A second
* terminal (the split pane) passes its own `{ terminal, getSessionId,
* setHovered }`, so a path printed there opens against THAT pane's session and
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
* Returns the provider.
*/
registerFilePathLinkProvider() {
registerFilePathLinkProvider(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
const getSessionId = target.getSessionId || (() => this.activeSessionId);
const setHovered =
target.setHovered ||
((hovered) => {
this._linkHovered = hovered;
});
const isPrimary = terminal === this.terminal;
// Debug: Track if provider is being invoked
let lastInvokedLine = -1;
@@ -1842,7 +1873,7 @@ Object.assign(CodemanApp.prototype, {
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
}
const buffer = self.terminal.buffer.active;
const buffer = terminal.buffer.active;
// provideLinks passes 1-based line number, getLine expects 0-based
const line = buffer.getLine(bufferLineNumber - 1);
@@ -1866,7 +1897,7 @@ Object.assign(CodemanApp.prototype, {
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
buffer,
bufferLineNumber - 1,
self.terminal.cols,
terminal.cols,
MAX_STITCHED_ROWS
);
if (!logical) {
@@ -1919,10 +1950,10 @@ Object.assign(CodemanApp.prototype, {
window.open(text, '_blank', 'noopener,noreferrer');
},
hover() {
self._linkHovered = true;
setHovered(true);
},
leave() {
self._linkHovered = false;
setHovered(false);
},
});
};
@@ -1978,17 +2009,18 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
self.openFilePreview(text, self.activeSessionId);
const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
self.openFilePreview(text, sessionId);
return;
}
self.openLogViewerWindow(text, self.activeSessionId);
self.openLogViewerWindow(text, sessionId);
},
hover() {
self._linkHovered = true;
setHovered(true);
},
leave() {
self._linkHovered = false;
setHovered(false);
},
});
};
@@ -2031,10 +2063,11 @@ Object.assign(CodemanApp.prototype, {
// produce), so the tap path asks this SAME provider what is under the finger
// rather than growing a second, driftable copy of the patterns.
// See _terminalLinkAtPoint.
this._terminalLinkProvider = provider;
this.terminal.registerLinkProvider(provider);
if (isPrimary) this._terminalLinkProvider = provider;
terminal.registerLinkProvider(provider);
console.log('[LinkProvider] File path link provider registered');
return provider;
},
/**
@@ -4480,8 +4513,56 @@ Object.assign(CodemanApp.prototype, {
// Terminal Controls
// ═══════════════════════════════════════════════════════════════
/**
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
*
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
* primary pane. It answers with the pane whose terminal was focused LAST, not
* with `document.activeElement`: clicking the mic or a header button moves
* DOM focus to that button, and the dictation it starts still belongs to the
* pane the user was typing in. A second pane (the split pane's Pane B) claims
* it from its own terminal's focus; the primary terminal's focus gives it back.
*/
_focusedPane() {
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 };
}
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
},
/** Record which second pane holds the keyboard (null: the primary terminal). */
_noteFocusedTile(tile) {
this._focusedTile = tile || null;
},
/**
* 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, { 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
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
// in the same pane whose display is cleared, never a different one.
clearTerminal() {
this.terminal.clear();
this._focusedPane().terminal?.clear();
},
/** Insert editable text at the active prompt without pressing Enter. */
@@ -4545,10 +4626,21 @@ Object.assign(CodemanApp.prototype, {
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
*/
async restoreTerminalSize() {
// A second pane owns its own geometry: refit it and force its PTY to the
// size it renders at (TerminalTile.fit), whatever another device set.
const pane = this._focusedPane();
if (!pane.isPrimary) {
pane.tile.fit({ force: true });
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
return;
}
if (!this.activeSessionId) {
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
@@ -4639,15 +4731,18 @@ Object.assign(CodemanApp.prototype, {
* terminal._core for cell dimensions, and falls back to cleaning normally if
* a future xterm renames it. SelectionMode.COLUMN is 3.
*/
cleanedTerminalSelection(text) {
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
cleanedTerminalSelection(text, target = {}) {
// `target` names a second terminal (the split pane) and its session; both
// default to the primary pane, whose `this.terminal` this file otherwise reads.
const terminal = target.terminal || this.terminal;
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
if (!raw) return '';
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
const clean = window.CodemanCopySelection?.clean;
if (!clean) return raw;
const range = this._normalisedSelectionRange();
const range = this._normalisedSelectionRange(terminal);
return clean(raw, {
margin: this._cliGutterColumns(),
margin: this._cliGutterColumns(target.sessionId),
firstLinePartial: !!range && range.start.x > 0,
});
},
@@ -4717,8 +4812,11 @@ Object.assign(CodemanApp.prototype, {
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined.
async copyTerminalSelection(text) {
const selection = this.cleanedTerminalSelection(text);
async copyTerminalSelection(text, target = {}) {
// Every terminal touched below is the TARGET one: clearing or refocusing the
// primary after copying from the split pane would hit the wrong pane.
const terminal = target.terminal || this.terminal;
const selection = this.cleanedTerminalSelection(text, target);
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
// alone, which are truthy, and a bare newline pasted into a chat composer
// or a shell submits the line. decideAutoCopy applies the same rule.
@@ -4727,7 +4825,7 @@ Object.assign(CodemanApp.prototype, {
// selection, so a padding-only selection left set can no longer swallow a
// later interrupt; it cleans to '' and the press reaches the PTY. What the
// clear avoids is a highlight that sits there having copied nothing.
this.terminal?.clearSelection?.();
terminal?.clearSelection?.();
this.showToast('Nothing to copy', 'warning');
return false;
}
@@ -4735,14 +4833,14 @@ Object.assign(CodemanApp.prototype, {
if (ok) {
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
// drops the selection on any keypress, so this matches existing feel).
this.terminal.clearSelection?.();
terminal.clearSelection?.();
this.showToast('Copied to clipboard', 'success');
} else {
this.showToast('Failed to copy', 'error');
}
// The execCommand fallback focuses a temp textarea, so hand focus back. This
// is the CJK-aware focus router, not xterm's raw focus().
this.terminal.focus();
terminal.focus();
return ok;
},
@@ -5671,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));
},
@@ -5688,10 +5795,13 @@ Object.assign(CodemanApp.prototype, {
// Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontSize = size;
this._splitPane.fitAddon?.fit();
}
this._forEachTile?.(
(tile) => {
tile.terminal.options.fontSize = size;
tile.fit(); // a font change is a size change: tell its PTY too (#464)
},
{ grid: false }
);
},
/**
@@ -5717,10 +5827,10 @@ Object.assign(CodemanApp.prototype, {
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontFamily = resolved;
this._splitPane.fitAddon?.fit();
}
this._forEachTile?.((tile) => {
tile.terminal.options.fontFamily = resolved;
tile.fit(); // a font change is a size change: tell its PTY too (#464)
});
},
/**
@@ -5772,11 +5882,11 @@ Object.assign(CodemanApp.prototype, {
/* pane not laid out yet — its own resize observer refits it */
}
}
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontWeight = fontWeight;
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
this._splitPane.fitAddon?.fit();
}
this._forEachTile?.((tile) => {
tile.terminal.options.fontWeight = fontWeight;
tile.terminal.options.fontWeightBold = fontWeightBold;
tile.fit(); // a font change is a size change: tell its PTY too (#464)
});
},
loadFontSize() {
@@ -5995,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).
@@ -6245,13 +6358,13 @@ Object.assign(CodemanApp.prototype, {
}
}
}
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
this._splitPane.terminal.options.theme = { ...theme };
this._forEachTile?.((tile) => {
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
tile.terminal.options.theme = { ...theme };
try {
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
tile.terminal.refresh(0, tile.terminal.rows - 1);
} catch {}
}
});
},
});
File diff suppressed because it is too large Load Diff
+48 -10
View File
@@ -554,6 +554,11 @@ const VoiceInput = {
_analyserSource: null, // MediaStreamSource for level meter
_audioContext: null, // AudioContext for level meter
_levelAnimFrame: null, // rAF handle for level meter
// The session dictation was started FOR, captured in start(). Transcripts
// arrive seconds later and the green send button / compose overlay can be
// used later still; reading app.activeSessionId at that point sent the text
// to whatever tab the user had switched to in the meantime.
_targetSessionId: null,
init() {
this._initRecognition();
@@ -663,10 +668,12 @@ const VoiceInput = {
start() {
if (this.isRecording) return;
if (!app.activeSessionId) {
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
if (!target) {
app.showToast('No active session', 'warning');
return;
}
this._targetSessionId = target;
this._retryCount = 0;
const provider = this._resolveProvider();
@@ -959,8 +966,31 @@ const VoiceInput = {
this.stop();
},
/** The session this dictation belongs to (see _targetSessionId). */
_targetSession() {
return this._targetSessionId || app.activeSessionId;
},
/**
* Send text to the dictation's own session. The active session keeps going
* through app.sendInput() exactly as before; any other session goes straight
* to the durable queue with the same useMux flag sendInput() passes.
*/
_sendToTarget(target, text) {
if (target === app.activeSessionId) return app.sendInput(text);
// Closed while dictating: say so instead of queueing text for a session
// that will only answer 404 (and never typing it into some other tab).
if (app.sessions && !app.sessions.has(target)) {
app.showToast?.('That session has closed; dictation not sent', 'warning');
return Promise.resolve();
}
app._sendInputAsync(target, text, { useMux: true });
return Promise.resolve();
},
_insertText(text) {
if (!app.activeSessionId || !text.trim()) return;
const target = this._targetSession();
if (!target || !text.trim()) return;
const trimmed = text.trim();
const mode = this._getDeepgramConfig().insertMode || 'direct';
@@ -975,14 +1005,17 @@ const VoiceInput = {
this._showComposeOverlay(trimmed);
}
} else {
// Direct mode: inject into local echo overlay if available, else send to PTY
if (app._localEchoEnabled && app._localEchoOverlay) {
// Direct mode: inject into local echo overlay if available, else send to PTY.
// The overlay belongs to the ACTIVE session's terminal, so text dictated
// for any other session must not be typed into it.
const isActive = target === app.activeSessionId;
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
app._localEchoOverlay.appendText(trimmed);
} else {
app.sendInput(trimmed).catch(() => {});
this._sendToTarget(target, trimmed).catch(() => {});
}
this._showVoiceSendBtn();
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
}
},
@@ -1008,10 +1041,15 @@ const VoiceInput = {
// Click handler
this._voiceSendHandler = () => {
if (!app.activeSessionId) return;
const target = this._targetSession();
if (!target) return;
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
// otherwise just send \r directly to the PTY
if (app._localEchoEnabled && app._localEchoOverlay) {
// otherwise just send \r directly to the PTY. Both the overlay and the
// predictions belong to the ACTIVE session's terminal, so a dictation
// for another session just sends its Enter there.
if (target !== app.activeSessionId) {
this._sendToTarget(target, '\r').catch(() => {});
} else if (app._localEchoEnabled && app._localEchoOverlay) {
const text = app._localEchoOverlay.pendingText || '';
app._localEchoOverlay.clear();
app._localEchoOverlay.suppressBufferDetection();
@@ -1065,7 +1103,7 @@ const VoiceInput = {
const send = () => {
const val = textarea.value.trim();
overlay.remove();
if (val) app.sendInput(val + '\r').catch(() => {});
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
};
const cancel = () => overlay.remove();
const newInput = () => {
+142 -12
View File
@@ -50,6 +50,8 @@ import {
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -163,6 +165,9 @@ function gitDiagnosticLine(stderr: string): string {
* the clone response says so out loud instead of silently merging into them.
*/
function repoShipsClaudeSettings(casePath: string): boolean {
// Deliberately NOT the bounded path probe: the tree was just cloned into the
// local case space (and lstat'ed synchronously moments ago), so a bound protects
// nothing here, while a probe answering "unknown" could silently drop this warning.
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
@@ -266,7 +271,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
cases.push({
name: e.name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
location: 'local',
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
});
@@ -281,17 +286,21 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const existingNames = new Set(cases.map((c) => c.name));
if (admin) {
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
const state = await probePath(path);
if (state === 'absent') continue;
// An unreachable linked case (a dead network mount) stays listed and says
// so: dropping it would read as "deleted" and invite a same-name local case.
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
linked: true,
location: 'linked-local',
...(state === 'unknown' ? { unreachable: true } : {}),
});
}
}
}
// Get remote cases (owner-scoped; legacy no-owner = admin-only)
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
@@ -333,7 +342,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const dockerCaseInfo: CaseInfo = {
name: dockerCase.name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -424,8 +433,109 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { cases: summaries } };
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
/**
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
* anything is written; a failure after the first write undoes what this call created.
*/
async function createCaseInCustomFolder(
name: string,
description: string | undefined,
customPath: string,
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
const ownCasesDir = resolveCasesDir(getAuthUser(req));
if (existsSync(join(ownCasesDir, name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
// POST /api/sessions for a workingDir on a dead mount.
if (prepared.code === 'UNREACHABLE') {
reply.code(422);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
}
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
const code =
prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT;
return createErrorResponse(code, prepared.reason);
}
const casePath = prepared.path;
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (alreadyAs) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
}
const made: string[] = [];
try {
if (!prepared.existedEmpty) {
mkdirSync(casePath);
made.push(casePath);
}
mkdirSync(join(casePath, 'src'));
made.push(join(casePath, 'src'));
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
made.push(join(casePath, 'CLAUDE.md'));
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if ((err as { conflict?: boolean }).conflict) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
return createCaseInCustomFolder(name, description, customPath, req, reply);
}
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
if (!casePath) {
@@ -1618,7 +1728,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return {
name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -1633,16 +1743,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
const casePath = await resolveCasePath(name, getAuthUser(req));
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
if (!existsSync(casePath)) {
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
// a path that merely did not answer (a dead network mount) must never get it.
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
const state = await probePath(casePath, { pastCap: true });
if (state === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
if (state === 'unknown') {
// The linked registry knows where the case lives, so say where, and that
// it is not answering. A local case has no such record to fall back on.
if (!linked) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Case folder', casePath, { pastCap: true })
);
}
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
}
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
// Probed like the folder above, or a healthy case reads as having no CLAUDE.md under the cap.
hasClaudeMd: (await probePath(join(casePath, 'CLAUDE.md'), { pastCap: true })) === 'present',
...(linked && { linked: true }),
};
});
@@ -1660,7 +1786,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
if (fixPlanState === 'unknown') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
}
if (fixPlanState === 'absent') {
return { exists: false, content: null, todos: [] };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
*
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
* timeout), so it must never run on the server's event loop: a handful of slow probes would
* freeze every request and every SSE client, with the process still alive. The default runner
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
* parses its output; the runner is injected so tests never spawn anything.
*
* Read-only, but the report names install paths and versions on the host, so in multi-user
* mode it is admin only (the same bar as the other host-introspection routes).
*/
import { execFile } from 'node:child_process';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
import type { DependencyReportJson } from '../../utils/dependency-report.js';
export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;
const DOCTOR_TIMEOUT_MS = 30_000;
function isReport(v: unknown): v is DependencyReportJson {
const r = v as Partial<DependencyReportJson> | null;
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
}
/**
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
*/
export const defaultDoctorRunner: DoctorRunner = (category) =>
new Promise((resolve, reject) => {
const args = [
...process.execArgv,
process.argv[1],
'doctor',
'--json',
...(category ? ['--category', category] : []),
];
execFile(
process.execPath,
args,
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
(err, stdout) => {
if (err && (err as { killed?: boolean }).killed) {
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
}
try {
const parsed: unknown = JSON.parse(stdout);
if (isReport(parsed)) return resolve(parsed);
} catch {
/* fall through to the error below */
}
reject(err ?? new Error('doctor produced no report'));
}
);
});
export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
// asking for the same category while one is in flight share its promise.
const inFlight = new Map<string, Promise<DependencyReportJson>>();
const runShared = (category?: string): Promise<DependencyReportJson> => {
const key = category ?? '';
let running = inFlight.get(key);
if (!running) {
running = runner(category).finally(() => inFlight.delete(key));
inFlight.set(key, running);
}
return running;
};
app.get(
'/api/doctor',
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
const { category } = req.query as { category?: string };
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
);
}
try {
return { success: true, data: await runShared(category) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
}
}
);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
* an overview: the enclosing repository, or each repository found below a folder that holds several
* projects (see `getGitWorkspaceOverview` for exactly which).
*
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
* `findSessionOrFail`, like every session-scoped route.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { readDockerCases } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
type GitFileKind,
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
git?: GitRunner,
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
): void {
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
const { id } = req.params as { id: string };
const { fresh } = req.query as { fresh?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
return {
success: true,
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
dockerWorkspaces: await dockerWorkspaces(),
}),
};
});
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
// the overview's own (cached) list with the Docker roots as they are now, and only that one
// repository's status is refreshed: a click must not re-read every repository in the folder.
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string };
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
: null;
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
const entry =
status?.state === 'ok'
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
: undefined;
if (!status?.repoRoot || !entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
} catch (err) {
reply.code(500);
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
);
}
});
}
+2
View File
@@ -13,6 +13,7 @@ export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
@@ -30,6 +31,7 @@ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-rout
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
+49 -9
View File
@@ -174,6 +174,7 @@ import {
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
@@ -774,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-
@@ -971,16 +986,23 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
// Validate workingDir exists and is a directory
// Validate workingDir exists and is a directory. Bounded: a workingDir on a
// network mount that stopped answering must not freeze the event loop, and
// "did not answer" is reported as such, never as "does not exist".
if (body.workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
const kind = await probePathKind(workingDir, { pastCap: true });
if (kind === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('workingDir', workingDir, { pastCap: true })
);
}
} catch {
if (kind === 'absent') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
if (kind !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
}
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
@@ -2978,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
@@ -3002,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'
@@ -3694,9 +3722,21 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
}
// Bounded probe of a local case folder: a linked case can sit on a network mount
// that stopped answering, and a synchronous check there froze the whole server.
// Only a DEFINITE absence may scaffold a new case; "did not answer" must not
// create one over the top of where the real case is mounted.
const localCaseState = remote || docker ? undefined : await probePathKind(resolvedCasePath, { pastCap: true });
if (localCaseState === 'unknown') {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Case folder', resolvedCasePath, { pastCap: true })
);
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (!remote && !docker && !existsSync(resolvedCasePath)) {
if (localCaseState === 'absent') {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
+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);
+7
View File
@@ -668,6 +668,13 @@ export const CreateCaseSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(),
/**
* Create the case in this folder instead of under the cases directory. Absolute, or starting with
* `~`. Only length-bounded here: what makes it acceptable (shape, blocked trees, symlinks, an
* existing folder with contents) is judged by `prepareNewCasePath()` in web/case-path.ts, which
* also produces the user-facing reason.
*/
path: z.string().min(1).max(1000).optional(),
});
/**
+8
View File
@@ -183,6 +183,7 @@ import {
registerApprovalRoutes,
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -202,6 +203,7 @@ import {
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -1121,6 +1123,7 @@ export class WebServer extends EventEmitter {
registerApprovalRoutes(this.app, ctx);
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1143,6 +1146,7 @@ export class WebServer extends EventEmitter {
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -3538,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);
+369
View File
@@ -0,0 +1,369 @@
/**
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
* a stat() that never settles (an unreachable hard network mount) must not hold
* the caller past the timeout, must read as "unknown" rather than "absent", must
* not be re-issued while it is still pending, must not let stalled probes pile up
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:fs/promises', () => ({
default: { stat: vi.fn() },
}));
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
// (a mount point with a space, octal-escaped in the table) are network mounts;
// everything else sits on the root filesystem. `null` = no table (not Linux).
const mounts = vi.hoisted(() => ({
table: null as string | null,
default: [
'sysfs /sys sysfs rw 0 0',
'/dev/sda1 / ext4 rw 0 0',
'nas:/export /mnt/nas nfs rw,hard 0 0',
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
'',
].join('\n'),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
if (String(path) === '/proc/self/mounts') {
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
return mounts.table;
}
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.readFileSync;
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
});
import fs from 'node:fs/promises';
import {
boundedPathExists,
describeUnknownPath,
isNearStalledPath,
probePath,
probePathKind,
unknownPathReason,
} from '../src/utils/bounded-path-probe.js';
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
const stat = vi.mocked(fs.stat);
const dirStats = { isDirectory: () => true } as never;
const fileStats = { isDirectory: () => false } as never;
let releases: Map<string, () => void>;
let warn: ReturnType<typeof vi.spyOn>;
/**
* Make the first stat() of each given path hang until released (the mount is
* down); every later stat, and every other path, answers "a directory exists".
*/
function hangOn(paths: string[]): Map<string, () => void> {
stat.mockImplementation((path) => {
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
return new Promise((resolve) => {
releases.set(String(path), () => resolve(dirStats));
});
});
return releases;
}
/** Start probes for `paths` and let them time out, leaving each one stalled. */
async function stall(paths: string[]): Promise<void> {
const pending = paths.map((p) => probePath(p));
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
}
beforeEach(() => {
mounts.table = mounts.default;
releases = new Map();
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
// Settle every stalled stat so module state does not leak into the next test.
releases.forEach((release) => release());
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
else await new Promise((r) => setTimeout(r, 0));
vi.useRealTimers();
stat.mockReset();
warn.mockRestore();
});
describe('probePath', () => {
it('tells present, absent and unreadable apart', async () => {
stat.mockImplementation(async (path) => {
if (String(path) === '/present') return dirStats;
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
expect(await probePath('/present')).toBe('present');
expect(await probePath('/missing')).toBe('absent');
expect(await probePath('/notdir/child')).toBe('absent');
// A soft mount that gave up answers EIO: that is not proof the path is gone.
expect(await probePath('/eio')).toBe('unknown');
expect(await boundedPathExists('/present')).toBe(true);
expect(await boundedPathExists('/missing')).toBe(false);
expect(await boundedPathExists('/eio')).toBe(false);
// An error answer is neither a stall nor a refusal.
expect(unknownPathReason('/eio')).toBe('unreadable');
expect(describeUnknownPath('Case folder', '/eio')).toBe('Case folder is not responding or not readable: /eio');
});
it('reports whether a present path is a directory', async () => {
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
expect(await probePathKind('/dir')).toBe('directory');
expect(await probePathKind('/file')).toBe('file');
});
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
vi.useFakeTimers();
hangOn(['/mnt/stalled/case']);
const result = probePath('/mnt/stalled/case');
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await result).toBe('unknown');
// A second caller gets the cached verdict immediately, without another stat.
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(1);
// Once the mount answers, the path is probed afresh.
releases.get('/mnt/stalled/case')!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/mnt/stalled/case')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('shares one in-flight stat between concurrent callers of the same path', async () => {
hangOn(['/slow']);
const a = probePath('/slow');
const b = boundedPathExists('/slow');
expect(stat).toHaveBeenCalledTimes(1);
releases.get('/slow')!();
expect(await a).toBe('present');
expect(await b).toBe(true);
});
it('does not give concurrent healthy probes a false negative', async () => {
stat.mockImplementation(async () => dirStats);
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
expect(results).toEqual([true, true, true, true, true]);
});
it('still probes a healthy path as present while fewer unrelated paths are stalled than the cap', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES - 1 }, (_, i) => `/mnt/nas-${i}/project`);
hangOn(dead);
await stall(dead);
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
});
it('answers unknown, without a stat, for paths near a stalled one', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
stat.mockClear();
// Its own files, and a sibling linked case on the same mount.
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
expect(stat).not.toHaveBeenCalled();
releases.get('/mnt/nas/project-one')!();
await vi.advanceTimersByTimeAsync(0);
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('reads octal-escaped mount points from the table', async () => {
vi.useFakeTimers();
hangOn(['/mnt/nas b/one']);
await stall(['/mnt/nas b/one']);
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
});
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
vi.useFakeTimers();
hangOn(['/srv/projects/stuck']);
await stall(['/srv/projects/stuck']);
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
expect(await probePath('/srv/projects/other')).toBe('present');
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
});
it('narrows a stall on a local mount to the stalled path, even when that mount is not /', async () => {
// /home is its own local filesystem; ~/nas is a symlink to a network mount, so
// the stalled path is typed under /home. Only network and FUSE mounts widen.
vi.useFakeTimers();
mounts.table = [mounts.default, '/dev/sdb1 /home ext4 rw,relatime 0 0', ''].join('\n');
hangOn(['/home/user/nas/project']);
await stall(['/home/user/nas/project']);
stat.mockClear();
expect(await probePath('/home/user/nas/project/CLAUDE.md')).toBe('unknown');
expect(isNearStalledPath('/home/user/codeman-cases/one')).toBe(false);
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
expect(await probePath('/home/user/nas/other')).toBe('present');
expect(stat).toHaveBeenCalledTimes(2);
});
it('widens a stall to the whole mount for network and FUSE filesystems', async () => {
vi.useFakeTimers();
mounts.table = [
mounts.default,
'nas:/four /srv/nas4 nfs4 rw,hard 0 0',
'user@host:/ /srv/sshfs fuse.sshfs rw 0 0',
'//nas/share /srv/smb cifs rw 0 0',
'',
].join('\n');
const dead = ['/srv/nas4/one', '/srv/sshfs/one'];
hangOn(dead);
await stall(dead);
expect(isNearStalledPath('/srv/nas4/two')).toBe(true);
expect(isNearStalledPath('/srv/sshfs/two')).toBe(true);
expect(isNearStalledPath('/srv/smb/two')).toBe(false);
expect(isNearStalledPath('/srv/elsewhere')).toBe(false);
});
it('narrows a stall to the stalled path when there is no mount table', async () => {
vi.useFakeTimers();
mounts.table = null;
hangOn(['/mnt/nas/project-one']);
await stall(['/mnt/nas/project-one']);
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
expect(await probePath('/mnt/nas/project-two')).toBe('present');
});
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
hangOn(dead);
await stall(dead);
stat.mockClear();
// Every slot is held by a stat that never returned: refuse another, but never
// claim the path is absent.
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// Once the stalled stats settle, probing resumes normally.
releases.forEach((release) => release());
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/elsewhere')).toBe('present');
expect(stat).toHaveBeenCalledTimes(1);
});
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
hangOn([...dead, '/mnt/another-dead/case']);
await stall(dead);
stat.mockClear();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
// A retry is answered from the stall record, not with another stat.
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
expect(stat).toHaveBeenCalledTimes(2);
});
it('stops pastCap probes at the threadpool ceiling, answering unknown without a stat', async () => {
vi.useFakeTimers();
// Fill the bulk cap, then let pastCap probes stall until the ceiling is reached.
const dead = Array.from({ length: PATH_PROBE_STALL_CEILING + 1 }, (_, i) => `/mnt/ceiling-${i}/case`);
hangOn(dead);
await stall(dead.slice(0, MAX_STALLED_PATH_PROBES));
for (const path of dead.slice(MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING)) {
const hung = probePath(path, { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await hung).toBe('unknown');
}
stat.mockClear();
// One worker must stay free: no new stat, even for an explicit request.
const refused = probePath(dead[PATH_PROBE_STALL_CEILING], { pastCap: true });
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
expect(await refused).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('unknown');
expect(stat).not.toHaveBeenCalled();
// ...and the message says the folder was never checked, rather than blaming it.
expect(unknownPathReason('/healthy/explicit', { pastCap: true })).toBe('refused');
expect(unknownPathReason(dead[0], { pastCap: true })).toBe('stalled');
expect(describeUnknownPath('workingDir', '/healthy/explicit', { pastCap: true })).toMatch(
/^workingDir was not checked: .*not answering.*: \/healthy\/explicit$/
);
expect(describeUnknownPath('workingDir', dead[0], { pastCap: true })).toBe(
`workingDir is not responding or not readable: ${dead[0]}`
);
// Once one stalled stat settles, an explicit request is probed again.
releases.get(dead[0])!();
await vi.advanceTimersByTimeAsync(0);
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
});
it('keeps the bulk cap below the ceiling, so a pastCap probe has room', async () => {
// Read under a controlled environment: the limits are computed at import from
// UV_THREADPOOL_SIZE and CODEMAN_PATH_PROBE_MAX_STALLED, which the test process
// could otherwise inherit.
const saved = { uv: process.env.UV_THREADPOOL_SIZE, max: process.env.CODEMAN_PATH_PROBE_MAX_STALLED };
const limitsUnder = async (uv: string | undefined, max: string | undefined) => {
if (uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = uv;
if (max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = max;
vi.resetModules();
return import('../src/config/path-probe.js');
};
try {
const defaults = await limitsUnder(undefined, undefined);
expect([defaults.MAX_STALLED_PATH_PROBES, defaults.PATH_PROBE_STALL_CEILING]).toEqual([2, 3]);
const bigPool = await limitsUnder('8', undefined);
expect([bigPool.MAX_STALLED_PATH_PROBES, bigPool.PATH_PROBE_STALL_CEILING]).toEqual([6, 7]);
// An override may reach the ceiling but never pass it.
const overridden = await limitsUnder(undefined, '64');
expect(overridden.MAX_STALLED_PATH_PROBES).toBe(overridden.PATH_PROBE_STALL_CEILING);
} finally {
if (saved.uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
else process.env.UV_THREADPOOL_SIZE = saved.uv;
if (saved.max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = saved.max;
vi.resetModules();
}
});
it('warns once when a path first stalls and once when the cap engages', async () => {
vi.useFakeTimers();
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
hangOn(dead);
await stall([dead[0]]);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
// Asking again about the same stalled path does not warn again.
await probePath(dead[0]);
expect(warn).toHaveBeenCalledTimes(1);
await stall(dead.slice(1));
warn.mockClear();
await probePath('/healthy/one');
await probePath('/healthy/two');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
});
});
+173
View File
@@ -0,0 +1,173 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let parent: string;
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(parent, { recursive: true, force: true });
}, 60000);
const open = async () => {
await page.evaluate(() => {
(window as any).app.showCreateCaseModal();
(window as any).app.switchCaseModalTab('case-create');
});
};
const toastText = () =>
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
await open();
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
await page.fill('#newCaseName', 'my-app');
await page.fill('#newCasePath', '~/projects/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
});
it('is exclusive with the Docker option, in both directions', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseDocker)');
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseDocker)');
});
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'picked');
const opts = await page.evaluate(() => {
// A top-level `const` in a classic script: a global binding, not a window property.
const picker = PathPicker;
let captured: any = null;
const original = picker.open;
picker.open = (o: any) => (captured = o);
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
picker.open = original;
captured.onSelect('/srv/work');
return { directoriesOnly: captured.directoriesOnly };
});
expect(opts.directoriesOnly).toBe(true);
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
});
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'no-folder');
await page.evaluate(() => (window as any).app.createCase());
expect(await toastText()).toMatch(/Choose the folder/);
});
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'in-custom');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.submitCaseModal());
await page.waitForFunction(() =>
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
const target = join(parent, 'in-custom');
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
expect(existsSync(join(target, 'src'))).toBe(true);
const cases = await page.evaluate(async () => {
const body = await (await fetch('/api/cases')).json();
return Array.isArray(body) ? body : body.data;
});
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
});
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
const busy = join(parent, 'busy');
mkdirSync(busy);
writeFileSync(join(busy, 'keep.txt'), 'mine');
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'busy');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.createCase());
await page.waitForFunction(() =>
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
await open();
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
});
it('keeps / as the root parent instead of sending an empty path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'at-root');
await page.fill('#newCasePath', '/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
});
it('names the folder the server created in the success toast (~ expanded)', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'via-tilde');
await page.fill('#newCasePath', `~/${basename(parent)}`);
await page.evaluate(() => (window as any).app.submitCaseModal());
const target = join(realpathSync(parent), 'via-tilde');
await page.waitForFunction(
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
target
);
expect(await toastText()).not.toMatch(/created in ~\//);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCasePath', '/tmp');
await open();
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
expect(await page.inputValue('#newCasePath')).toBe('');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
* lstat / readdir calls that would wait on a hard mount forever.
*
* Only the chosen dead paths hang; everything else is the real filesystem.
* Port: none.
*/
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so a stall costs ~100 ms, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
});
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
return ((path: string, ...rest: unknown[]) => {
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
probe.touched.push(`${name} ${String(path)}`);
return new Promise((_resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
});
}
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
}
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = hangOnDead('stat', actual.stat);
return { ...actual, stat, default: { ...actual, stat } };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const promises = {
...actual.promises,
realpath: hangOnDead('realpath', actual.promises.realpath),
stat: hangOnDead('stat', actual.promises.stat),
lstat: hangOnDead('lstat', actual.promises.lstat),
readdir: hangOnDead('readdir', actual.promises.readdir),
};
return { ...actual, promises, default: { ...actual, promises } };
});
import { prepareNewCasePath } from '../src/web/case-path.js';
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
afterEach(async () => {
probe.releases.splice(0).forEach((release) => release());
probe.touched.length = 0;
await new Promise((r) => setTimeout(r, 0));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('prepareNewCasePath on a parent folder that does not answer', () => {
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await Promise.race([
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
]);
warn.mockRestore();
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
expect((result as { reason: string }).reason).toBe(
`The parent folder is not responding or not readable: ${probe.dead}`
);
// Only the bounded probe's own stat reached the dead mount.
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
});
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
});
});
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
let root: string;
let home: string;
let ctx: NewCasePathContext;
beforeEach(() => {
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
home = join(root, 'home');
mkdirSync(join(home, 'code'), { recursive: true });
ctx = { home, dataDir: join(home, '.codeman') };
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('expandHome', () => {
it('expands ~ and ~/x only', () => {
expect(expandHome('~', '/h')).toBe('/h');
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
expect(expandHome('~other/x', '/h')).toBe('~other/x');
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
});
});
describe('blockedReason', () => {
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
it.each([
['/', /root/],
['/etc', /system directory/],
['/etc/cron.d/x', /system directory/],
['/usr/local/src', /system directory/],
['/proc/1', /system directory/],
['/home/u', /home folder itself/],
['/home/u/.ssh', /credentials/],
['/home/u/.ssh/proj', /credentials/],
['/home/u/.aws/x', /credentials/],
['/home/u/.claude/skills/x', /configuration/],
['/home/u/.codeman/cases/x', /data folder/],
['/home/u/.codeman-beta/x', /data folder/],
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
'allows %s (a name that merely contains a blocked word is fine)',
(p) => expect(blockedReason(p, c)).toBeNull()
);
it('does not treat /etcetera or /usrlocal as the system directories', () => {
expect(blockedReason('/etcetera/x', c)).toBeNull();
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
});
});
describe('prepareNewCasePath', () => {
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
expect(r).toMatchObject({ ok: true, existedEmpty: false });
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
});
it('accepts an existing EMPTY folder and says so', async () => {
mkdirSync(join(home, 'code', 'empty'));
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
});
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
// ...and a folder with a space in it can be the PARENT of the next one.
mkdirSync(join(home, 'code', 'with space'));
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
});
it.each([
['empty', ''],
['whitespace', ' '],
['relative', 'code/app'],
['dot-relative', './app'],
['traversal', '/tmp/../etc/x'],
['shell metacharacters', '/tmp/a;rm -rf /'],
['command substitution', '/tmp/$(id)'],
['quotes', "/tmp/it's"],
['newline', '/tmp/a\nb'],
])('rejects %s as invalid', async (_label, raw) => {
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
});
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
for (const raw of [
'/etc/proj',
'/usr/src/x',
home,
join(home, '.ssh', 'x'),
join(home, '.codeman', 'cases', 'x'),
]) {
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
}
});
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
symlinkSync('/etc', join(home, 'code', 'sneaky'));
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
const realHome = join(root, 'realhome');
mkdirSync(join(realHome, '.ssh'), { recursive: true });
mkdirSync(join(realHome, 'code'));
const linkHome = join(root, 'linkhome');
symlinkSync(realHome, linkHome);
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
// The same for Codeman's data dir given through the link.
mkdirSync(join(realHome, '.codeman'));
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('refuses a link into a cases directory, judged on its resolved form', async () => {
const cases = join(home, 'codeman-cases');
mkdirSync(cases);
symlinkSync(cases, join(home, 'code', 'shortcut'));
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
});
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
});
it('refuses a parent that is a file', async () => {
writeFileSync(join(home, 'code', 'afile'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
});
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
mkdirSync(join(home, 'code', 'mine'));
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
});
it('refuses a target that is a file or a symbolic link', async () => {
writeFileSync(join(home, 'code', 'plain'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
mkdirSync(join(home, 'code', 'real'));
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
});
it('never creates anything', async () => {
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
const { existsSync } = await import('node:fs');
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
*
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
* sentence; with the split pane open it was not even the pane being typed in.
* The action now has NO default key: the capture-phase shortcut handler lets
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
* stays in the registry so a user can still bind a key to it in App Settings →
* Shortcuts.
*
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
type App = {
getShortcutRegistry(): Shortcut[];
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
loadAppSettingsFromStorage: () => Record<string, unknown>;
};
function makeApp(settings: Record<string, unknown> = {}): App {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn() },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const app = Object.create(CodemanApp.prototype) as App;
app.loadAppSettingsFromStorage = () => settings;
return app;
}
const keydown = (overrides: Record<string, unknown>) => ({
type: 'keydown',
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
...overrides,
});
describe('Ctrl+W is left to the terminal', () => {
it('Close Session is still in the registry, with no default key', () => {
const close = makeApp()
.getShortcutRegistry()
.find((s) => s.id === 'close-session');
expect(close?.action).toBe('killActiveSession');
expect(close?.bindings).toEqual([]);
});
it.each([
['Ctrl+W', { ctrlKey: true }],
['Cmd+W', { metaKey: true }],
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
const app = makeApp();
const event = keydown(mods);
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
expect(matched.map((s) => s.id)).toEqual([]);
});
it('a user can still bind a key to Close Session', () => {
const app = makeApp({
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
});
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
});
it('the shortcut overlay says an unbound action is not bound', () => {
const appSource = read('app.js');
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
});
});
+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');
});
});
+136 -1
View File
@@ -1,4 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dependencyRegistry } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
environment: env,
which: () => null,
fileExists: () => false,
isExecutableFile: () => false,
runVersion: () => null,
windowsProgramRoots: () => [],
windowsFileVersion: () => null,
@@ -259,6 +263,121 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => {
});
});
describe('checkTool with searchDirs (service PATH is minimal)', () => {
const claudeLike: ToolDependency = {
...tmuxTool,
id: 'claude',
label: 'Claude CLI',
resolvers: [
{
match: ['linux'],
resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] },
},
],
};
it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({
status: 'ok',
path: '/opt/npm/bin/claude',
version: '2.1.0',
});
expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']);
});
it('still reports missing when neither PATH nor any search dir has it', () => {
expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' });
});
it('prefers the PATH hit over a search dir', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/claude',
isExecutableFile: () => true,
runVersion: () => '1.0.0',
});
expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' });
});
// The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only
// as an absolute path to an executable regular file. A file that merely exists is not one.
it('skips a search-dir file that exists but is not executable', () => {
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion });
expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => {
const relative: ToolDependency = {
...claudeLike,
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }],
};
const runVersion = vi.fn(() => 'claude 2.1.0');
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion });
expect(checkTool(relative, host)).toMatchObject({ status: 'missing' });
expect(runVersion).not.toHaveBeenCalled();
});
// The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin.
// The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor
// used to stop at the PATH hit and report MISSING.
const squatted: ToolDependency = {
...claudeLike,
id: 'pi',
label: 'Pi CLI',
resolvers: [
{
match: ['linux'],
resolver: {
kind: 'path',
bins: ['pi'],
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'],
},
},
],
};
it('finds the right binary in a search dir when a wrong one is on the PATH', () => {
const host = fakeHost('linux', {
which: () => '/usr/bin/pi',
isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi',
runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'),
});
expect(checkTool(squatted, host)).toMatchObject({
status: 'ok',
path: '/home/u/.npm-global/bin/pi',
version: '0.84.3',
});
});
it('version-checks each search-dir candidate and moves past one that fails', () => {
const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n'));
const host = fakeHost('linux', { isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('probes a search dir that is also on the PATH only once', () => {
const runVersion = vi.fn(() => 'Raspberry Pi utility\n');
const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion });
expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' });
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
});
it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => {
const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver));
const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length);
expect(withDirs.length).toBeGreaterThan(0);
for (const r of withDirs) {
if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false);
}
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
expect(typeof host.which).toBe('function');
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
});
it('counts only an executable regular file as a search-dir candidate', () => {
const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-'));
try {
const file = join(dir, 'tool');
writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 });
const host = createRealHost();
expect(host.isExecutableFile(file)).toBe(false);
chmodSync(file, 0o755);
expect(host.isExecutableFile(file)).toBe(true);
expect(host.isExecutableFile(dir)).toBe(false);
expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment node
// The contract GET /api/doctor's default runner relies on: the same entry script, given
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
// non-zero because something required is missing.
//
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
// clis.json in that HOME's data dir drops the registry's absolute search dirs
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
import { execFile, execFileSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const ROOT = join(import.meta.dirname, '..');
interface DoctorRun {
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
stderr: string;
}
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
symlinkSync(process.execPath, join(bare, 'node'));
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
const clis = Object.fromEntries(
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
);
mkdirSync(join(home, '.codeman'), { recursive: true });
// 0600 or the registry ignores the file (isUnsafePermissions).
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
delete env.CODEMAN_DATA_DIR;
delete env.CODEMAN_INSTANCE;
return {
home,
bare,
env,
cleanup: () => {
rmSync(home, { recursive: true, force: true });
rmSync(bare, { recursive: true, force: true });
},
};
}
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
join(ROOT, 'src/index.ts'),
'doctor',
'--json',
'--category',
'core',
],
{ timeout: 60_000, cwd: ROOT, env },
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
);
});
}
describe('codeman doctor --json', () => {
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
const h = hermeticDoctorEnv();
try {
const { report, stderr } = await runDoctor(h.env);
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
const node = report.tools.find((t) => t.id === 'node');
expect(node?.status).toBe('ok');
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
// the doctor found, and so ran, lives outside this test's own temp dirs.
expect(stderr).not.toContain('[cli-registry]');
for (const t of report.tools.filter((t) => t.path)) {
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
}
} finally {
h.cleanup();
}
}, 90_000);
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
// searchDirs) found it.
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
const h = hermeticDoctorEnv();
try {
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
const fake = join(h.home, '.local/bin/claude');
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
chmodSync(fake, 0o755);
const { report } = await runDoctor(h.env);
const claude = report.tools.find((t) => t.id === 'claude');
expect(claude).toMatchObject({ status: 'ok', path: fake });
} finally {
h.cleanup();
}
}, 90_000);
});
+110
View File
@@ -0,0 +1,110 @@
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3196;
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
usedBy: [],
status: 'ok',
version: '22.1.0',
path: '/usr/bin/node',
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
usedBy: [],
status: 'missing',
installHint: 'apt install tmux',
},
// Host-supplied strings must be rendered as text, never as markup.
{
id: 'x',
label: '<img src=x onerror=window.__pwned=1>',
category: 'other',
required: false,
usedBy: [],
status: 'missing',
},
],
};
describe('Diagnostics panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
await page.route('**/api/doctor', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
const text = await page.textContent('#doctorResult');
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
expect(text).toContain('✓ Node.js ok · 22.1.0');
expect(text).toContain('/usr/bin/node');
expect(text).toContain('✗ tmux missing · required');
// A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it.
expect(text).toContain('○ <img src=x onerror=window.__pwned=1> missing · optional');
expect(text).toContain('Install: apt install tmux');
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
expect(await page.$('#doctorResult img')).toBeNull();
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('shows the server’s message when the check fails, and re-enables the button', async () => {
await page.unroute('**/api/doctor');
await page.route('**/api/doctor', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
})
);
await page.click('#doctorRunBtn');
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
});
it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => {
const visible = (user: Record<string, unknown>) =>
page.evaluate((u) => {
(window as any).__codemanUser = u;
document.dispatchEvent(new CustomEvent('codeman:me'));
return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none';
}, user);
expect(await visible({ multiUser: true, role: 'user' })).toBe(false);
expect(await visible({ multiUser: true, role: 'admin' })).toBe(true);
expect(await visible({ multiUser: false })).toBe(true);
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
*
* With the split pane open, the global shortcut handler (app.js, capture phase)
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
* now answers with the pane whose terminal was focused last, and the actions
* that are about a TERMINAL (clear, restore size) go through it.
*
* Close Session is not one of them: it has no default key any more (Ctrl+W is
* left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
* key a user binds to it closes the active session, as it always did.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 'session-a';
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
app.showToast = vi.fn();
app.sendResize = vi.fn(async () => true);
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
return app;
}
function paneB(overrides: Record<string, unknown> = {}) {
return {
sessionId: 'session-b',
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
fit: vi.fn(),
_destroyed: false,
...overrides,
};
}
describe('_focusedPane()', () => {
it('is the primary pane until another pane claims the keyboard', () => {
const app = loadApp();
const pane = app._focusedPane();
expect(pane.isPrimary).toBe(true);
expect(pane.terminal).toBe(app.terminal);
expect(pane.sessionId).toBe('session-a');
});
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
app._noteFocusedTile(null);
expect(app._focusedPane().isPrimary).toBe(true);
});
it('never answers with a destroyed pane', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
tile._destroyed = true;
expect(app._focusedPane().isPrimary).toBe(true);
});
});
describe('terminal shortcuts follow the focused pane', () => {
it('Ctrl+L clears the focused second pane, not the primary', () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
app.clearTerminal();
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
expect(app.terminal.clear).not.toHaveBeenCalled();
});
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
const app = loadApp();
app.clearTerminal();
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
});
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
const app = loadApp();
const tile = paneB();
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(tile.fit).toHaveBeenCalledWith({ force: true });
expect(app.sendResize).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
});
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
const app = loadApp();
await app.restoreTerminalSize();
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
});
});
describe('Close Session (user-bound key only) stays on the active session', () => {
it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
const appSource = read('app.js');
const body = appSource.slice(
appSource.indexOf('async killActiveSession() {'),
appSource.indexOf('async killAllSessions() {')
);
expect(body).toContain('await this.closeSession(this.activeSessionId);');
expect(body).not.toContain('_focusedPane');
});
});
+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.
+414
View File
@@ -0,0 +1,414 @@
/** @fileoverview Bottom-bar Git indicator and panel, end to end: real server, real Chromium, a real git repo with a remote. */
import { execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3192;
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', ['-c', 'protocol.file.allow=always', ...args], { cwd, env: ENV, stdio: 'ignore' });
describe('Git status indicator in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
let repo: string;
let plain: string;
let repoSession: string;
let plainSession: string;
const gitStatusRequests: string[] = [];
const settingsPutStatuses: number[] = [];
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
const commitAll = (msg: string) => {
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', msg);
};
const label = () => page.evaluate(() => document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '');
const buttonVisible = () => page.evaluate(() => !document.getElementById('gitStatusBtn')!.hidden);
const refresh = () => page.evaluate(() => (window as any).app.refreshGitStatusNow());
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowGitStatus')) !== on)
await page.click('label.switch:has(#appSettingsShowGitStatus)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
const createSession = (dir: string) =>
page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id as string;
}, dir);
const select = (id: string) =>
page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, id);
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'git-ui-'));
const bare = join(root, 'origin.git');
repo = join(root, 'repo');
plain = join(root, 'plain');
mkdirSync(repo);
mkdirSync(plain);
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'init', '-q', '-b', 'main');
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commitAll('base');
git(repo, 'push', '-q', '-u', 'origin', 'main');
// Dirty: a modified file, an untracked file, and one commit nobody has pushed.
write('b.txt', 'b\n');
commitAll('add b (not pushed)');
write('a.txt', '2\n');
write('new file.txt', 'n\n');
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('request', (r) => {
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
});
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
await select(repoSession);
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
}, 60000);
it('is off by default: no button and no request to the git-status route', async () => {
await page.waitForTimeout(2500); // longer than one tick
expect(await buttonVisible()).toBe(false);
expect(gitStatusRequests).toEqual([]);
});
it('turning it on through Settings saves cleanly (the key must not reach the strict PUT) and shows the counts', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
expect(await label()).toContain('● 2'); // a.txt modified + new file.txt untracked
expect(await label()).toContain('↑ 1'); // one commit not pushed
const title = await page.getAttribute('#gitStatusBtn', 'title');
expect(title).toMatch(/main/);
expect(title).toMatch(/2 uncommitted files/);
expect(title).toMatch(/1 commit not pushed/);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--dirty/);
// The toolbar skin rules out-rank a bare class, so check the colour the user actually sees.
const colour = await page.evaluate(() => getComputedStyle(document.getElementById('gitStatusBtn')!).color);
expect(colour).toBe('rgb(224, 160, 48)');
});
it('sits at the right of the bottom bar, beside the version', async () => {
const inRight = await page.evaluate(() => !!document.querySelector('.toolbar-right #gitStatusBtn'));
expect(inRight).toBe(true);
const order = await page.evaluate(() => {
const right = document.querySelector('.toolbar-right')!;
return [...right.children].map((c) => c.id).filter(Boolean);
});
expect(order.indexOf('gitStatusBtn')).toBeLessThan(order.indexOf('versionDisplay'));
});
it('opens a panel listing the uncommitted files and the unpushed commit', async () => {
await page.click('#gitStatusBtn');
await page.waitForSelector('#gitStatusPanel.visible');
await page.waitForFunction(() => /Not staged/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('main → origin/main');
expect(body).toContain('Uncommitted changes (2)');
expect(body).toMatch(/Not staged \(1\)/);
expect(body).toMatch(/Untracked \(1\)/);
expect(body).toContain('a.txt');
expect(body).toContain('new file.txt');
expect(body).toContain('Not pushed (1)');
expect(body).toContain('add b (not pushed)');
expect(await page.textContent('#gitStatusBranch')).toBe('main');
expect(await page.textContent('#gitStatusFooter')).toMatch(/Read-only/);
expect(await page.getAttribute('#gitStatusBtn', 'aria-expanded')).toBe('true');
});
it('renders hostile file names as text, never as markup', async () => {
write('<img src=x onerror=window.__pwned=1>.txt');
await refresh();
await page.waitForFunction(() => /onerror/.test(document.getElementById('gitStatusBody')!.textContent ?? ''));
expect(await page.$('#gitStatusBody img')).toBeNull();
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('clicking a file shows its diff in the panel; Back returns to the list; Open file previews it', async () => {
await page.evaluate(() => {
(window as any).__previewed = [];
(window as any).app.openFilePreview = (p: string) => (window as any).__previewed.push(p);
});
await page.click('.git-status-file:has-text("a.txt")');
await page.waitForSelector('#gitStatusBody .git-diff');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('a.txt');
expect(await page.textContent('#gitStatusBody .git-diff-line--del')).toBe('-1\n');
expect(await page.textContent('#gitStatusBody .git-diff-line--add')).toBe('+2\n');
// The 15 s poll re-renders the panel; the diff must survive it.
await refresh();
expect(await page.$('#gitStatusBody .git-diff')).not.toBeNull();
await page.click('#gitStatusBody button:has-text("Open file")');
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([join(repo, 'a.txt')]);
await page.click('#gitStatusBody button:has-text("Back")');
await page.waitForSelector('.git-status-file:has-text("a.txt")');
expect(await page.$('#gitStatusBody .git-diff')).toBeNull();
});
it('an untracked file diffs as all additions, and a deleted file as all removals (with no Open file)', async () => {
await page.click('.git-status-file:has-text("new file.txt")');
await page.waitForSelector('#gitStatusBody .git-diff-line--add');
expect(await page.locator('#gitStatusBody .git-diff-line--del').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
rmSync(join(repo, 'b.txt'));
await refresh();
await page.waitForSelector('.git-status-file:has(.git-status-badge--D)');
await page.click('.git-status-file:has(.git-status-badge--D)');
await page.waitForSelector('#gitStatusBody .git-diff-line--del');
expect(await page.locator('#gitStatusBody .git-diff-line--add').count()).toBe(0);
expect(await page.locator('#gitStatusBody button:has-text("Open file")').count()).toBe(0);
await page.click('#gitStatusBody button:has-text("Back")');
});
it('keeps keyboard focus on the same file row across the 15 s re-render', async () => {
await page.focus('.git-status-file:has-text("a.txt")');
const key = () => page.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.gitKey ?? null);
expect(await key()).toBe('unstaged|a.txt');
await refresh();
await page.waitForFunction(() => document.activeElement?.getAttribute('data-git-key') === 'unstaged|a.txt');
expect(await key()).toBe('unstaged|a.txt');
});
it('the panel never starts off-screen, even on a 650px-wide viewport', async () => {
const original = page.viewportSize()!;
await page.setViewportSize({ width: 650, height: original.height });
const left = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
await page.setViewportSize(original);
expect(left).toBeGreaterThanOrEqual(0);
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.git-status-header').boundingBox())!;
await page.mouse.move(box.x + 40, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
await page.mouse.up();
const after = await page.evaluate(() => document.getElementById('gitStatusPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('groups files under folders that start collapsed and expand on click; the setting turns it off', async () => {
mkdirSync(join(repo, 'deep/er/still'), { recursive: true });
mkdirSync(join(repo, 'docs'));
write('deep/er/still/one.txt');
write('docs/a.md');
write('docs/b.md');
// git reports an all-untracked folder as ONE `dir/` entry, so commit these first and then edit them.
git(repo, 'add', 'deep', 'docs');
git(repo, 'commit', '-q', '-m', 'add folders');
write('deep/er/still/one.txt', 'changed\n');
write('docs/a.md', 'changed\n');
write('docs/b.md', 'changed\n');
await refresh();
await page.waitForSelector('.git-tree-dir');
// `deep/er/still` is a chain of single-child folders: one row, not three.
const names = await page.locator('.git-tree-name').allTextContents();
expect(names).toContain('deep/er/still/');
expect(names).toContain('docs/');
expect(await page.locator('.git-tree-dir[open]').count()).toBe(0);
expect(await page.locator('.git-status-file:has-text("one.txt")').isVisible()).toBe(false);
expect(await page.locator('.git-tree-dir:has(.git-tree-name:text-is("docs/")) .git-tree-count').textContent()).toBe(
'2'
);
await page.click('.git-tree-summary:has-text("docs/")');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// The open folder survives the re-render a refresh causes.
await refresh();
await page.waitForSelector('.git-tree-dir[open]');
expect(await page.locator('.git-status-file:has-text("a.md")').isVisible()).toBe(true);
// A file in a folder still opens its diff, and shows only its own name.
await page.click('.git-status-file:has-text("a.md")');
await page.waitForSelector('#gitStatusBody .git-diff-path');
expect(await page.textContent('#gitStatusBody .git-diff-path')).toBe('docs/a.md');
await page.click('#gitStatusBody button:has-text("Back")');
// Setting off: the flat list, every file by its full path.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
await page.evaluate(() => (window as any).app._renderGitStatusPanel());
expect(await page.locator('.git-tree-dir').count()).toBe(0);
expect(await page.locator('.git-status-path', { hasText: 'docs/a.md' }).count()).toBe(1);
// Back on for the rest of the file.
await page.evaluate(() => (window as any).app.openAppSettings());
await page.click('label.switch:has(#appSettingsGitStatusTree)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
// gitStatusTree is per-device: it must never reach the strict PUT /api/settings (a 400 there is
// what an unstripped key looks like), and it must round-trip through the saved settings.
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
expect(await page.evaluate(() => (window as any).app.isGitStatusTree())).toBe(true);
rmSync(join(repo, 'deep'), { recursive: true });
rmSync(join(repo, 'docs'), { recursive: true });
}, 30000);
it('once everything is committed and pushed the button says so, and the panel agrees', async () => {
rmSync(join(repo, '<img src=x onerror=window.__pwned=1>.txt'));
git(repo, 'checkout', '-q', '--', '.');
git(repo, 'clean', '-fdq');
git(repo, 'push', '-q');
await refresh();
// A manual refresh bypasses the server's short cache, so this shows the push at once (not at the
// next 15 s poll, which is what a cached answer would mean).
await page.waitForFunction(
() => (document.querySelector('#gitStatusBtn .git-status-label')?.textContent ?? '').includes('✓'),
null,
{ timeout: 5000 }
);
expect(await page.getAttribute('#gitStatusBtn', 'class')).toMatch(/git-status--clean/);
// Toolbar buttons animate colour changes, so wait for the transition rather than racing it.
await page.waitForFunction(
() => getComputedStyle(document.getElementById('gitStatusBtn')!).color !== 'rgb(224, 160, 48)'
);
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('Nothing uncommitted.');
expect(body).toContain('Every commit on this branch is on a remote.');
});
it('shows nothing for a session whose folder is not a repository, and follows the active session', async () => {
await select(plainSession);
await page.waitForFunction(() => document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
// The open panel must stop showing the previous repo at once, then say why there is nothing.
await page.waitForFunction(
() => /No git repository here/.test(document.getElementById('gitStatusBody')!.textContent ?? ''),
null,
{
timeout: 15000,
}
);
expect(await page.textContent('#gitStatusBody')).not.toContain('a.txt');
await select(repoSession);
await page.waitForFunction(() => !document.getElementById('gitStatusBtn')!.hidden, null, { timeout: 15000 });
});
it('a folder that holds several repositories shows each one, and the indicator adds them up', async () => {
const parent = join(root, 'monorepo-ish');
mkdirSync(parent);
for (const name of ['api', 'web']) {
const r = join(parent, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), '1\n');
git(r, 'add', '-A');
git(r, 'commit', '-q', '-m', 'init');
}
writeFileSync(join(parent, 'api', 'dirty.txt'), 'x');
writeFileSync(join(parent, 'api', 'dirty2.txt'), 'y');
const multiSession = await createSession(parent);
await select(multiSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 2, null, {
timeout: 15000,
});
expect(await page.textContent('#gitStatusBranch')).toBe('2 repositories');
expect(await label()).toContain('● 2'); // only api is dirty, with two files
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(2 repositories\)/);
const names = await page.$$eval('.git-status-repo-name', (els) => els.map((e) => e.textContent));
expect(names).toEqual(['api', 'web']);
// Every repository starts collapsed (the summary line shows what is outstanding); one the user
// opens stays open when a refresh re-renders the panel.
const allClosed = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(allClosed).toEqual([false, false]);
await page.click('.git-status-repo:nth-of-type(1) > summary');
await refresh();
await page.waitForSelector('.git-status-repo[open]');
const open = await page.$$eval('.git-status-repo', (els) => els.map((e) => (e as HTMLDetailsElement).open));
expect(open).toEqual([true, false]);
const apiBody = (await page.textContent('.git-status-repo:nth-of-type(1)')) ?? '';
expect(apiBody).toContain('dirty.txt');
expect(apiBody).toContain('dirty2.txt');
await select(repoSession);
await page.waitForFunction(() => document.querySelectorAll('#gitStatusBody .git-status-repo').length === 0, null, {
timeout: 15000,
});
});
it('turning the setting off while a read is in flight, then on again, does not leave polling dead', async () => {
let slow = true;
await page.route('**/api/sessions/*/git-status*', async (route) => {
if (slow) await new Promise((r) => setTimeout(r, 1500));
await route.continue();
});
page.setDefaultTimeout(6000);
// A background poll may be mid-read: let it finish so OUR read is the one the slow route holds.
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === false);
await page.evaluate(() => void (window as any).app.refreshGitStatus({ fresh: true }));
await page.waitForFunction(() => (window as any).app._gitStatusInFlight === true);
await setSetting(false);
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
slow = false;
await setSetting(true);
// Re-enabling starts its own read. With the flag stuck true that read is skipped for this session
// and the indicator never comes back.
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
page.setDefaultTimeout(30000);
// The first read may still be asleep in the handler: wait for it to continue, or its late
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
expect(await buttonVisible()).toBe(true);
// Turning the setting off closed the panel; reopen it for the tests that follow.
await page.click('#gitStatusBtn');
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
await page.click('.git-status-actions button[aria-label="Close git status"]');
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
await page.click('#gitStatusBtn');
expect(await page.isVisible('#gitStatusPanel')).toBe(true);
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
expect(await page.evaluate(() => (window as any).app._gitStatusTimer)).toBeNull();
const before = gitStatusRequests.length;
await page.waitForTimeout(3000);
expect(gitStatusRequests.length).toBe(before);
}, 30000);
});
+898
View File
@@ -0,0 +1,898 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearGitStatusCache,
getGitWorkspaceStatus,
MAX_FILES,
parseCommitLog,
parsePorcelainV2,
runGit,
type GitRunner,
} from '../src/git-workspace-status.js';
const NUL = '\0';
describe('parsePorcelainV2', () => {
const header = (extra: string[] = []) =>
['# branch.oid abc123', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +2 -1', ...extra].join(
NUL
) + NUL;
it('reads the branch, upstream and ahead/behind', () => {
const p = parsePorcelainV2(header());
expect(p).toMatchObject({
branch: 'main',
detached: false,
upstream: 'origin/main',
ahead: 2,
behind: 1,
files: [],
});
});
it('flags an upstream whose remote branch is gone: branch.upstream without branch.ab', () => {
expect(
parsePorcelainV2(['# branch.oid x', '# branch.head feature', '# branch.upstream origin/feature'].join(NUL) + NUL)
).toMatchObject({ upstream: 'origin/feature', upstreamGone: true });
expect(
parsePorcelainV2(
['# branch.oid x', '# branch.head main', '# branch.upstream origin/main', '# branch.ab +0 -0'].join(NUL) + NUL
)
).toMatchObject({ upstreamGone: false });
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL).upstreamGone).toBe(false);
});
it('reads a detached HEAD and a branch with no upstream (no branch.ab line either)', () => {
expect(parsePorcelainV2(['# branch.oid x', '# branch.head (detached)'].join(NUL) + NUL)).toMatchObject({
branch: null,
detached: true,
upstream: null,
ahead: 0,
});
expect(parsePorcelainV2(['# branch.oid x', '# branch.head feature'].join(NUL) + NUL)).toMatchObject({
branch: 'feature',
upstream: null,
});
});
it('turns an entry that is staged AND modified in the tree into one row per kind', () => {
const line = '1 MM N... 100644 100644 100644 aaa bbb src/a.ts';
const files = parsePorcelainV2(header([line])).files;
expect(files.map((f) => [f.kind, f.index, f.worktree, f.path])).toEqual([
['staged', 'M', 'M', 'src/a.ts'],
['unstaged', 'M', 'M', 'src/a.ts'],
]);
});
it('classifies staged-only, unstaged-only, added and deleted', () => {
const lines = [
'1 M. N... 100644 100644 100644 a b staged.ts',
'1 .M N... 100644 100644 100644 a b unstaged.ts',
'1 A. N... 000000 100644 100644 0 b added.ts',
'1 .D N... 100644 100644 000000 a b gone.ts',
];
const files = parsePorcelainV2(header(lines)).files;
expect(files.map((f) => `${f.kind}:${f.index}${f.worktree}:${f.path}`)).toEqual([
'staged:M.:staged.ts',
'unstaged:.M:unstaged.ts',
'staged:A.:added.ts',
'unstaged:.D:gone.ts',
]);
});
it('reads a rename with its original path from the following token', () => {
const text = header(['2 R. N... 100644 100644 100644 a b R100 new name.ts' + NUL + 'old name.ts']);
expect(parsePorcelainV2(text).files).toEqual([
{ path: 'new name.ts', origPath: 'old name.ts', index: 'R', worktree: '.', kind: 'staged' },
]);
});
it('reads unmerged and untracked entries, and keeps odd names intact', () => {
const text = header([
'u UU N... 100644 100644 100644 100644 a b c conflict.ts',
'? with space.txt',
'? quote"and\'tick.txt',
'? new\nline.txt',
'? dir/',
]);
const files = parsePorcelainV2(text).files;
expect(files.map((f) => [f.kind, f.path])).toEqual([
['conflicted', 'conflict.ts'],
['untracked', 'with space.txt'],
['untracked', 'quote"and\'tick.txt'],
['untracked', 'new\nline.txt'],
['untracked', 'dir/'],
]);
});
it('skips unknown lines and survives empty input', () => {
expect(parsePorcelainV2('')).toMatchObject({ files: [], branch: null });
expect(parsePorcelainV2('! ignored.log' + NUL + 'weird line' + NUL).files).toEqual([]);
});
});
describe('parseCommitLog', () => {
it('reads hash, author, time and subject, including unicode and empty input', () => {
const text = 'abc1234\x1fAda\x1f1700000000\x1ffix: café\x1e\ndef5678\x1fBob\x1f1700000100\x1fsecond\x1e';
expect(parseCommitLog(text)).toEqual([
{ hash: 'abc1234', author: 'Ada', time: 1700000000, subject: 'fix: café' },
{ hash: 'def5678', author: 'Bob', time: 1700000100, subject: 'second' },
]);
expect(parseCommitLog('')).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Real git
// ---------------------------------------------------------------------------
const GIT_ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'protocol.file.allow=always', ...args], {
cwd,
env: GIT_ENV,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
let root: string;
let repo: string;
const write = (rel: string, text = 'x\n', dir = repo) => {
const f = join(dir, rel);
mkdirSync(join(f, '..'), { recursive: true });
writeFileSync(f, text);
};
const commit = (msg: string, dir = repo) => {
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', msg);
};
beforeEach(() => {
clearGitStatusCache();
root = mkdtempSync(join(tmpdir(), 'git-status-'));
repo = join(root, 'repo');
mkdirSync(repo);
git(repo, 'init', '-q', '-b', 'main');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('getGitWorkspaceStatus against a real repository', () => {
it('is not-a-repo outside a repository', async () => {
const plain = join(root, 'plain');
mkdirSync(plain);
expect((await getGitWorkspaceStatus(plain)).state).toBe('not-a-repo');
});
it('reports a folder that no longer exists as not-a-repo or an error, never a crash', async () => {
const s = await getGitWorkspaceStatus(join(root, 'gone'));
expect(['not-a-repo', 'error']).toContain(s.state);
});
it('a fresh repo: on its branch, untracked files, no remote, nothing "unpushed"', async () => {
write('a.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ state: 'ok', branch: 'main', upstream: null, hasRemote: false, unpushedCount: 0 });
expect(s.counts).toMatchObject({ untracked: 1, uncommitted: 1, staged: 0, unstaged: 0 });
});
it('a clean repo with commits but no remote has nothing to push (not "every commit")', async () => {
write('a.txt');
commit('one');
write('b.txt');
commit('two');
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.uncommitted).toBe(0);
expect(s).toMatchObject({ hasRemote: false, unpushedCount: 0, unpushed: [] });
});
it('separates staged, unstaged and untracked, and counts a staged+modified file once as uncommitted', async () => {
write('tracked.txt', '1\n');
write('both.txt', '1\n');
write('removed.txt', '1\n');
commit('base');
write('both.txt', '2\n');
git(repo, 'add', 'both.txt');
write('both.txt', '3\n'); // staged AND modified again
write('tracked.txt', '2\n'); // unstaged only
git(repo, 'rm', '-q', 'removed.txt'); // staged delete
write('new.txt'); // untracked
const s = await getGitWorkspaceStatus(repo);
expect(s.counts).toMatchObject({ staged: 2, unstaged: 2, untracked: 1, conflicted: 0, uncommitted: 4 });
const row = (kind: string, path: string) => s.files.find((f) => f.kind === kind && f.path === path);
expect(row('staged', 'both.txt')).toBeTruthy();
expect(row('unstaged', 'both.txt')).toBeTruthy();
expect(row('staged', 'removed.txt')?.index).toBe('D');
expect(row('untracked', 'new.txt')).toBeTruthy();
});
it('reports a staged rename with its original path', async () => {
write('old.txt', 'content that is long enough to be detected as a rename\n'.repeat(5));
commit('base');
git(repo, 'mv', 'old.txt', 'new.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toContainEqual(
expect.objectContaining({ path: 'new.txt', origPath: 'old.txt', index: 'R', kind: 'staged' })
);
});
it('reports merge conflicts', async () => {
write('c.txt', 'base\n');
commit('base');
git(repo, 'checkout', '-q', '-b', 'other');
write('c.txt', 'other\n');
commit('other');
git(repo, 'checkout', '-q', 'main');
write('c.txt', 'main\n');
commit('main');
expect(() => git(repo, 'merge', 'other')).toThrow();
const s = await getGitWorkspaceStatus(repo);
expect(s.counts.conflicted).toBe(1);
expect(s.files.find((f) => f.kind === 'conflicted')?.path).toBe('c.txt');
});
it('handles file names with spaces, quotes and unicode', async () => {
write('with space.txt');
write('quote"d.txt');
write('café ☕.txt');
const s = await getGitWorkspaceStatus(repo);
expect(s.files.map((f) => f.path).sort()).toEqual(['café ☕.txt', 'quote"d.txt', 'with space.txt']);
});
it('caps the file list but keeps the counts exact', async () => {
for (let i = 0; i < MAX_FILES + 20; i++) write(`f${i}.txt`);
const s = await getGitWorkspaceStatus(repo);
expect(s.files).toHaveLength(MAX_FILES);
expect(s.filesTruncated).toBe(true);
expect(s.counts.untracked).toBe(MAX_FILES + 20);
expect(s.counts.uncommitted).toBe(MAX_FILES + 20);
});
it('counts stashes', async () => {
write('a.txt', '1\n');
commit('base');
write('a.txt', '2\n');
git(repo, 'stash', '-q');
expect((await getGitWorkspaceStatus(repo)).counts.stashes).toBe(1);
});
it('reports a detached HEAD', async () => {
write('a.txt');
commit('one');
git(repo, 'checkout', '-q', '--detach');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ detached: true, branch: null });
});
describe('with a remote', () => {
let bare: string;
beforeEach(() => {
bare = join(root, 'origin.git');
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
git(repo, 'remote', 'add', 'origin', bare);
write('a.txt', '1\n');
commit('first');
git(repo, 'push', '-q', '-u', 'origin', 'main');
});
it('in sync: nothing ahead, nothing unpushed', async () => {
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ upstream: 'origin/main', ahead: 0, behind: 0, hasRemote: true, unpushedCount: 0 });
});
it('lists commits that are ahead of the upstream, newest first, with the subject and author', async () => {
write('b.txt');
commit('second: add b');
write('c.txt');
commit('third: add c');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ ahead: 2, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['third: add c', 'second: add b']);
expect(s.unpushed[0]).toMatchObject({ author: 'T' });
expect(s.unpushed[0].hash).toMatch(/^[0-9a-f]{7,}$/);
expect(s.unpushed[0].time).toBeGreaterThan(1_600_000_000);
});
it('behind reflects the last fetch only: it never fetches on its own', async () => {
const other = join(root, 'other');
git(root, 'clone', '-q', bare, other);
write('theirs.txt', 'x\n', other);
commit('theirs', other);
git(other, 'push', '-q', 'origin', 'main');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(0); // not fetched yet
clearGitStatusCache();
git(repo, 'fetch', '-q');
expect((await getGitWorkspaceStatus(repo)).behind).toBe(1);
});
it('a branch with no upstream lists what no remote has', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: null, hasRemote: true, unpushedCount: 2 });
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a branch whose upstream was deleted and pruned is NOT reported as everything pushed', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
git(repo, 'push', '-q', 'origin', '--delete', 'feature');
git(repo, 'fetch', '-q', '--prune');
write('f2.txt');
commit('f2');
const s = await getGitWorkspaceStatus(repo);
expect(s).toMatchObject({ branch: 'feature', upstream: 'origin/feature', upstreamGone: true });
expect(s.unpushedCount).toBe(2);
expect(s.unpushed.map((c) => c.subject)).toEqual(['f2', 'f1']);
});
it('a pushed branch is not reported as unpushed once it has an upstream', async () => {
git(repo, 'checkout', '-q', '-b', 'feature');
write('f1.txt');
commit('f1');
git(repo, 'push', '-q', '-u', 'origin', 'feature');
expect((await getGitWorkspaceStatus(repo)).unpushedCount).toBe(0);
});
});
describe('it only reads', () => {
it('does not rewrite the index or leave a lock, even when stat data is stale', async () => {
write('a.txt', '1\n');
commit('base');
const before = readFileSync(join(repo, '.git', 'index'));
// Touching a tracked file makes a plain `git status` want to refresh the index.
const now = new Date();
const { utimesSync } = await import('node:fs');
utimesSync(join(repo, 'a.txt'), now, new Date(now.getTime() + 5000));
await getGitWorkspaceStatus(repo);
expect(readFileSync(join(repo, '.git', 'index')).equals(before)).toBe(true);
expect(existsSync(join(repo, '.git', 'index.lock'))).toBe(false);
});
it('does not run a repository-configured fsmonitor hook (and a plain git status would have)', async () => {
write('a.txt', '1\n');
commit('base');
const hook = join(root, 'fsmonitor.sh');
const marker = join(root, 'fsmonitor-ran');
writeFileSync(hook, `#!/bin/sh\necho ran >> '${marker}'\nprintf ''\n`);
chmodSync(hook, 0o755);
git(repo, 'config', 'core.fsmonitor', hook);
// Control: git itself runs it for a plain status, so the assertion below is not vacuous.
git(repo, 'status', '--short');
expect(existsSync(marker)).toBe(true);
rmSync(marker);
await getGitWorkspaceStatus(repo);
expect(existsSync(marker)).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// Cache and error mapping (fake runner)
// ---------------------------------------------------------------------------
describe('caching and failures', () => {
const okRunner = (calls: string[][] = []): GitRunner =>
vi.fn(async (_cwd, args) => {
calls.push(args);
if (args[0] === 'status') return ['# branch.oid x', '# branch.head main'].join(NUL) + NUL;
return '';
});
it('never runs a git command that writes or touches the network', async () => {
const calls: string[][] = [];
await getGitWorkspaceStatus('/w/never-network', { git: okRunner(calls) });
const verbs = new Set(calls.map((a) => a[0]));
for (const forbidden of ['fetch', 'pull', 'push', 'commit', 'add', 'checkout', 'reset', 'clean', 'gc']) {
expect(verbs.has(forbidden), forbidden).toBe(false);
}
expect([...verbs].sort()).toEqual(['log', 'remote', 'rev-list', 'rev-parse', 'stash', 'status']);
});
it('shares one in-flight computation between concurrent callers', async () => {
const calls: string[][] = [];
const git = okRunner(calls);
const [a, b, c] = await Promise.all([1, 2, 3].map(() => getGitWorkspaceStatus('/w/shared', { git })));
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
expect(a).toBe(b);
expect(b).toBe(c);
});
it('reuses a fresh result, recomputes after the TTL, and keeps folders apart', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (cwd: string) => getGitWorkspaceStatus(cwd, { git, now: () => t });
await get('/w/a');
t += 1000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get('/w/b');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
t += 10_000;
await get('/w/a');
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(3);
});
it('`fresh` skips the reuse of a recent result, but still joins a computation already running', async () => {
let t = 1_000_000;
const calls: string[][] = [];
const git = okRunner(calls);
const get = (fresh: boolean) => getGitWorkspaceStatus('/w/fresh', { git, now: () => t, fresh });
await get(false);
t += 500;
await get(false); // reused
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
await get(true); // a person pressed Refresh
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(2);
clearGitStatusCache();
calls.length = 0;
await Promise.all([get(true), get(true), get(true)]);
expect(calls.filter((x) => x[0] === 'status')).toHaveLength(1);
});
it('maps "not a git repository" to not-a-repo', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: 'fatal: not a git repository (or any of the parent directories): .git',
});
};
expect((await getGitWorkspaceStatus('/w/none', { git })).state).toBe('not-a-repo');
});
it('reports a missing git binary and a timeout as short errors', async () => {
const enoent: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceStatus('/w/e1', { git: enoent })).toMatchObject({
state: 'error',
error: expect.stringMatching(/not installed/),
});
const slow: GitRunner = async () => {
throw Object.assign(new Error('timed out'), { killed: true });
};
expect(await getGitWorkspaceStatus('/w/e2', { git: slow })).toMatchObject({
state: 'error',
error: 'git timed out',
});
});
it('redacts credentials embedded in a remote URL from an error message', async () => {
const git: GitRunner = async () => {
throw Object.assign(new Error('x'), {
stderr: "fatal: unable to access 'https://user:ghp_SECRET@github.com/o/r.git/'",
});
};
const s = await getGitWorkspaceStatus('/w/redact', { git });
expect(s.state).toBe('error');
expect(s.error).not.toContain('ghp_SECRET');
expect(s.error).toContain('***:***@');
});
it('survives the secondary calls failing: status still comes back', async () => {
const git: GitRunner = async (_cwd, args) => {
if (args[0] === 'status')
return (
['# branch.oid x', '# branch.head main', '# branch.upstream o/main', '# branch.ab +3 -0'].join(NUL) + NUL
);
throw new Error('boom');
};
const s = await getGitWorkspaceStatus('/w/partial', { git });
expect(s).toMatchObject({ state: 'ok', branch: 'main', ahead: 3, unpushedCount: 0, unpushed: [] });
});
});
// ---------------------------------------------------------------------------
// Which repositories: getGitWorkspaceOverview
// ---------------------------------------------------------------------------
import { mkdirSync as mkdir, symlinkSync as symlink } from 'node:fs';
import {
discoverChildRepos,
getGitWorkspaceOverview,
isUnrelatedAncestor,
MAX_REPOS,
} from '../src/git-workspace-status.js';
describe('getGitWorkspaceOverview', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
const names = (o: { repos: { path: string }[] }) => o.repos.map((r) => r.path);
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-overview-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('a folder that holds several repositories reports each, alphabetically, with its own status', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'web'));
repoAt(join(ws, 'api'));
writeFileSync(join(ws, 'api', 'dirty.txt'), 'x');
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['api', 'web']);
expect(o.repos.map((r) => r.name)).toEqual(['api', 'web']);
expect(o.repos[0].status.counts.uncommitted).toBe(1);
expect(o.repos[1].status.counts.uncommitted).toBe(0);
expect(o.reposTruncated).toBe(false);
});
it('finds repositories two levels down but not three', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'apps', 'web'));
repoAt(join(ws, 'a', 'b', 'too-deep'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual([join('apps', 'web')]);
});
it('a subfolder of a repository reports the whole enclosing repository, naming where it is', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'src', 'deep'), { recursive: true });
writeFileSync(join(r, 'top.txt'), 'x');
const o = await getGitWorkspaceOverview(join(r, 'src', 'deep'), { home });
expect(o.repos).toHaveLength(1);
expect(o.repos[0]).toMatchObject({ name: 'proj', path: join('..', '..') });
expect(o.repos[0].status.files.map((f) => f.path)).toContain('top.txt');
});
it('inside a repository it does not go looking for nested ones (they are just an untracked folder to the outer repo)', async () => {
const r = repoAt(join(home, 'outer'));
repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(r, { home });
expect(names(o)).toEqual(['.']);
expect(o.repos[0].status.files.map((f) => f.path)).toEqual(['vendor-ish/']);
});
it('a session started inside the nested repository reports that one', async () => {
const r = repoAt(join(home, 'outer'));
const inner = repoAt(join(r, 'vendor-ish', 'inner'));
const o = await getGitWorkspaceOverview(inner, { home });
expect(o.repos[0].name).toBe('inner');
});
it('ignores a repository that merely sits above the workspace and is the home folder (a dotfiles repo)', async () => {
repoAt(home);
writeFileSync(join(home, 'zshrc'), 'x'); // dirty, and nothing to do with this session
const ws = join(home, 'codeman-cases', 'my-case');
mkdir(ws, { recursive: true });
const none = await getGitWorkspaceOverview(ws, { home });
expect(none.state).toBe('not-a-repo');
// ...but a project below the workspace is still found.
repoAt(join(ws, 'real-project'));
clearGitStatusCache();
const some = await getGitWorkspaceOverview(ws, { home, fresh: true });
expect(names(some)).toEqual(['real-project']);
});
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
repoAt(home);
const ws = join(home, 'case');
repoAt(join(ws, 'proj'));
const calls: Array<[string, string]> = [];
const spy: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
return runGit(cwd, args);
};
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['proj']);
// The dotfiles repository costs one rev-parse, never a full status.
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
});
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
repoAt(top); // contains `home`, so it is above it
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
});
it('but a workspace that IS the repository root is the session’s repository, even when that is the home folder', async () => {
repoAt(home);
const o = await getGitWorkspaceOverview(home, { home });
expect(o.state).toBe('ok');
expect(o.repos[0].path).toBe('.');
});
it('accepts an enclosing repository that is below the home folder, and one outside the home folder entirely', async () => {
const r = repoAt(join(home, 'proj'));
mkdir(join(r, 'sub'), { recursive: true });
expect((await getGitWorkspaceOverview(join(r, 'sub'), { home })).state).toBe('ok');
const elsewhere = repoAt(join(top, 'elsewhere', 'proj'));
clearGitStatusCache();
expect((await getGitWorkspaceOverview(elsewhere, { home })).state).toBe('ok');
});
it('skips node_modules, dot-folders and symbolic links when looking for repositories', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'node_modules', 'pkg'));
repoAt(join(ws, '.hidden', 'secret'));
const outside = repoAt(join(top, 'outside'));
mkdir(ws, { recursive: true });
symlink(outside, join(ws, 'linked'));
repoAt(join(ws, 'real'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['real']);
});
it('counts a worktree (its .git is a file) as a repository', async () => {
const main = repoAt(join(top, 'main-repo'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
git(main, 'worktree', 'add', '-q', '-b', 'feature', join(ws, 'wt'));
const o = await getGitWorkspaceOverview(ws, { home });
expect(names(o)).toEqual(['wt']);
expect(o.repos[0].status.branch).toBe('feature');
});
it('caps how many repositories it reports and says so', async () => {
const ws = join(home, 'case');
for (let i = 0; i < MAX_REPOS + 3; i++) repoAt(join(ws, `p${String(i).padStart(2, '0')}`));
const o = await getGitWorkspaceOverview(ws, { home });
expect(o.repos).toHaveLength(MAX_REPOS);
expect(o.reposTruncated).toBe(true);
expect(names(o)[0]).toBe('p00');
});
it('is not-a-repo when there is no repository here or below, and reports a git failure as an error', async () => {
const ws = join(home, 'empty');
mkdir(join(ws, 'a'), { recursive: true });
expect((await getGitWorkspaceOverview(ws, { home })).state).toBe('not-a-repo');
clearGitStatusCache(); // the answer above is cached for this folder
const broken: GitRunner = async () => {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
};
expect(await getGitWorkspaceOverview(ws, { home, git: broken })).toMatchObject({ state: 'error', repos: [] });
});
it('re-scans for repositories only every so often, unless a person asked for fresh', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
repoAt(join(ws, 'one'));
const get = (fresh = false) => getGitWorkspaceOverview(ws, { home, now: () => t, fresh });
expect(names(await get())).toEqual(['one']);
repoAt(join(ws, 'two'));
t += 5000;
expect(names(await get())).toEqual(['one']); // list reused
expect(names(await get(true))).toEqual(['one', 'two']); // Refresh sees it
t += 60_000;
expect(names(await get())).toEqual(['one', 'two']); // and so does the next scan
});
it('discoverChildRepos never reads below a repository it found', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'outer'));
repoAt(join(ws, 'outer', 'inner'));
expect((await discoverChildRepos(ws)).dirs.map((d) => d.slice(ws.length + 1))).toEqual(['outer']);
});
it('isUnrelatedAncestor: home or above, but never the workspace root itself', async () => {
mkdir(join(home, 'proj'), { recursive: true });
expect(await isUnrelatedAncestor(home, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(top, join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor('/', join(home, 'proj'), home)).toBe(true);
expect(await isUnrelatedAncestor(join(home, 'proj'), join(home, 'proj', 'x'), home)).toBe(false);
expect(await isUnrelatedAncestor(home, home, home)).toBe(false);
});
});
import { MAX_DIFF_BYTES, getGitFileDiff, isSafeRepoRelativePath } from '../src/git-workspace-status.js';
describe('isSafeRepoRelativePath', () => {
it.each([
['a.txt', true],
['src/deep/x.ts', true],
['', false],
['-rf', true], // every operand follows `--`, so a leading dash is just a name
['-', true],
['/etc/passwd', false],
['../x', false],
['a/../../x', false],
['a\0b', false],
])('%j -> %s', (p, ok) => expect(isSafeRepoRelativePath(p)).toBe(ok));
});
describe('getGitFileDiff', () => {
it('refuses an unsafe path without running git', async () => {
const git = vi.fn(async () => '');
await expect(getGitFileDiff('/r', { path: '../x', kind: 'unstaged' }, { git })).rejects.toThrow('Invalid path');
expect(git).not.toHaveBeenCalled();
});
it('builds read-only, option-injection-safe commands per kind', async () => {
const git = vi.fn(async (_cwd: string, _args: string[]) => '');
await getGitFileDiff('/r', { path: 'a.txt', kind: 'unstaged' }, { git });
await getGitFileDiff('/r', { path: 'b.txt', origPath: 'old.txt', kind: 'staged' }, { git });
const [unstaged, staged] = git.mock.calls.map((c) => c[1]);
for (const args of [unstaged, staged]) {
expect(args).toContain('--no-ext-diff');
expect(args).toContain('--no-textconv');
expect(args.indexOf('--')).toBeGreaterThan(0);
}
expect(unstaged.slice(-2)).toEqual(['--', 'a.txt']);
expect(staged).toContain('--cached');
expect(staged.slice(-3)).toEqual(['--', 'old.txt', 'b.txt']);
});
it('treats --no-index exit 1 as the normal untracked result', async () => {
const git = vi.fn(async () => {
throw Object.assign(new Error('exit 1'), { code: 1, stdout: '+hello\n' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git })).resolves.toMatchObject({
diff: '+hello\n',
});
const boom = vi.fn(async () => {
throw Object.assign(new Error('exit 128'), { code: 128, stdout: '' });
});
await expect(getGitFileDiff('/r', { path: 'n.txt', kind: 'untracked' }, { git: boom })).rejects.toThrow();
});
it('flags binary output and cuts an oversized diff at a line boundary', async () => {
const bin = await getGitFileDiff(
'/r',
{ path: 'x.png', kind: 'unstaged' },
{ git: async () => 'Binary files a/x.png and b/x.png differ\n' }
);
expect(bin.binary).toBe(true);
const big = ('+' + 'x'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) + 50);
const cut = await getGitFileDiff('/r', { path: 'big', kind: 'unstaged' }, { git: async () => big });
expect(cut.truncated).toBe(true);
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true);
});
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
const git = vi.fn(async () => {
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: partial,
});
});
for (const kind of ['unstaged', 'untracked'] as const) {
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
expect(d.truncated).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(d.diff.endsWith('y')).toBe(true);
}
});
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
expect(d.truncated).toBe(true);
expect(d.diff.startsWith('diff --git')).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
}, 30_000);
});
describe('Docker case workspaces are never inspected', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
/** A repository whose clean filter drops a marker file: proof that git ran on it. */
const booby = (p: string): string => {
repoAt(p);
git(p, 'config', 'filter.mark.clean', 'touch RAN; cat');
writeFileSync(join(p, '.gitattributes'), 'f.txt filter=mark\n');
// Same size as the committed '1\n', so git must read the content (running the filter) to see the change.
writeFileSync(join(p, 'f.txt'), 'x\n');
return p;
};
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-docker-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('control: without the exclusion git does run the repository’s clean filter', async () => {
const ws = join(home, 'case');
booby(join(ws, 'proj'));
await getGitWorkspaceOverview(ws, { home, git: undefined });
expect(existsSync(join(ws, 'proj', 'RAN'))).toBe(true);
});
it('drops a Docker workspace found below the folder, and runs nothing in it', async () => {
const ws = join(home, 'case');
booby(join(ws, 'sandbox'));
repoAt(join(ws, 'plain'));
const o = await getGitWorkspaceOverview(ws, { home, dockerWorkspaces: [join(ws, 'sandbox')] });
expect(o.repos.map((r) => r.path)).toEqual(['plain']);
expect(existsSync(join(ws, 'sandbox', 'RAN'))).toBe(false);
});
it('answers unsupported/docker for a folder at or inside a Docker workspace, before any git runs', async () => {
const dock = booby(join(home, 'dock'));
mkdir(join(dock, 'sub'));
for (const cwd of [dock, join(dock, 'sub')]) {
clearGitStatusCache();
const git = vi.fn(async () => '');
const o = await getGitWorkspaceOverview(cwd, { home, git, dockerWorkspaces: [dock] });
expect(o).toMatchObject({ state: 'unsupported', reason: 'docker', repos: [] });
expect(git).not.toHaveBeenCalled();
}
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
repoAt(join(ws, 'b'));
const cwds: string[] = [];
const spy: GitRunner = (cwd, args) => {
cwds.push(cwd);
return runGit(cwd, args);
};
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
t += 5000; // past the status cache, well inside the list's 30 s
cwds.length = 0;
const o = await getGitWorkspaceOverview(ws, {
home,
git: spy,
now: () => t,
dockerWorkspaces: [join(ws, 'a')],
});
expect(o.repos.map((r) => r.path)).toEqual(['b']);
expect(cwds).not.toContain(join(ws, 'a'));
});
it('sees through a symlink to the workspace', async () => {
const dock = booby(join(home, 'dock'));
const ws = join(home, 'case');
mkdir(ws, { recursive: true });
symlink(dock, join(ws, 'link'));
const o = await getGitWorkspaceOverview(join(ws, 'link'), { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('unsupported');
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('a folder next to the workspace, and one whose name merely starts the same, are not excluded', async () => {
const dock = join(home, 'dock');
const sibling = repoAt(join(home, 'dock-two'));
mkdir(dock, { recursive: true });
const o = await getGitWorkspaceOverview(sibling, { home, dockerWorkspaces: [dock] });
expect(o.state).toBe('ok');
});
});
+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');
}
});
});
+12 -2
View File
@@ -31,14 +31,16 @@ 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', () => {
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
it('documents implemented global and tab shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
// Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
@@ -51,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');
+106
View File
@@ -91,6 +91,112 @@ 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;
api.configure({ language: 'zh-CN' });
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
api.t(label)
);
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
expect(new Set(labels).size).toBe(4);
dom.window.close();
});
it('renders hostile-looking names as text rather than HTML', () => {
const dom = makeDom('<span class="logo">Codeman</span>');
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>/);
});
});
+30 -1
View File
@@ -143,6 +143,7 @@ function loadImageInputApp() {
app.activeSessionId = 'session-1';
app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {});
app._sendInputAsync = vi.fn();
app._normalizeImageForUpload = vi.fn(async (file) => file);
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
return app as Record<string, any>;
@@ -199,6 +200,7 @@ describe('image upload insertion policy', () => {
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
expect(app.sendInput).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('preserves terminal insertion by default', async () => {
@@ -207,6 +209,33 @@ describe('image upload insertion policy', () => {
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
// The same delivery sendInput() uses (durable queue, useMux for the POST
// fallback), but addressed to the session the batch was uploaded to.
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
});
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
const app = loadImageInputApp();
// The user switches tabs while the upload is in flight. sendInput() re-read
// activeSessionId after the awaits, so the paths used to land in session-2.
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
app.activeSessionId = 'session-2';
return file.path;
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
expect(app.sendInput).not.toHaveBeenCalled();
});
it('uploads to and inserts into an explicitly named session', async () => {
const app = loadImageInputApp();
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
});
});
+471 -7
View File
@@ -13,7 +13,8 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
* (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3164;
const ORDERING_PORT = 3165;
const LONG_PREFIX_PORT = 3192;
const BASE_URL = `http://localhost:${PORT}`;
describe('Inline rename input', () => {
@@ -581,11 +584,20 @@ describe('Inline rename input', () => {
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
// The rail has two row variants, and the detailed one clamps the name to
// three lines instead of two: the editor must come out unclamped in both,
// and cancelling must put back the clamp of the variant it was opened in.
it.each([
{ detail: 'simple', restoredClamp: '2' },
{ detail: 'rich', restoredClamp: '3' },
])(
'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel',
async ({ detail, restoredClamp }) => {
await resetState();
const id = 'vertical-live-input';
const id = `vertical-live-input-${detail}`;
await page.evaluate((sessionId) => {
await page.evaluate(
({ sessionId, detail }) => {
const app = (
window as unknown as {
app: {
@@ -595,6 +607,7 @@ describe('Inline rename input', () => {
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
document.documentElement.dataset.tabRailDetail = detail;
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
@@ -605,7 +618,9 @@ describe('Inline rename input', () => {
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
},
{ sessionId: id, detail }
);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
@@ -622,13 +637,462 @@ describe('Inline rename input', () => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
const result = {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
document.documentElement.dataset.tabOrientation = 'horizontal';
return result;
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp });
}
);
});
/**
* Two renames of one session can be in flight at once: commit, reopen the
* editor before the PUT answers, then commit or cancel again (or start a group
* rename, which cancels the session editor). The writes go out one at a time
* in the order they were made, and a confirmed write is applied locally even
* if the editor that made it has since been cancelled, so the tab never shows
* a name the server no longer holds.
*/
describe('Inline rename write ordering', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
server = new WebServer(ORDERING_PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */
async function mount(id: string, name: string): Promise<void> {
await page.evaluate(
({ id, name }) => {
const w = window as unknown as {
app: {
_activeRename: { cancel: () => void } | null;
sessions: Map<string, { id: string; name: string; status: string }>;
sessionOrder: string[];
};
__pending: Array<{ body: string; resolve: (response: Response) => void }>;
__origFetch?: typeof window.fetch;
};
w.app._activeRename?.cancel();
w.app.sessions.clear();
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
w.app.sessions.set(id, { id, name, status: 'idle' });
w.app.sessionOrder = [id];
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.dataset.id = id;
tab.innerHTML = `<span class="tab-info"><span class="tab-name" data-session-id="${id}">${name}</span></span>`;
(document.getElementById('sessionTabs') as HTMLElement).appendChild(tab);
w.__pending = [];
w.__origFetch ??= window.fetch;
const passThrough = w.__origFetch;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init);
return new Promise<Response>((resolve) => {
w.__pending.push({ body: String(init?.body ?? ''), resolve });
});
}) as typeof window.fetch;
},
{ id, name }
);
}
async function restoreFetch(): Promise<void> {
await page.evaluate(() => {
const w = window as unknown as { __origFetch?: typeof window.fetch };
if (w.__origFetch) window.fetch = w.__origFetch;
});
}
async function commit(id: string, value: string | null): Promise<void> {
await page.evaluate(
async ({ id, value }) => {
const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app;
if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) {
app.startInlineRename(id);
}
const input = document.querySelector(
`.tab-name[data-session-id="${id}"] input.tab-rename-input`
) as HTMLInputElement;
if (value !== null) input.value = value;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
},
{ id, value }
);
}
async function answer(index: number, name: string): Promise<void> {
await page.evaluate(
async ({ index, body }) => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[index]?.resolve(
new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
);
await new Promise((resolve) => setTimeout(resolve, 30));
},
{ index, body: JSON.stringify({ success: true, data: { name } }) }
);
}
async function state(id: string) {
return page.evaluate((id) => {
const w = window as unknown as {
app: { sessions: Map<string, { name: string }>; _activeRename: unknown };
__pending: Pending[];
};
return {
bodies: w.__pending.map(({ body }) => JSON.parse(body).name),
mapName: w.app.sessions.get(id)?.name ?? null,
renameActive: !!w.app._activeRename,
};
}, id);
}
it('sends successive renames of one session one at a time, in the order they were made', async () => {
await mount('order', 'Old');
await commit('order', 'First');
await commit('order', 'Second');
expect((await state('order')).bodies).toEqual(['First']);
await answer(0, 'First');
expect((await state('order')).bodies).toEqual(['First', 'Second']);
await answer(1, 'Second');
await restoreFetch();
expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
});
it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => {
await mount('reopen', 'Old');
await commit('reopen', 'First');
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('reopen');
app._activeRename.cancel();
});
await answer(0, 'First');
await restoreFetch();
expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reopens the editor on the name still in flight, so confirming it unchanged keeps the rename', async () => {
await mount('stale', 'Old');
await commit('stale', 'First');
// The PUT for "First" has not answered, so app.sessions still says "Old".
// The reopened editor must show "First", the user's last word, and an
// untouched confirm must not queue "Old" behind it.
const reopenedValue = await page.evaluate(() => {
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('stale');
return (document.querySelector('.tab-name[data-session-id="stale"] input.tab-rename-input') as HTMLInputElement)
.value;
});
expect(reopenedValue).toBe('First');
await commit('stale', null);
await answer(0, 'First');
await restoreFetch();
expect(await state('stale')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
expect(
await page.evaluate(
() =>
(window as unknown as { app: { _inlineRenamePending?: Map<string, string> } }).app._inlineRenamePending?.has(
'stale'
) ?? false
)
).toBe(false);
});
it('shows the in-flight name when a reopened editor is confirmed unchanged, before the PUT lands', async () => {
await mount('shown', 'Old');
await commit('shown', 'First');
// Reopen while the PUT for "First" is held, then confirm it untouched. The
// label must read "First" now, not the "Old" the cancelled editor
// repainted from app.sessions.
await page.evaluate(() =>
(window as unknown as { app: { startInlineRename: (id: string) => void } }).app.startInlineRename('shown')
);
await commit('shown', null);
const label = await page.evaluate(
() => (document.querySelector('.tab-name[data-session-id="shown"]') as HTMLElement).textContent
);
expect((await state('shown')).bodies).toEqual(['First']);
expect(label).toBe('First');
await answer(0, 'First');
await restoreFetch();
expect(await state('shown')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
});
it('reports a failed write even after its editor is gone', async () => {
await mount('fail-late', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: (message: string, type?: string) => void };
__toasts: string[];
__origToast?: (message: string, type?: string) => void;
};
w.__toasts = [];
w.__origToast = w.app.showToast;
w.app.showToast = (message: string) => {
w.__toasts.push(message);
};
});
await commit('fail-late', 'First');
// Reopen and dismiss: the editor that made the write is gone.
await page.evaluate(() => {
const app = (
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
).app;
app.startInlineRename('fail-late');
app._activeRename.cancel();
});
await page.evaluate(async () => {
const w = window as unknown as { __pending: Pending[] };
w.__pending[0]?.resolve(
new Response(JSON.stringify({ success: false, error: 'boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
);
await new Promise((resolve) => setTimeout(resolve, 30));
});
await restoreFetch();
const toasts = await page.evaluate(() => {
const w = window as unknown as {
app: { showToast: unknown };
__toasts: string[];
__origToast?: unknown;
};
w.app.showToast = w.__origToast;
return w.__toasts;
});
expect(toasts).toEqual(['Failed to rename']);
expect(await state('fail-late')).toEqual({ bodies: ['First'], mapName: 'Old', renameActive: false });
});
it('keeps sending a session renames after the work following a PUT throws', async () => {
await mount('throws', 'Old');
await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: (id: string) => void };
__origParentNames?: (id: string) => void;
__throwOnce: boolean;
};
w.__origParentNames = w.app.updateSubagentParentNames;
w.__throwOnce = true;
w.app.updateSubagentParentNames = (id: string) => {
if (w.__throwOnce) {
w.__throwOnce = false;
throw new Error('forced');
}
w.__origParentNames?.call(w.app, id);
};
window.addEventListener('unhandledrejection', (event) => event.preventDefault(), { once: true });
});
await commit('throws', 'First');
await answer(0, 'First');
await commit('throws', 'Second');
await answer(1, 'Second');
await restoreFetch();
const leftover = await page.evaluate(() => {
const w = window as unknown as {
app: { updateSubagentParentNames?: unknown; _inlineRenameWrites?: Map<string, unknown> };
__origParentNames?: unknown;
};
w.app.updateSubagentParentNames = w.__origParentNames;
return w.app._inlineRenameWrites?.has('throws') ?? false;
});
expect(await state('throws')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
expect(leftover).toBe(false);
});
it('lets the header strip editor shrink (inline min-width 0)', async () => {
await mount('header-width', 'Old');
const minWidth = await page.evaluate(() => {
const app = (
window as unknown as {
app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } | null };
}
).app;
app.startInlineRename('header-width');
const input = document.querySelector(
'.tab-name[data-session-id="header-width"] input.tab-rename-input'
) as HTMLInputElement;
const value = input.style.minWidth;
app._activeRename?.cancel();
return value;
});
await restoreFetch();
expect(minWidth).toBe('0px');
});
it('keeps a confirmed session rename when a group rename takes over the editor', async () => {
await mount('to-group', 'Old');
await commit('to-group', 'Saved');
const groupStarted = await page.evaluate(() => {
const app = (
window as unknown as {
app: {
tabLayout: unknown;
startTabGroupRename: (groupId: string) => boolean;
};
}
).app;
const section = document.createElement('section');
section.setAttribute('data-test-tab', '1');
section.innerHTML =
'<div class="tab-layout-group-header" data-tab-group-header="g1">' +
'<span class="tab-layout-group-name">Group</span></div>';
(document.getElementById('sessionTabs') as HTMLElement).appendChild(section);
(window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout;
app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] };
return app.startTabGroupRename('g1');
});
expect(groupStarted).toBe(true);
await answer(0, 'Saved');
const after = await page.evaluate(() => {
const w = window as unknown as {
app: {
sessions: Map<string, { name: string }>;
_inlineRenameActive: boolean;
_activeRename: { cancel: () => void } | null;
tabLayout: unknown;
};
__origLayout: unknown;
};
const groupInput = document.querySelector('.tab-layout-group-rename-input');
const result = {
mapName: w.app.sessions.get('to-group')?.name ?? null,
groupEditorOpen: !!groupInput?.isConnected,
guardHeld: w.app._inlineRenameActive,
};
w.app._activeRename?.cancel();
w.app.tabLayout = w.__origLayout;
return result;
});
await restoreFetch();
expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true });
});
});
/**
* Real rows, rendered by the app from a live session: a long `w<n>-<case>`
* prefix must not push the editor (or the prefix itself) out of the row in any
* rail variant. The prefix gives way first, with an ellipsis, and the input
* always keeps a usable width.
*/
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
server = new WebServer(port, false, true);
await server.start();
const res = await fetch(`http://localhost:${port}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
});
expect(res.ok).toBe(true);
const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } };
sessionId = created.data?.session?.id ?? created.data?.id ?? '';
expect(sessionId).not.toBe('');
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it.each([
{ variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false },
{ variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false },
{ variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true },
{ variant: 'sidebar', settings: { sessionListLayout: 'sidebar' }, compact: false },
{ variant: 'detailed sidebar', settings: { sessionListLayout: 'sidebar-rich' }, compact: false },
])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
try {
await context.addInitScript(
(value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)),
settings
);
const page = await context.newPage();
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });
expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact);
await row.click({ button: 'right' });
const input = row.locator('input.tab-rename-input');
await input.press('Control+A');
await page.keyboard.type('typed live text');
expect(await input.inputValue()).toBe('typed live text');
const geometry = await row.evaluate((tab) => {
const box = (el: Element) => el.getBoundingClientRect();
const within = (inner: DOMRect, outer: DOMRect) =>
inner.left >= outer.left - 0.5 &&
inner.right <= outer.right + 0.5 &&
inner.top >= outer.top - 0.5 &&
inner.bottom <= outer.bottom + 0.5;
const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement;
const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement;
const info = tab.querySelector('.tab-info') as HTMLElement;
return {
focused: document.activeElement === input,
inputWidth: box(input).width,
prefixWidth: box(prefix).width,
inputInsideRow: within(box(input), box(info)),
prefixInsideRow: within(box(prefix), box(info)),
prefixEllipsis: getComputedStyle(prefix).textOverflow,
inlineMinWidth: input.style.minWidth,
};
});
expect(geometry.focused).toBe(true);
expect(geometry.inputWidth).toBeGreaterThanOrEqual(64);
expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24);
expect(geometry.inputInsideRow).toBe(true);
expect(geometry.prefixInsideRow).toBe(true);
expect(geometry.prefixEllipsis).toBe('ellipsis');
// The floor is the editor's own inline style, not a stylesheet override.
expect(geometry.inlineMinWidth).toBe('4rem');
await input.press('Escape');
expect(await row.locator('input.tab-rename-input').count()).toBe(0);
} finally {
await context.close();
}
});
});
+1 -1
View File
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
});
it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
});
+281
View File
@@ -0,0 +1,281 @@
/**
* @fileoverview The durable input layer delivers over ANY registered terminal
* socket, not only the primary one (`_inputSocketFor`, app.js).
*
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
* bound to another session (the split pane's Pane B, later a grid tile) had no
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
* and no idle-alert acknowledgement. These tests pin the seam: a registered
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
* registration can never evict the socket that replaced it, and the sweep
* judges a socket's silence by that socket's own last frame.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
document: { addEventListener: vi.fn() },
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
type Frame = { t: string; d: string; seq?: number; cid?: string };
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
type Handle = { ws: FakeSocket; lastRecvAt: number };
type Rec = { seq: number; data: string; sentAt: number; tries: number };
type App = {
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
_sendInputEphemeral: (sessionId: string, input: string) => void;
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
_registerInputSocket: (sessionId: string, handle: Handle) => void;
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
_onWsReady: (sessionId: string) => void;
_redeliverSweep: () => void;
_pendingDeliveries: Map<string, Rec[]>;
_seqCounters: Map<string, number>;
_ws: FakeSocket | null;
_wsSessionId: string | null;
_wsLastRecvAt: number;
_reliableAckTimeoutMs: number;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
function makeApp(): App {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
app._clientId = 'c-test';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.activeSessionId = 'primary';
app._ws = null;
app._wsSessionId = null;
app._wsLastRecvAt = 0;
app._reliableAckTimeoutMs = 4000;
return app as unknown as App;
}
function fakeSocket(frames: Frame[]): FakeSocket {
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
}
describe('a registered second socket joins the exactly-once queue', () => {
it('delivers seq-tagged frames over the registered socket for its session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
const tileFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputAsync('other', 'x');
app._sendInputAsync('other', 'y');
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
['x', 1, 'c-test'],
['y', 2, 'c-test'],
]);
// Nothing for the other session leaks onto the primary socket.
expect(primaryFrames).toEqual([]);
});
it('still prefers the primary socket for the primary session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'a');
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
});
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
const app = makeApp();
const posts: Array<{ input: string; seq: number }> = [];
global.fetch = vi.fn(async (_url, init) => {
posts.push(JSON.parse(String(init?.body)));
return new Response('{}', { status: 200 });
});
const closing = { ...fakeSocket([]), readyState: 3 };
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
expect(app._inputSocketFor('other')).toBeNull();
app._sendInputAsync('other', 'z');
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
});
it('routes ephemeral input over the registered socket too', () => {
const app = makeApp();
const tileFrames: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
});
});
describe('ACK routing', () => {
it('drops the record from the queue of the session the ACK arrived for', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
app._sendInputAsync('primary', 'p');
app._sendInputAsync('other', 'o');
// Both sessions issued seq 1. The ACK names no session, so the socket's own
// session decides which queue it drains; the primary's must be untouched.
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
expect(app._pendingDeliveries.get('other')).toBeUndefined();
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
});
it('defaults to the primary socket session when no session is passed', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'p');
app._onWsInputAck(1, { t: 'ia', seq: 1 });
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
});
it('re-sends everything pending over a freshly registered socket on ready', () => {
const app = makeApp();
const first: Frame[] = [];
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
app._registerInputSocket('other', firstHandle);
app._sendInputAsync('other', 'a');
expect(first.map((f) => f.d)).toEqual(['a']);
// The socket dies without an ACK; a replacement opens and asks for a flush.
firstHandle.ws.readyState = 3;
const second: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
app._onWsReady('other');
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
});
});
describe('registration ownership', () => {
it('a stale handle cannot unregister the socket that replaced it', () => {
const app = makeApp();
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
const newFrames: Frame[] = [];
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
app._registerInputSocket('other', oldHandle);
app._registerInputSocket('other', newHandle);
// The old socket's close lands late and tries to clean up after itself.
app._unregisterInputSocket('other', oldHandle);
app._sendInputAsync('other', 'still-here');
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
});
it('the owning handle does unregister', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._unregisterInputSocket('other', handle);
expect(app._inputSocketFor('other')).toBeNull();
});
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
const before = Date.now();
app._registerInputSocket('other', handle);
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
});
});
describe('the redelivery sweep judges each socket by its own last frame', () => {
function staleRecord(app: App, sessionId: string) {
const rec = app._pendingDeliveries.get(sessionId)![0];
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
}
it('force-closes a silent registered socket with a stale record', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
// The primary socket is chatty; that must not vouch for the other one.
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._wsLastRecvAt = Date.now();
app._redeliverSweep();
expect(handle.ws.close).toHaveBeenCalledTimes(1);
expect(app._ws.close).not.toHaveBeenCalled();
});
it('re-drives (does not close) a registered socket that is still receiving', () => {
const app = makeApp();
const frames: Frame[] = [];
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now();
// A SILENT primary must not condemn the live registered socket either.
app._wsLastRecvAt = 0;
app._redeliverSweep();
expect(handle.ws.close).not.toHaveBeenCalled();
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
});
});
+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 = [];
}
+1 -1
View File
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
describe('the client lifts itself over the watermark', () => {
const handler = appSource.slice(
appSource.indexOf('_onWsInputAck(seq, msg)'),
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
appSource.indexOf('/** Called from ws.onopen')
);
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
});
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
// A link from the custom parent into the cases dir is judged on its resolved form too.
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
try {
for (const path of [
join(CASES_DIR(), 'bar'),
join(CASES_DIR(), 'existing-empty'),
join(work(), 'into-cases', 'via-link'),
]) {
const res = await create(app, { name: 'bar', path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/plain Create New/);
}
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});
+137 -1
View File
@@ -13,13 +13,24 @@
* behavior matches production exactly).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
// A short path-probe timeout keeps the unreachable-mount tests quick. Read when the
// probe's config module is first imported, so it is set before any import runs.
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '300';
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
// Mock filesystem modules
vi.mock('node:fs', async (importOriginal) => {
@@ -35,6 +46,7 @@ vi.mock('node:fs', async (importOriginal) => {
vi.mock('node:fs/promises', () => ({
default: {
stat: vi.fn(),
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
@@ -74,6 +86,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
const mockedReaddir = vi.mocked(fs.readdir);
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedStat = vi.mocked(fs.stat);
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
interface CaseRouteHarness {
@@ -127,6 +140,12 @@ describe('case-routes', () => {
// Default: existsSync returns false, readFile throws ENOENT
mockedExistsSync.mockReturnValue(false);
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
// Async stat (the bounded path probe) follows the mocked existsSync, so a
// test that sets up a path's presence via existsSync drives both the same way.
mockedStat.mockImplementation(async (path) => {
if (mockedExistsSync(path)) return { isDirectory: () => true } as never;
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
afterEach(async () => {
@@ -210,6 +229,53 @@ describe('case-routes', () => {
// Should have both regular and linked cases
expect(body.data.length).toBeGreaterThanOrEqual(1);
});
it('still answers promptly when a linked case sits on an unreachable mount', async () => {
// A hard network mount that went away: a synchronous probe blocks the
// thread (simulated by a busy-wait), and an async stat never settles.
const stalledPath = '/mnt/unreachable/linked-nfs';
const BLOCK_MS = 4_000;
mockedReaddir.mockResolvedValue([] as never);
mockedReadFile.mockResolvedValueOnce(JSON.stringify({ 'linked-nfs': stalledPath }) as never);
mockedExistsSync.mockImplementation((p) => {
if (String(p) !== stalledPath) return false;
const until = Date.now() + BLOCK_MS;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
return true;
});
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const started = Date.now();
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
const elapsed = Date.now() - started;
release?.();
expect(res.statusCode).toBe(200);
expect(elapsed).toBeLessThan(BLOCK_MS - 1_000);
// The unreachable case is listed as such rather than holding the list
// hostage, or vanishing as though it had been deleted.
expect(JSON.parse(res.body).data).toEqual([
{
name: 'linked-nfs',
path: stalledPath,
hasClaudeMd: false,
linked: true,
location: 'linked-local',
unreachable: true,
},
]);
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
});
});
describe('remote host and remote case routes', () => {
@@ -670,6 +736,66 @@ describe('case-routes', () => {
expect(body.data.name).toBe('regular-case');
});
it('answers a linked case on an unreachable mount with its registered path, not NOT_FOUND', async () => {
// The timeout path: the mount does not answer at all.
const stalledPath = '/mnt/unreachable/linked-get';
mockedReadFile.mockResolvedValue(JSON.stringify({ 'linked-get': stalledPath }) as never);
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/linked-get' });
release?.();
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toMatchObject({ name: 'linked-get', path: stalledPath, linked: true, unreachable: true });
});
it('still answers a healthy case while unrelated mounts are stalled past the cap', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/linked`);
const releases: Array<() => void> = [];
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockImplementation((p) => {
if (dead.includes(String(p))) {
return new Promise((resolve) => releases.push(() => resolve({ isDirectory: () => true } as never)));
}
return Promise.resolve({ isDirectory: () => true } as never);
});
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/healthy-local' });
releases.forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data).toMatchObject({ name: 'healthy-local' });
expect(JSON.parse(res.body).data.unreachable).toBeUndefined();
// Its CLAUDE.md is probed the same way as its folder, so it is not misreported missing.
expect(JSON.parse(res.body).data.hasClaudeMd).toBe(true);
});
it('answers a local case it cannot read with a non-NOT_FOUND error', async () => {
// A soft mount that gave up (EIO) is not proof the case is gone, and the Run
// button creates a case on NOT_FOUND.
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/eio-case' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(res.statusCode).not.toBe(404);
});
it('returns error when case not found anywhere', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedExistsSync.mockReturnValue(false);
@@ -704,6 +830,16 @@ describe('case-routes', () => {
expect(body.data.todos).toEqual([]);
});
it('reports an unreadable fix plan as an error, not as "no plan"', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/my-case/fix-plan' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
});
it('parses fix plan with todos and stats', async () => {
const fixPlanContent = [
'# Fix Plan',
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
* The route runs the probe out of process (the engine is synchronous), so every test injects the
* runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
* it relies on is exercised for real in test/doctor-cli-json.test.ts.
*
* Port: N/A (app.inject()).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFile: execFileMock };
});
const REPORT: DependencyReportJson = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{ id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
{ id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
],
};
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
execFileMock.mockReset();
});
describe('GET /api/doctor', () => {
it('returns the runner’s report in the success envelope', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: REPORT });
expect(runner).toHaveBeenCalledWith(undefined);
});
it('passes a valid category through and rejects an unknown one without running anything', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
expect(runner).toHaveBeenLastCalledWith('office');
runner.mockClear();
const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
expect(bad.statusCode).toBe(400);
expect(bad.json().errorCode).toBe('INVALID_INPUT');
expect(runner).not.toHaveBeenCalled();
});
it('answers 500 with a message when the runner fails', async () => {
const runner: DoctorRunner = async () => {
throw new Error('spawn blew up');
};
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain('spawn blew up');
expect(res.json().errorCode).toBe('INTERNAL_ERROR');
});
it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => {
const releases: Array<(r: DependencyReportJson) => void> = [];
const runner = vi.fn<DoctorRunner>(() => new Promise<DependencyReportJson>((res) => releases.push(res)));
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const first = app.inject({ method: 'GET', url: '/api/doctor' });
const second = app.inject({ method: 'GET', url: '/api/doctor' });
const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' });
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2));
releases[0](REPORT);
expect((await first).statusCode).toBe(200);
expect((await second).statusCode).toBe(200);
expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office
releases[1](REPORT);
await other;
runner.mockImplementation(async () => REPORT);
await app.inject({ method: 'GET', url: '/api/doctor' });
expect(runner).toHaveBeenCalledTimes(3);
});
it('multi-user: a non-admin is refused and nothing is probed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(403);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
});
});
describe('defaultDoctorRunner', () => {
type Done = (err: Error | null, stdout: string) => void;
const respond = (err: Error | null, stdout: string) =>
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
respond(null, JSON.stringify(REPORT));
await defaultDoctorRunner('core');
const [bin, args, opts] = execFileMock.mock.calls[0];
expect(bin).toBe(process.execPath);
expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
expect(args).toContain(process.argv[1]);
expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
});
it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
});
it.each([
['empty output', ''],
['non-JSON output', 'Segmentation fault'],
['JSON of the wrong shape', '{"hello":"world"}'],
])('rejects %s', async (_label, stdout) => {
respond(null, stdout);
await expect(defaultDoctorRunner()).rejects.toThrow();
});
it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => {
respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), '');
await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s');
});
it('passes the child’s own error through when there is no report at all', async () => {
respond(new Error('ETIMEDOUT'), '');
await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
});
});
+300
View File
@@ -0,0 +1,300 @@
/**
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
* all (remote and Docker sessions). Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env: ENV, stdio: 'ignore' });
let dir: string;
let session: Record<string, unknown>;
async function setup(
opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {}
) {
const h = await createRouteTestHarness(
(app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []),
{
authUser: opts.authUser,
}
);
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = dir;
return h;
}
beforeEach(() => {
clearGitStatusCache();
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/sessions/:id/git-status', () => {
it('returns the snapshot of the session workspace in the success envelope', async () => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), '1\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), '2\n');
writeFileSync(join(dir, 'new.txt'), 'n\n');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.state).toBe('ok');
expect(body.data.repos).toHaveLength(1);
const repo = body.data.repos[0];
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
});
it('reports each repository found below a folder that holds several projects', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const data = res.json().data;
expect(data.state).toBe('ok');
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
expect(data.repos[0].status.counts.uncommitted).toBe(1);
expect(data.repos[1].status.counts.uncommitted).toBe(0);
});
it('answers not-a-repo for a folder that is not a repository', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.json().data.state).toBe('not-a-repo');
});
it('404s an unknown session', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
expect(res.statusCode).toBe(404);
});
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
// The workspace is the root of its repository, as real git would say.
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
const { app } = await setup({ git: runner });
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const revParses = calls('rev-parse');
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(calls('status')).toBe(1);
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
expect(calls('status')).toBe(2);
});
it('runs git in the session working directory', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(runner).toHaveBeenCalled();
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session[kind] = value;
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: the owner and an admin can read it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
for (const authUser of [
{ username: 'alice', role: 'user' as const },
{ username: 'root', role: 'admin' as const },
]) {
clearGitStatusCache();
const { app } = await setup({ git: runner, authUser });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
200
);
}
});
});
describe('GET /api/sessions/:id/git-diff', () => {
const url = (q: Record<string, string>) => `/api/sessions/test-session-1/git-diff?${new URLSearchParams(q)}`;
let root: string;
beforeEach(() => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), 'one\n');
writeFileSync(join(dir, 'b.txt'), 'bee\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), 'two\n');
writeFileSync(join(dir, 'b.txt'), 'staged\n');
git(dir, 'add', 'b.txt');
writeFileSync(join(dir, 'new.txt'), 'fresh\n');
root = realpathSync(dir);
});
it.each([
['unstaged', 'a.txt', ['-one', '+two']],
['staged', 'b.txt', ['-bee', '+staged']],
['untracked', 'new.txt', ['+fresh']],
])('returns the %s diff of %s', async (kind, path, lines) => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: url({ repo: root, path, kind }) });
expect(res.statusCode).toBe(200);
const { diff, truncated, binary } = res.json().data;
for (const l of lines) expect(diff).toContain(l);
expect(truncated).toBe(false);
expect(binary).toBe(false);
});
it('answers 404 for a path or repo the status does not list, running no diff', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
for (const q of [
{ repo: root, path: '../../etc/passwd', kind: 'unstaged' },
{ repo: '/etc', path: 'a.txt', kind: 'unstaged' },
{ repo: root, path: 'a.txt', kind: 'staged' },
]) {
const res = await app.inject({ method: 'GET', url: url(q) });
expect(res.statusCode).toBe(404);
}
expect(runner.mock.calls.some(([, args]) => args[0] === 'diff')).toBe(false);
});
it('does not run git for remote and Docker sessions', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session.remote = { host: 'h' };
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(400);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({ authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(404);
});
it('diffs a staged rename against its old name, and a merge conflict as git’s combined diff (real git)', async () => {
// beforeEach left a.txt/b.txt modified; start this case from a clean tree.
git(dir, 'checkout', '-q', '--', '.');
git(dir, 'reset', '-q', '--hard');
git(dir, 'clean', '-fdq');
writeFileSync(join(dir, 'old.txt'), 'a\nb\nc\nd\ne\nf\ng\n');
git(dir, 'add', 'old.txt');
git(dir, 'commit', '-q', '-m', 'old');
// A real conflict on c.txt.
writeFileSync(join(dir, 'c.txt'), 'base\n');
git(dir, 'add', 'c.txt');
git(dir, 'commit', '-q', '-m', 'c');
git(dir, 'checkout', '-q', '-b', 'other');
writeFileSync(join(dir, 'c.txt'), 'theirs\n');
git(dir, 'commit', '-q', '-am', 'theirs');
git(dir, 'checkout', '-q', 'main');
writeFileSync(join(dir, 'c.txt'), 'ours\n');
git(dir, 'commit', '-q', '-am', 'ours');
try {
git(dir, 'merge', 'other');
} catch {
/* the conflict is the point */
}
// A staged rename, made once the merge has stopped on the conflict.
git(dir, 'mv', 'old.txt', 'new-name.txt');
writeFileSync(join(dir, 'new-name.txt'), 'a\nb\nc\nd\ne\nf\nCHANGED\n');
git(dir, 'add', 'new-name.txt');
const { app } = await setup();
const root = realpathSync(dir);
const rename = await app.inject({ method: 'GET', url: url({ repo: root, path: 'new-name.txt', kind: 'staged' }) });
expect(rename.statusCode).toBe(200);
expect(rename.json().data.diff).toContain('rename from old.txt');
expect(rename.json().data.diff).toContain('+CHANGED');
const conflict = await app.inject({ method: 'GET', url: url({ repo: root, path: 'c.txt', kind: 'conflicted' }) });
expect(conflict.statusCode).toBe(200);
expect(conflict.json().data.diff).toMatch(/<<<<<<<|\+\+<<<<<<</);
});
it('404s a repository inside a Docker case workspace without running git in it', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({
method: 'GET',
url: url({ repo: realpathSync(dir), path: 'a.txt', kind: 'unstaged' }),
});
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
it('checks the repository against the listed ones and re-reads only that one', async () => {
for (const name of ['api', 'web']) {
const r = join(dir, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), `${name}\n`);
}
const calls: Array<[string, string]> = [];
const runner: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
return runGit(cwd, args);
};
const { app } = await setup({ git: runner });
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
calls.length = 0;
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
expect(res.statusCode).toBe(200);
expect(res.json().data.diff).toContain('+api');
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
});
});
@@ -0,0 +1,174 @@
/**
* @fileoverview Session creation must not freeze the server on a workspace whose
* network mount has gone away (`POST /api/sessions` with a `workingDir` on it, and
* `POST /api/quick-start` for a linked case that lives there), and must not treat
* "did not answer" as "does not exist" (quick-start would scaffold a fresh case
* over the top of where the real one is mounted).
*
* A hard mount that stopped answering is simulated two ways, matching how each
* API behaves on one: a synchronous probe (`existsSync`/`statSync`/`mkdirSync`)
* busy-waits, freezing the event loop, and an async `stat()` never settles.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
const dead = vi.hoisted(() => {
// Short probe timeout so a stalled stat costs ~200 ms here. Read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return {
root: '/mnt/codeman-test-dead-mount',
blockMs: 3_000,
syncTouches: [] as string[],
releases: [] as Array<() => void>,
};
});
function onDeadMount(path: unknown): boolean {
const p = String(path);
return p === dead.root || p.startsWith(dead.root + '/');
}
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const freezeOn =
<T extends (...args: never[]) => unknown>(fn: T) =>
(...args: Parameters<T>): ReturnType<T> => {
if (onDeadMount(args[0])) {
dead.syncTouches.push(String(args[0]));
const until = Date.now() + dead.blockMs;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
throw Object.assign(new Error('EIO'), { code: 'EIO' });
}
return fn(...args) as ReturnType<T>;
};
const existsSync = freezeOn(actual.existsSync);
const statSync = freezeOn(actual.statSync as (...args: never[]) => unknown);
const mkdirSync = freezeOn(actual.mkdirSync as (...args: never[]) => unknown);
return {
...actual,
existsSync,
statSync,
mkdirSync,
default: { ...actual, existsSync, statSync, mkdirSync },
};
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (onDeadMount(path)) {
return new Promise((_resolve, reject) => {
dead.releases.push(() => reject(Object.assign(new Error('EIO'), { code: 'EIO' })));
});
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath } from '../../src/config/instance.js';
describe('session creation on an unreachable mount', () => {
let app: FastifyInstance;
let scratch: string;
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
dead.syncTouches.length = 0;
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scratch = await mkdtemp(join(tmpdir(), 'codeman-unreachable-create-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext() as never);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
dead.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
await rm(scratch, { recursive: true, force: true });
await rm(dataPath('linked-cases.json'), { force: true });
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
it('POST /api/sessions answers promptly, and not as "does not exist", for a workingDir on a dead mount', async () => {
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'dead-mount', mode: 'shell', workingDir: `${dead.root}/project` },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
it('POST /api/sessions keeps INVALID_INPUT for a missing workingDir and for a file', async () => {
const file = join(scratch, 'a-file.txt');
await writeFile(file, 'x');
const missing = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'missing', mode: 'shell', workingDir: join(scratch, 'nope') },
});
expect(JSON.parse(missing.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir does not exist',
});
const notDir = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'file', mode: 'shell', workingDir: file },
});
expect(JSON.parse(notDir.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir is not a directory',
});
});
it('POST /api/quick-start refuses, promptly and without scaffolding, a linked case on a dead mount', async () => {
await writeFile(dataPath('linked-cases.json'), JSON.stringify({ 'nas-linked': `${dead.root}/linked` }));
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'nas-linked', mode: 'shell' },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
// Neither probed nor created synchronously on the dead mount.
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
});

Some files were not shown because too many files have changed in this diff Show More