Compare commits

...
Author SHA1 Message Date
Codeman maintainer 53f61ca539 fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.

The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.

Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.

Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.

Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:25:54 +02:00
Codeman maintainer b59145effd feat(templates): new cases' CLAUDE.md asks for absolute file paths and points at the codeman skill
Codeman turns absolute paths in the terminal into links that open the file
viewer, but agents usually report created files as relative paths, which
cannot be clicked. The generated CLAUDE.md now asks for the full absolute
path of every created file in the final reply, and says why relative, ~/
and markdown-link forms do not work.

It also tells the agent about the codeman skill (start, prompt, wait on and
clean up worker sessions) when the skill is available, and how the user can
install it when it is not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:28:55 +02:00
Codeman maintainer 3a0cee6b90 chore: version packages (1.40.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:56:25 +02:00
Codeman maintainer 5e2e9bb833 fix(terminal): the Path and Clear keys act on the tile or Pane B that has the keyboard
The phone keyboard's Path key (insertTerminalText) and its clear-prompt key
(clearTerminalInput) always wrote to the main pane: into its local-echo
overlay, or to the active session. With the tile grid open that overlay is
parked behind the grid, so a picked path landed there unseen and only reached
the session later, and with the split open a path meant for Pane B went to
Pane A.

Both now ask _focusedPane() first. When a tile or Pane B holds the keyboard,
the path is sent to that pane's session through the exactly-once queue, and
clearing sends Ctrl+U there (those panes have no overlay, so the TUI owns the
line), then the pane's own terminal takes focus. The main pane's behaviour is
unchanged. Left over from the final checkup's dictation fix (c10), which
covered voice input only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:22:23 +02:00
Codeman maintainer 28708cfa14 fix(i18n): zh-CN for the Redraw toasts, and comments that named old defaults
Redraw (Ctrl+Shift+R and the header button) shows five literal toasts and a
size report on the main pane, a tile or the split's Pane B. None had a zh-CN
entry, including the two the final checkup's tile Redraw fix added, and
"Failed to restore terminal size" fell to the generic "Failed to" pattern,
which left English behind. They now translate, and the size report keeps its
numbers through a pattern rule. test/redraw-toast-i18n.test.ts reads the
toasts from restoreTerminalSize() itself, so a reworded one without an entry
fails.

Comments and docs that still described an older default:
- styles.css: the Tiles header button is no longer opt-in; it is on by default
  on desktop and off on phones and coarse-pointer tablets.
- terminal-ui.js: the desktop branch of getDefaultSettings is no longer always
  {}; what the comment needs is that it sets no copyStripMargin.
- docs/tile-grid-plan.md: the Tiles default bullet names the tablet default.
- docs/cli-registry.md: codex's footer is read in a two-row window since
  codex 0.162's hint row, not from its last row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:21:25 +02:00
Codeman maintainer c638c88739 Merge the final checkup's fixes into the 1.40.0 release
From the read-only final review of the release, adversarially verified, then reviewed again:
- tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in)
- voice: dictation with the grid open reaches the focused tile
- css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px
- i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts
- cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort
- build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:19:41 +02:00
Codeman maintainer a4511a0e48 fix: final checkup review follow-ups
- i18n: the spreadsheet notice's feature words (charts, drawings, macros,
  pivot tables, external links) were bare, case-insensitive zh-CN keys, so
  the page translator also renamed a charts/ or macros/ folder in the Files
  panel and a case of that name in the case picker. They are now scoped
  'Spreadsheet feature: <word>' keys; warningText() falls back to the plain
  word when the scoped key has no translation (English, no i18n). Removing
  the bare keys closes this branch's regression. The older 'models' key has
  the same class of problem; it is left alone here, since adding
  .case-combobox-option-label to USER_TEXT_SELECTOR would also untranslate
  the picker's two action rows and still miss the title attribute.
- Spreadsheet notice bar: marked data-i18n-skip. It is written already
  translated, item by item, and ends with a number format's code, which the
  observer's t() over the whole line rewrote ({name}, "Codeman").
- Connection tile (Header Stats Style Tiles): applyLocalization() now repaints
  the indicator, so a switch back to English no longer leaves the Chinese
  value word in its data-i18n-skip span until the next keystroke or ACK.
- Tiles default: loadAppSettingsFromStorage() no longer caches the
  posture-dependent showTileGridButton default, so the init merge never
  persists it; a 2-in-1 first opened as a tablet gets the button once docked.
  Every reader still resolves the absent key through a fresh
  getDefaultSettings(), so phones stay OFF and desktops ON.
- Tile grid over a split: closeSplitPane() skips Pane A's closing resize only
  when Pane A becomes a tile. With mergeSplit false (Open group as tiles, a
  stored grid) a Pane A left out of the set gets its full width back before
  the main terminal parks, instead of keeping the split's half width.
- By-case tab strip on tablets and phones: with the boxes dissolved, the
  -<case> part of a generated name shows again, so w1-alpha and w1-beta no
  longer both read "w1".

Each new assertion fails against the previous source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:17:44 +02:00
Codeman maintainer be3436f5b3 Merge the final checkup's tile fixes into the 1.40.0 release
From the read-only final review of the release, adversarially verified:
- a server clear frame refreshes a tile instead of blanking it, so a Claude session Run into the grid keeps its banner and transcript
- a tile refresh fetches first and resets in-stream, so the screen never goes blank while it waits
- Redraw on a tile sends the forced resize and reports only what it sent
- each tile caps its live-output backlog (4 MiB) and recovers dropped output with one bounded refresh
- the tile grid plan is marked merged

Gate green on the branch: static checks, 41 tile and split unit files (836 tests) and 7 browser files (36 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:52:42 +02:00
Codeman maintainer 330203c08b fix(tiles): final checkup review follow-ups
- docs/tile-grid-plan.md: the As built bullet on tile loads said a refresh
  clears the screen at its turn in the queue. Since the fetch-first refresh it
  fetches at its turn, keeps the last frame through the wait and its own round
  trip, and resets with the queued in-stream \x1bc only once the capture is in
  hand; a failed, aborted or empty fetch writes nothing and resets nothing.
- docs/architecture-invariants.md: the tile grid's One load queue paragraph
  gets the same correction, and its list of captures that go through the
  TileLoadQueue now names the server {t:'c'} refresh and the dropped-output
  recovery refresh.
- test/terminal-tile-input.test.ts: destroy() cancelling a pending recovery is
  now pinned on the timer itself (armed before destroy(), null right after it,
  read before any timer runs), since the recovery callback's own destroyed
  guard made the fetch check pass either way; a second test pins that
  destroy() starts the live-output count over, so a write callback xterm still
  owed counts nothing. Both fail with the _resetLiveFlow() call removed from
  destroy().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:48:55 +02:00
Codeman maintainer 9d38cbf51a fix(build,docs): dependency preflight before the build wipes dist, docs drift for 1.40.0
- scripts/build.mjs resolves exceljs/dist/exceljs.min.js and fflate first,
  before tsc and before rm -rf dist/web/public. A tree whose node_modules
  predate those devDependencies (pulled but never ran npm install) used to
  fail in prepare-spreadsheet-assets.mjs with the live dist assets already
  deleted, so the running server served an index.html whose hashed files
  were gone. It now exits 1 with "run `npm install` first", nothing touched.
  test/spreadsheet-assets.test.ts pins the order, that the list covers every
  require.resolve in the prepare script, and runs a relocated copy of the
  build to prove the exit and message.
- CLAUDE.md: the header visibility rule's stock desktop default now lists
  Tiles (1180px and wider), which ships ON on desktop.
- docs/wiki/Agent-CLIs.md: "Before 1.36.0" becomes "Before 1.40.0" (four
  places); 1.36.0 never ships.
- docs/wiki/Home.md: the "Everything in the manual" index lists Tile Grid
  and Custom Model Endpoints, matching the sidebar. test/wiki-home-index
  fails when a sidebar page is missing from that index.
- docs/wiki/Tile-Grid.md: the Tiles default is off on tablets too since the
  touch-primary default landed, not only on phones.
- docs/browser-testing-guide.md: the fixed port table and new WebServer(PORT)
  snippet give way to the port-0 pattern (new WebServer(0, false, true),
  server.boundPort) that test/test-ports-guard.test.ts enforces; the
  examples that opened localhost:3000, the live instance, use BASE_URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:47:22 +02:00
Codeman maintainer ecd577157b fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults
- Codex footer model detection (c28): the modelDetect.screenLine effort
  alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
  'ultra' (offered by the codexReasoningEffort App Setting and codex's own
  /model picker) is read and the launch enum and the footer reader cannot
  drift again. Still one capture group, 125 characters, no new quantifier.
  New session-display-model case loops every effort level, ultra included.

- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
  branches the synced codex model/effort defaults added to the create and
  quick-start routes are replaced by a registry capability,
  capabilities.launchDefaults (launch param -> settings key, values from a
  closed enum), declared on the codex entry only. The resolver moved from
  web/codex-launch-defaults.ts to web/launch-defaults.ts as
  applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
  legacyConfigField object through legacyConfigAliases, still re-validating
  with SettingsUpdateSchema and never overwriting a caller's value. The
  route exclusions are unchanged (create: not remote; quick-start: not
  remote, not Docker, not a custom model endpoint), and quick-start still
  derives the session model from a bag without ompConfig, as before.
  schema.ts refuses an undeclared param, an unknown settings key, an empty
  map, and launchDefaults on an entry with no legacyConfigField.

- The no-id-branching guard now carries an exact occurrence count per
  allowlisted key, so a new copy of an already approved expression fails
  instead of riding the old approval, with a synthetic anti-vacuity case.

- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
  default and 'compact' the headerStatsStyle default, matching the
  resolvers and the pre-paint script; state/case/ledger are marked opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:35:56 +02:00
Codeman maintainer c614241c48 docs(tiles): mark the tile grid plan as merged, and its Tiles default as ON
The status header of docs/tile-grid-plan.md still said both tile PRs were
"local only" and named private worktrees. Both are merged for the 1.40.0
release (#560, the TerminalTile foundation, and #561, the grid), and the
"As built" section below the header is now called out as authoritative
where it differs from the spec. The Gating section's "default OFF" for
showTileGridButton is marked superseded: the button ships ON on desktop
and OFF on handhelds, as the As built list already says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:27:09 +02:00
Codeman maintainer 1def7de146 fix(tiles): cap each tile's live-output backlog and recover dropped output
The server applies no WebSocket backpressure (16 KB / 8 ms batches, no
bufferedAmount check), and a tile wrote every live frame straight into
xterm. A flood a tile could not parse as fast (a shell tile running cat on
a huge log) piled up in xterm's own write queue without bound, on a main
thread up to six tiles share, until xterm's WriteBuffer threw past 50M
code units; onmessage's empty catch then dropped every frame silently and
nothing recaptured the screen. The primary pane caps its queues and drops
then recaptures (_onSessionTerminal, _scheduleDroppedOutputRecovery).

Each tile now writes live output through _writeLive:
- unparsed code units are counted, each write's callback counting its own
  back down; frames held behind a replay (_liveQueue) count too;
- the budget is TerminalTile.LIVE_BACKLOG_BUDGET, 4 MiB, deliberately not
  the primary pane's 128 KB: that caps its own rAF-paced queues, while
  xterm itself paces a tile, and a tight cap would trip on ordinary bursts
  and blank-and-reload the tile over and over;
- past it a frame is dropped, the tile stops writing onto the hole, and one
  refresh is scheduled, debounced and bounded by the primary pane's own
  rule (CodemanDroppedOutput: 2 s, DROP_RECOVERY_MAX_ATTEMPTS, never retried
  after a deadline abort). It is an ordinary refresh, so single-flight,
  bounded by lines=/tail= and paced by the grid's TileLoadQueue. The flag
  clears once a capture taken after the last dropped frame has replayed;
- a write that throws is the same drop, never a "malformed frame";
- past the bound the flag is released, so a tile is never left frozen;
- a reconnect starts the accounting over (an epoch makes callbacks from
  before it count nothing) and drops a pending recovery, since its own
  refresh replaces the screen; destroy() cancels it.
The live-queue flush after a pull or a refresh goes through the same path,
so a throwing write there cannot skip the load's marker and trailing
refresh either.

Tests (input harness, real constants and fake timers): the default budget
lets a 1 MiB unparsed burst through, parsed bytes stop counting, a trip
stops writing and ONE debounced refresh recaptures, a write throw takes the
same recovery, a hole in the held queue is recovered by another refresh,
bounded retries then release, no retry after a deadline, a reconnect resets
the count, and destroy cancels. The fake xterm can now hold and release
parses and throw on a write. Live writes now carry a callback, so the unit
tests match them on the data argument (a `.not.toHaveBeenCalledWith(data)`
would otherwise pass for nothing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:26:51 +02:00
Codeman maintainer 82c87f56d1 fix(i18n): zh-CN for the case picker rows, Bottom bar settings, new toasts, tile and spreadsheet text
- Case picker: the "New or link a case…" and "Case settings…" rows that
  replaced the translated + and gear buttons get zh-CN entries reusing the
  buttons' wording, plus the list's "No cases match".
- App Settings, Bottom bar: the whole group translates as one (heading, the
  four Git status rows with #543's max repositories and git timeout, and
  their descriptions), so it never reads half English. The Git panel's two
  button names, which the new "Git status" key reaches, read naturally too.
- Toasts: the three "Could not open a new window for this ..." errors and
  the dictation-closed warning.
- Spreadsheet preview: "Spreadsheet preview failed (<status>)" gets a
  pattern. Worker refusals map their error code to one user sentence each in
  the renderer (the raw message goes to the console), feature ids read as
  words (pivot tables, external links), and the notice bar translates each
  item before the join; a number format's code passes through untouched.
  The worker and core are unchanged, so their pinned strings and the
  asset version hash stay as they were.
- Header Stats Style Tiles: the connection tile's value word reads in
  Chinese through scoped "Connection tile: <word>" keys, never bare-word
  keys ("retry" is also the orchestrator's Retry button, "LIVE" a resume
  list badge). The indicator cache now includes the UI language, so a
  language switch repaints it on the next update.
- Tile grid: the "Loading…" label was CSS content text the translator
  cannot reach; it is now content: attr(data-loading-label), written through
  the translator when the tile is built and each time it starts loading.

Tests pin every new string (zh differs, no English left, English unchanged)
and fail without these changes: the tile harvest sees the loading label and
the rename field, a CSS guard keeps words out of tile generated content, the
Bottom bar group, the case picker rows and toasts read from their source,
the connection tile across a language switch, and the spreadsheet error
codes, notice bar and status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:23:05 +02:00
Codeman maintainer cdb3c34ae0 fix(tiles): Redraw on a tile sends the forced resize and reports only what it sent
Redraw (Ctrl+Shift+R and the header button, restoreTerminalSize) on a
focused tile or the split's Pane B called tile.fit({ force: true }) and
always toasted "Terminal restored to CxR". In TerminalTile._sendResize,
force only skipped the client-side dedupe: the frame carried no `f`, so
Session.resize skipped a size equal to the one it last applied and the
server did nothing. And _sendResize returned silently with the socket down
or the session popped out to its own window, while the toast still
claimed success.

Both halves are fixed, the first as parity with the primary pane:
- a forced fit now sends `f: true`, the flag the primary's sendResize sets,
  which the server honours (ws-routes reads msg.f, Session.resize then runs
  tmux resize-window and the PTY resize at the same size);
- fit() and _sendResize() return whether a frame went out, and
  restoreTerminalSize toasts success only then. Otherwise it says why, as
  the primary branch does: "sized by its own window" for a detached
  session, "not connected" while the tile's socket is down (it announces
  its size again on reopen), and the primary's "Could not determine
  terminal size" for a pane that measured nothing.

What this does not claim: a forced resize to the size the PTY already has
changes no geometry, so it is not a cure for a garbled tile whose PTY
already matches; the primary pane's forced resize has the same limit. It
matters when the server's recorded size has drifted from the tmux window.

Tests: the forced frame carries f:true (and plain ones do not), fit()'s
return value on send, dedupe, detached and closed-socket paths, and Redraw
end to end on a real tile (sent, socket down, popped out), plus the three
no-success toasts in focused-pane-shortcuts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:20:19 +02:00
Codeman maintainer eb5d982c38 fix(tiles): refresh fetches first, then resets in-stream and replays
A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.

The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
  through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
  arrival time, as _pullHistory already did, and the body read of a bounded
  window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
  unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
  now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.

The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.

Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:18:32 +02:00
Codeman maintainer e39a750749 fix(tiles): treat the server's clear frame as a refresh, not a bare clear
The server sends {t:'c'} from one place only: a fresh Claude pane's first
prompt (Session.startInteractive), meaning "refresh after startup". The
primary pane answers it with a refetch and replay (_onSessionClearTerminal),
and stands aside while the grid is open, so the tile's own handling was the
only one that ran. That handling was a bare xterm clear(), which keeps only
the cursor's row and drops the banner, a resumed transcript and all
scrollback. An idle Claude never repaints static rows, so a Claude session
Run into the grid, or Attached in a tile, came up as a near-empty tile.

_onLiveClear() now calls _refreshBuffer(), the {t:'r'} path: single-flight,
coalesced into one trailing refresh behind a load already running (a shell
pull's held frames included), and paced by the grid's TileLoadQueue. The
queued {clear:true} entry and its branch in _pullHistory's flush are gone,
along with the _clearTerminal helper they used.

Tests: two unit tests pinned the bare clear (a clear frame queued in order
during a pull, and one applied at once before the capture); they are
replaced by tests that the frame coalesces behind the pull and refetches,
plus a socket-level {t:'c'} test, a coalescing test, and a grid test that
the frame waits its turn in the load queue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:13:56 +02:00
Codeman maintainer daf7330d6e Merge tile parity for #555 and #541 into the 1.40.0 release
Grid tiles and the split view's Pane B now get two fixes the primary pane already had in this release:
- opencode's hollow-buffer wheel paging and its click reports (#555)
- the Android soft-keyboard controller, so autocorrect no longer duplicates a line in a tile (#541, with the #441 drain)

Gate green on the branch tip 7409ad26: 525 files and 10243 tests, plus the touched and adjacent browser files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:02:43 +02:00
Codeman maintainer 41643f2b38 fix(css): tablet By-case strip, compositor-only needs pulse, phone chip height
Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's
'tablet'): the case boxes could shrink in the tablet's one-row scrolling
strip, so they squeezed and wrapped their tabs inside themselves and every
tab past a box's first line was clipped under the fixed 48px header. The
boxes now dissolve into the chip row as they already do on phones, which
keeps the tablet's own 40px chip geometry. The rule sits in its own
600 to 767px block, not the 768px tablet block, so the desktop path at
768px and up (boxes keep their width, the strip wraps box by box) is
untouched.

Needs-you tile pulse: the glow animated box-shadow on the tile itself, so
the whole tile (DOM-rendered terminal rows included, the whole stage when
zoomed) was repainted every frame for as long as a prompt waited. The tile
keeps its red border; the glow is now a static inset shadow on a
.tile--needs::after overlay (inset because .tile is overflow: hidden and
clips an outer one, z-index 3 above .tile-attach, pointer-events none)
and only its opacity animates. The entering-and-needs animation shorthand
is gone, since it would now blink the whole tile's opacity, and reduced
motion keeps the static ring and hides the overlay.

Welcome chips: the phone block set 36px, below the 40px that styles.css
gives touch screens, and it wins on every phone, so phones got shorter
chips than tablets. It now restates 40px.

Tests: the tablet widths and the 768px boundary in tab-clusters, the
opacity-only pulse overlay in tile-grid-motion, and the phone chip height
in run-mode-ui; each fails against the previous CSS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:31 +02:00
Codeman maintainer 37ddcbe2f0 fix(voice): dictation with the tile grid open reaches the focused tile
With the tile grid open the main terminal is parked (display: none), but
local echo stays on, so direct-mode dictation for the focused tile's
session (which is activeSessionId) was appended to the main terminal's
hidden local-echo overlay. Nothing appeared in the tile, Enter in the tile
submitted without the dictated text, and the stranded text was later
flushed into whichever tile had focus when the grid closed, or dropped.

- _insertText: skip the overlay while _tilesOwnTerminal() is true, so the
  text goes through _sendToTarget to the session itself.
- The post-insert refocus gives the keyboard to the focused tile (only if
  it is still the dictation target) instead of the parked main terminal.
  Outside the grid it still focuses the main terminal, so split view keeps
  its behaviour even when Pane B took focus mid-dictation.
- Green send button: with tiles open, send only Enter to the target and
  leave the parked overlay and main-terminal predictions alone.

The gate is _tilesOwnTerminal(), not _focusedPane().isPrimary: in split
view a target equal to activeSessionId is Pane A with a visible overlay,
and focus read at transcript time could otherwise push Pane A's dictation
past its own unflushed overlay text.

Tests: tile-grid dictation and green-send cases (both fail without the
fix) plus a split-view pin in test/voice-input-target.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:51:08 +02:00
Codeman maintainer 18c8b5c280 fix(tiles): file drops, focus handoffs, pop-out fallback, split merge, tablet default
- A file dragged onto the tile grid navigated the browser away: the single
  view's drop handler sits on #terminalContainer, hidden while tiles are
  open. The #tileGrid section now cancels every file dragover and drop
  (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a
  drop on a tile uploads its images to THAT tile's session through
  _uploadAndInsertImages, with the same "Only image files are supported"
  toast as image-input.js (now in the zh-CN table).
- App-driven refocus no longer moves DOM focus into another session's
  xterm: a remote delete of the focused tile, _reconcileTileGrid and a
  socket closed with 4003/4004/4010 (_onTileExit) pass focus: false.
  removeTile gains a focus option; user-initiated removes keep focusing.
- Popping out the last tile left the parked terminal's stale content under
  the popped-out tab (and snapshotted it on the next switch).
  _selectAfterTileGrid treats a detached session as unusable for both the
  focused id and the fallback.
- "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass
  mergeSplit: false, so an open split no longer adds its two sessions on
  top of a set already sized to the group, the count and the window.
- Touch-primary devices (primary pointer coarse: iPad, Android tablets)
  default the Tiles button OFF in getDefaultSettings(); touchscreen
  laptops (fine primary pointer) keep the desktop default ON. The button,
  the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key
  through these defaults, so they agree. CLAUDE.md and the invariants doc
  say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:44:52 +02:00
Codeman maintainer 7409ad2655 fix(tiles): say the split and grid width gate is width alone, review follow-up
Two lines the #541 parity commit edited still called the split "desktop-only"
and listed "Phones and tablets" as a tile grid non-goal, right next to the new
note that a wide Android tablet clears the gate. The same commit documents
the gate as width alone in terminal-tile.js and architecture-invariants, and
that is what the code does: terminal-split.js and canOpenTileGrid in
tile-grid.js only compare window.innerWidth with SPLIT_PANE_MIN_WIDTH.

CLAUDE.md's Split-pane line now reads "desktop-only at 1180px (width alone,
so a wide Android tablet clears it)", in step with the Tile grid line, and the
tile-grid-plan non-goal names phones only and says a wide tablet or an
unfolded foldable in landscape can reach the grid, pointing at the keyboard
exception below it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:42:22 +02:00
Codeman maintainer a96a94fb7e fix(tiles): send a tile's click report ephemeral, review follow-up
The tile's hand-encoded click report went through _handleDesktopTerminalClick
and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted
and would be redelivered after a reload. The documented TerminalTile rule
(CLAUDE.md, Split-pane sessions) is that only typed input enters that queue
and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData
says the same. Before #555 an opencode tile's click went through xterm's
encoder and that ephemeral path. A click still unacknowledged when the page
reloads, or sent during a server restart, could be replayed onto a later
screen, where a press+release can pick a dialog option.

_sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and
sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick
passes the target through unchanged, and TerminalTile._installClickListener
sets it. Without the flag nothing changes, so the primary pane's own click
and touch tap reports stay on _sendInputAsync exactly as before (whether the
primary pane should also go ephemeral is a separate question, out of scope
here).

Tests: the tile case now requires a frame with no seq and nothing pending in
the reliable queue, and the targeted-click case in terminal-touch-tap spies on
both send paths: a target with the flag goes ephemeral, an untargeted click
and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile,
or the branch in _sendSyntheticSgrTap, turns the matching test red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:42:03 +02:00
Codeman maintainer 24a73ecd81 fix(tiles): page a hollow tile only from the live screen, review follow-up
A tile counts as hollow when every row above its screen is its own overflow
(baseY minus _overflowRows is 0), so unlike the primary pane, whose hollow
buffer has baseY 0, its viewport can sit above the bottom while it is hollow:
Shift+PageUp, a scrollbar drag or a wheel during the first replay leave it up
there. _maybePageCliTranscript never looked at the viewport, so every wheel,
wheel-down included, was turned into PageUp/PageDown and swallowed. xterm never
scrolled back, the stale rows stayed on screen while the CLI paged out of
view, and clicks were dropped too, because the click report refuses an
off-bottom viewport.

The tile now pages only while _terminalViewportAtBottom holds for its own
terminal, checked before the pending travel is touched. Off the bottom the
wheel stays with xterm, so a wheel-down brings the viewport home and paging
resumes from there. The primary pane is unchanged: its hollow test already
implies a viewport at the bottom, which the twin comment now says.

Tests: a unit case for a tile hollow by the discount with its viewport above
the bottom (no page key, no preventDefault, and no travel carried over once
back home), and the real-browser case now scrolls a hollow tile up and proves
a real wheel-down scrolls xterm home with no page key sent, then pages again.
Both go red with the gate removed, and the unit case also with the gate moved
below the pending-travel update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:40:04 +02:00
Codeman maintainer 312a8faa06 fix(tiles): wire the Android soft-keyboard controller into every tile (#541 parity)
#541 fixed Android autocorrect duplicating the typed line in the primary
pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on
space (delete a word, insert the corrected one) sent the whole line again.
The fix, an edit-based diff that sends one DEL per deleted code point and
then the inserted text, lives in terminal-keycode229-recovery.js together
with #441's next-keydown drain (a character committed in the same task as
Enter goes out ahead of the \r) and the original orphaned-insertText
recovery. Only the primary pane created that controller, so a grid tile or
the split's Pane B still ran xterm's stock behaviour. Both are gated on
width alone (1180 CSS px), which a wide Android tablet clears.

TerminalTile now creates its own controller in connect(), after the xterm
opens and before the first await, handed this tile's textarea, this tile's
CompositionHelper and _onTerminalData as the send path, so recovered bytes
go to the tile's own session through the exactly-once queue. As in the
primary pane, handleKeyEvent runs first in the custom key handler, above the
keyCode-229 early return, and notifyCanonicalData sits in the onData lambda,
gated on the same two CodemanTerminalInput predicates, never in
_onTerminalData, which the recovered bytes also take. destroy() tears the
controller down before disposing the xterm, which restores xterm's own diff
and removes the capture listeners. No mode or device gate, matching the
primary. The module itself is unchanged apart from its header; terminal-ui.js
gains only a comment naming the twin.

Tests: test/terminal-tile-input.test.ts now loads the real module into its
vm harness (with window timers, without which create() would silently throw
and every test would run against no controller) and drives a fake
CompositionHelper carrying xterm's own append-only diff. It covers install
and restore on the tile's own helper and textarea, autocorrect sent as an
edit (with a control reproducing the device-log duplicate), the last
character and an autocorrect each followed by Enter in one task, a
self-rescued 229 key delivered once, the onData gate ignoring query replies
and focus reports, two refused inserts after one keydown both recovered,
robustness when the controller throws, per-tile controllers, and a source pin
keeping the call above the early return. Removing the create, the
handleKeyEvent call, the notify, its gate, or the destroy each turns at least
one of them red, as does moving the notify into _onTerminalData. The browser
suite gains a TerminalTile block in
test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted
execCommand input, chunks asserted to address the tile's session, with a
destroyed-controller control).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:20:02 +02:00
Codeman maintainer 4fe843a94e fix(tiles): page a hollow tile's CLI transcript and report its clicks (#555 parity)
#555 made the primary pane page opencode's transcript with PageUp/PageDown
from the wheel, because opencode draws in place on the alternate screen and
leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile,
the split's Pane B) left every wheel to xterm, so in an opencode tile the
wheel scrolled nothing, or only stale rows.

The tile now runs the primary pane's own gates aimed at itself (its terminal,
its session, never the active one): xterm's tracking mode, the Claude
forwarding gate, then the hollow-buffer test. A wheel that passes them is
consumed in the capture phase and turned into PageUp/PageDown through the
shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin
of the primary pane's queue) and sent ephemeral on the tile's own socket.
Every other wheel stays with xterm as before, the shell history pull
included. The file names no CLI: the mode rules stay in terminal-ui.js, and
terminal-tile.js joins the frontend no-id-branching guard.

A plain port of the primary's baseY === 0 test would almost never fire in a
grid. A tile's first capture is taken at the PTY's previous size (usually the
taller primary pane's) and written into a shorter xterm, and its own
row-shrinking fits (zoom-out, divider drags, tile count changes) push more
rows above the screen. The tile counts those rows as its own overflow: all of
them after a load whose capture held a single screen (the server's
captureRows), plus whatever a local fit or a PTY geometry report pushes up,
reset by a clear and clamped to baseY. The paging gate gets baseY minus that
count. Output that scrolls real lines still counts as history, so the tile
stops paging there.

#555's other half, stripping opencode's mouse DECSETs so a drag selects text,
is server-side and already reached tile sockets. It also left the tile's
xterm unable to encode opencode's clicks, so the tile now installs the
primary pane's desktop click report (bubble phase, gated on the session's
cliMouseTracking, the tile's own link hover and selection). Both listeners,
the flush timer and the page-key state are torn down in destroy().

Still out of scope, as the fileoverview now says: touch paging (tiles have
no touch path) and SGR wheel forwarding to Claude's fullscreen renderer
(tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the
wheel to xterm.

Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm
harness (session targeting, every no-page case, accumulation, the cap,
coalescing, byte parity with the primary pane, the overflow discount through
a load, a fit, a geometry report and a clear, the click report and destroy);
the discount cases fail with it removed. The fake xterm gains opt-in row
emulation. test/terminal-tile-scroll.browser.test.ts checks the same model
against a real xterm with trusted wheel events (browser suite, not the gate).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 07:35:29 +02:00
Codeman maintainer 155372f7a8 refactor(terminal): let the wheel paging and click-report gates answer for another pane
The primary pane's hollow-buffer paging (#555) and its desktop click report
read this.terminal and this.activeSessionId throughout, so a second pane (a
grid tile, the split's Pane B) could only get them by copying the gates and
their CLI rules. They now take an optional trailing target instead, the
pattern registerFilePathLinkProvider, copyTerminalSelection and
_handleImagePaste already use for tiles:

- _shouldForwardWheelToApp(ev, { terminal, sessionId })
- _localScrollbackIsHollow({ terminal, sessionId, localRows }), where
  localRows stands in for baseY so a tile can discount rows it pushed above
  the screen itself
- _handleDesktopTerminalClick(ev, { terminal, sessionId, linkHovered }),
  _sendSyntheticSgrTap(x, y, target), _shouldReportMouseToCli(sessionId),
  _terminalViewportAtBottom(terminal) and _clientPointToCell(x, y, terminal)

Every field left out means the primary pane's, and every existing caller
passes none, so the primary pane behaves exactly as before and its
grep-pinned call sites are unchanged. The mode list for hollow buffers and
the claude >= 2.1.187 forwarding gate stay in terminal-ui.js alone.

The stateless math moves into two pure exports on CodemanTerminalInput,
wheelDeltaLines and pageKeysForTravel, which _wheelScrollLinesFloat and
_maybePageCliTranscript now delegate to. Comments on both sides name the
tile's twins (the page-key pager and the 40 ms coalescer).

Tests: the exports agree with the primary pane's methods and bytes, the gates
read the target's session, buffer, rows and tracking mode rather than the
active ones, and a targeted click uses the target's geometry, selection,
scroll position and link hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 07:18:47 +02:00
Codeman maintainer f855b5d274 Merge the reviewed contributor PRs for 1.40.0
Thirteen contributor PRs, each re-checked against its GitHub head, merged
with its own merge commit and landing fixes, reviewed, and gated together
(524 test files, 10194 tests): #559, #552, #550, #556, #551, #546, #542,
#540, #555, #543, #541, #502, #432.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:33:04 +02:00
Ark0NandClaude Opus 5.5 ccd52583e2 fix(ci): #540 landing review follow-up
Move the nightly browser-suite cron from 03:23 to 03:29 UTC, authored as
Ark0N. GitHub sends scheduled-run failure notices to whoever last modified
the cron line, but its docs do not say whether that means the commit author
or the pusher. The previous cron edit (02c65e98) was authored under the
maintainer identity, whose noreply@anthropic.com address GitHub resolves to
the unrelated login "claude", so under the author reading the nightly's
failure notices would never reach the maintainer. With this commit the
author, the committer and the pusher are all Ark0N, so every reading lands
on the maintainer. Only the minute changes; no doc or test names a clock
time.

After the first scheduled run on master, confirm with
gh api 'repos/Ark0N/Codeman/actions/runs?event=schedule&per_page=1'
--jq '.workflow_runs[0].actor.login', which should print Ark0N.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:28:40 +02:00
Codeman maintainer 2c267276c3 docs(readme.zh-CN): show the tile grid opening and closing
The same Tile Grid subsection and GIF as the English README, in the zh-CN
README under 多会话仪表盘, using the app's own zh-CN terms (平铺 for the
feature and its button, 窗格 for a tile, 平铺网格 for the grid).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:22:16 +02:00
Codeman maintainer efb3fa112d docs(readme): show the tile grid opening and closing
A Tile Grid subsection under Multi-Session Dashboard with an 800px GIF of the
real Tiles button opening six live sessions side by side and closing back to a
single session, recorded from the 1.36.0 release candidate. Identity text in
two terminals is covered by bars; the GIF was OCR-checked for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:13:57 +02:00
Codeman maintainer fe1acd625e fix(test): #542 landing review follow-up
The Run dropdown scroll browser test built WebServer on the fixed port 3290,
which the static port guard (test/test-ports-guard.test.ts, from #556) rejects
for any file outside its shrink-only legacy list, so the CI gate failed on the
landing branch. The test now binds an ephemeral port with new WebServer(0, ...)
and navigates to server.boundPort, and its header records the new convention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:12:18 +02:00
Codeman maintainer 432bd5fc0a fix(codex): #546 landing review follow-up
Pin the App Settings codexModel guard to the schema. The 28df21f4 landing fix
added a client check in saveAppSettings() that copies the pattern of
SettingsUpdateSchema.codexModel, so one bad character no longer 400s the whole
.strict() settings PUT behind a "Settings saved" toast. Nothing tied the copy
to the schema: a looser copy would bring the silent 400 back, and a stricter
one would refuse valid model ids.

The new test extracts the client pattern from the saveAppSettings() body,
checks it agrees with the schema on eight samples (empty, dotted, slashed,
colon, space, semicolon, leading dash, non-ASCII), and asserts the guard runs
before the localStorage write. Length is left out on purpose, since the
input's maxlength="100" covers .max(100). Both a loosened pattern and a guard
moved after the write turn the test red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:11:18 +02:00
Codeman maintainer f79f530f93 fix(mobile): #432 landing fixes
Applies the review's landing list for the native-wrapper window bridge, with the verifier corrections.

detachSession now refuses before asking the host when there is no window channel (no BroadcastChannel). Without the channel there is no roll-call liveness, so a hosted tab could never re-dock and would stay detached, and excluded from tiles and split, until the session ended. The guard sits before the host call so a channel-less host never gets a native window and a window.open as well.

A single resolver, tabDetachButtonEnabled(), now lives in app.js next to hasHostWindows() and decides the host-aware pop-out default for the tab icon, App Settings and the tab action menu (which also serves the tile grid's menu). Before this the menu read the raw setting and hid "Open in a new window" under a host. The menu and both settings-ui.js sites call it optionally with a fallback, because test/session-sidebar-ux.browser.test.ts loads tab-rail-resize.js onto a bare CodemanApp without app.js, and a bare call would throw before the menu is appended.

The "Close window" button on the solo session-gone overlay goes through _closeSoloWindow(), as the re-dock button already did, so it works in a host window.

openWebviewExternal no longer falls through to window.open when the host refuses (in a WebView that can replace the dashboard page); it toasts instead, like the session and file-preview paths.

The hasHostWindows and openInHostWindow JSDoc now say what the code does: anything but false counts as opened, and a saved web tab passes its own origin.

docs/versioning-policy.md lists the window.CodemanHost bridge under experimental surfaces, so it does not read as a stable contract until the wrapper docs section lands.

test/host-window-detach.test.ts gives the harness a live window channel (Object.create leaves it undefined, which the new guard would refuse) and pins the no-channel refusal.

The per-PR changeset is removed; the release writes one consolidated changeset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:57:49 +02:00
Codeman maintainer 48f54ec090 Merge #432: pop a session, a file preview or a web tab out into a native wrapper's own window
# Conflicts:
#	src/web/public/app.js
#	src/web/public/settings-ui.js
2026-10-09 05:55:08 +02:00
Codeman maintainer 34f211538a fix(preview): #502 landing fixes
Skip zero-size spreadsheet cells in renderTile. On sheets past the
8,000,000 px scroll cap, a cell clipped to nothing at the spacer's edge
(or a visible row or column the worker clamped to 0 px) was still created,
and the cell padding and border drew it as a 5 px box below the spacer
that grew the scroll area. The size is now computed before the element is
created and such cells are skipped, the same way the heading loops already
skip 0 px rows and columns. spreadsheet-preview.js is not an input of
SPREADSHEET_ASSET_VERSION, so the asset token stays valid.

Add zh-CN entries for the static spreadsheet preview strings (loading,
too large, no visible worksheets, empty worksheet, the warnings label,
timeout, failure, the four parser start and message failures, and the
unavailable message from panels-ui). The file-preview body is not a
skipped surface, so the exact-match entries apply with no code change.
The worker's admission refusal messages and the dynamic status message
stay English for a follow-up.

docs/security-architecture.md described the attachment gate as a
6-extension allowlist; it now names SUPPORTED_ATTACHMENT_EXTENSIONS in
src/attachment-registry.ts and what it covers, including the xlsx this
PR adds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:54:07 +02:00
Codeman maintainer 0868286661 Merge #502: read-only XLSX spreadsheet preview in the file-preview overlay
# Conflicts:
#	CLAUDE.md
2026-10-09 05:48:05 +02:00
Codeman maintainer d7140c32b4 fix(terminal): #541 landing fixes
A composition that ends in the same task as an Enter keydown was sent
twice. The keydown settled the pending edit (sending the composed word
and setting _dataAlreadySent), then xterm's own keydown finalized the
composition synchronously through _finalizeComposition(false), which
ignores _dataAlreadySent and sent the word again. settleEdit() now takes
the keydown event and, while xterm has a composition in flight
(_isSendingComposition), leaves the text to xterm for any key that makes
it finalize synchronously. On 229, CapsLock and the modifiers xterm keeps
the composition on its async path, which honours _dataAlreadySent, so the
edit still applies there. The waiting timers are cleared before that
early return, so a timer cannot fire after Enter's textarea clear and
send a run of DELs.

The guard sits in settleEdit(), not in applyEdit() as the bot proposed.
In applyEdit() it would also silence the timer path, where xterm always
finalizes asynchronously and skips _dataAlreadySent, so a non-composing
character typed just before a composition (the x in xword) would be lost
where master and the PR head both deliver it.

Two unit tests pin it, both measured: one fails without the guard
(the Enter keydown sends 'ab word' instead of 'ab '), and one fails with
the guard moved into applyEdit() (the timer path sends 'ab ' instead of
'ab xword'; a 229 settle must also still send the edit).

The xterm private-API guard test now also checks the bundle still ships
_isSendingComposition, and names it in its failure message and comment.

CLAUDE.md: the surviving #441 sentence said a keydown decides before
xterm's 229 rescue has run and that Enter's clear makes the pending diff
emit nothing. Neither holds any more (the edit diff is settled first, and
master already sent one DEL there), so it now says the edit diff is
settled first at that keydown. The PR's sentence notes the composition
exception.

The PR's own changeset is removed; its text goes into the single
combined release changeset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:46:56 +02:00
Codeman maintainer af4e3e6ed7 Merge #541: stop Android autocorrect duplicating the typed line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:40:31 +02:00
Codeman maintainer e86c3d1ed3 fix(git-status): #543 landing fixes
A lone repository git could not read rendered as a clean, empty one. The
panel took its single-repository view whenever the overview held one row,
and the error row only exists in the list view, so it showed "Nothing
uncommitted / No remote configured" with an empty header while the
indicator said "? 1". The single-repository view now needs a readable
repository and an untruncated overview; anything else takes the list view
(headed "1 repository"), and the tooltip names the unreadable repository
instead of saying "no branch".

The same condition covers a limit of 1 in a folder of several projects,
now that max repositories can go down to 1: the one row shown keeps the
"Showing the first" notice instead of looking like the only repository.

A repeated timeout query parameter reaches the route as an array, and
calling trim() on it answered 500 with an internal message, before the
ownership check. The route now treats a non-string timeout as "default",
like an empty or absent one, and the route test pins it.

The browser test gains the lone-unreadable-repository case (error row,
no "Nothing uncommitted", "? 1", tooltip names the repository) and the
truncated single-row case. Both fail against the unfixed panel.

The git timeout input steps by 1, not 5: the save accepts any whole
number of seconds and step 5 flagged values like 7 as invalid.

Docs: api-reference says repoLimit is only present in the
folder-of-projects case, the Settings Reference and Working With Files
glyph lists mention "? N", and the module header says the repository
count is the caller's maxRepos.

The PR's own changeset is removed; its text goes into the single
combined release changeset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:36:57 +02:00
Codeman maintainer 1ee566e7a1 Merge #543: configurable git status repository limit and git timeout, unreadable repositories stay listed
# Conflicts:
#	src/web/public/styles.css
2026-10-09 05:32:55 +02:00
Codeman maintainer 1105d646f3 fix(terminal): #555 landing fixes
Comment and doc corrections that #555 made stale, no behaviour change.

- stock.ts: the grok and omp altScreen comments compared their strip to
  opencode's, which is now strip-mux-and-mouse rather than the narrow
  strip. Grok now says it shares antigravity's strip until measured, and
  omp drops opencode from its comparison.
- terminal-ui.js: the touch-tap comment named Claude/Codex/Gemini as the
  stripped modes, but the gate is now the cliMouseTracking flag alone and
  covers opencode too, so it names the two stripping flavours instead.
- src/types/session.ts: the cliMouseTracking JSDoc (the flag the browser
  now gates on exclusively) listed only claude/codex/gemini; it now names
  the strip-full and strip-mux-and-mouse modes, including opencode under
  tmux.
- src/session.ts: the usesMux getter doc now names isMuxMouseStripMode,
  since the replay strip passes usesMux to it as well.
- docs/architecture-invariants.md: the narrow-strip list gains
  grok/deepseek/omp (matching the PR's own CLAUDE.md line), the
  "must REMEMBER" heading covers both DECSET-stripping flavours, and the
  cliMouseTracking writer is described as the full-or-mouse branch it
  really is.
- docs/wiki/The-Dashboard.md: the user manual said every non-Claude CLI
  scrolls locally; opencode's wheel and swipes now page its conversation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:30:20 +02:00
Codeman maintainer ff2f81541a Merge #555: opencode drags select text and the wheel pages its transcript 2026-10-09 05:28:51 +02:00
Codeman maintainer 02c65e988a fix(ci): #540 landing fixes
Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run
failure notices to whoever last modified the cron line, and after the merge
that is the contributor, so a maintainer commit has to touch it. The
docs below give no clock time, so they cannot drift from the cron.

Drop the "Keep the failure artifacts" step and the blank line before it.
No browser test writes test-results/ or screenshots-echo-diag/ (only the
ignore files name them), and if-no-files-found: ignore made the step upload
nothing without a word. The run log already carries the failure output.

Reword the workflow header. Drop the claim that the skipped suite let two
semantically conflicting PRs merge green: that incident came from
test/mobile/keyboard.test.ts, which this job does not run. Correct the
codex-predictive-echo note: the test uses a fake key in a throwaway
CODEX_HOME and skips itself when codex is missing, so it needs a codex
binary, not an authenticated one.

opencode-resize: record WebSocket resize frames under the socket's own URL
instead of appending '#' + the session id. The URL already carries
/ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for
a resize sent on any session's socket, the bug this test exists to catch.

Reduce the six session-id extractions (opencode-resize and perf-browser) to
data.data?.session?.id. POST /api/sessions always answers in the
{ success, data: { session } } envelope, and the dead fallbacks are what
hid the original breakage.

split-pane-terminal: restore the browser config's 60 s test timeout (the
added 20000 ms override tightened it), and replace the comment that blamed
Codeman's post-create clear. Under vitest the session is an echo PTY, so
that clear comes back as text; the real fix is useMux:false, since a plain
prompt otherwise goes through tmux send-keys, which test mode no-ops.

CLAUDE.md: the CI note now says the gate excludes the Playwright tests in
BROWSER_TEST_GLOBS instead of a stale count of 14, and names
browser-suite.yml; the Testing warning says the browser suite runs nightly.
CONTRIBUTING.md gets the same one-line pointer under Tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:13:06 +02:00
Codeman maintainer 4502bfe8a9 Merge #540: run the Playwright browser suite nightly, and fix the stale browser tests it trips on 2026-10-09 05:06:20 +02:00
Codeman maintainer ce6e4cc6e6 fix(ui): #542 landing fixes
Follow the keyboard: the menu's cap now uses var(--app-height, 100dvh)
instead of 100dvh. The viewport meta has no interactive-widget, so dvh does
not shrink for an on-screen keyboard, while MobileDetection always sets
--app-height to the visual viewport height and KeyboardHandler keeps it there
while the keyboard is up (body and .app already size the same way).

Subtract both safe areas from the cap: in the iPhone home-screen app the
phone header grows by the top inset and the toolbar sits above the bottom
inset, so without them the top of a full menu slid under the fixed header.

Add overflow-x: hidden. overflow-y: auto computes overflow-x to auto, so a
long nowrap custom-endpoint label showed a horizontal scrollbar that
touch-action: pan-y cannot pan (the same trap the file documents for
.run-mode-history).

Raise the toolbar while the Run menu is open, by adding
.toolbar:has(.run-mode-menu.active) to the existing popover raise rule. The
menu is trapped in the toolbar's stacking context, so on a touch device the
keyboard accessory bar (z 51) and the visible CJK input (z 52) covered its
last rows even when scrolled to the end. This follows the rule the case
settings popover and case combobox already use.

Reword the rule's comment: it claimed dvh follows the keyboard and that the
vh line is a fallback, and neither is true (a declaration carrying var() is
never dropped at parse time). The new text has no braces and no max-height
text, which the gate test's rule() slicer depends on.

Pin the fixes in the gate test (the --app-height and safe-area terms,
overflow-x: hidden, the toolbar raise) and retitle the cap test so it no
longer names dvh as the mechanism. The test now strips CSS comments before
reading the rule, since the rule's own comment names overflow-y: auto and
touch-action: pan-y and would otherwise keep those assertions green after the
declarations were deleted (checked by deleting them: the test now fails).

Drop .changeset/run-menu-scroll.md: it repeated the false dvh claim, and the
release writes one consolidated changeset at COM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:02:27 +02:00
Codeman maintainer e5417310f8 Merge #542: the Run dropdown fits between the header and the toolbar and scrolls 2026-10-09 04:58:11 +02:00
Codeman maintainer 28df21f4bb fix(codex): #546 landing fixes
App Settings now refuses a Default Codex model that the server would reject,
before anything is written to localStorage. SettingsUpdateSchema is .strict()
and checks codexModel with ^[a-zA-Z0-9._\-/]*$, so a value like gpt-oss:20b
400'd the whole settings PUT while the toast still said "Settings saved", and
because the bad value was already in the local blob every later save from that
device failed the same way. The client check uses the same pattern, shows an
error toast, focuses the field and keeps the modal open. The toast has a zh-CN
translation in i18n.js.

src/web/codex-launch-defaults.ts gets an @fileoverview (fill only unset fields,
re-validate persisted values, callers decide scope, never writes Codex config
files), as every module in src carries one.

Both new Codex rows in index.html carry has-field, like every other App
Settings field row, so on phones the input and the select stack under their
label instead of squeezing it into a narrow column.

The Agent CLIs wiki paragraph said the defaults apply to every local launch.
Scheduled (cron) codex jobs are built without a codexConfig and never get
them, while Resume goes through POST /api/sessions and does, so the sentence
now names the Run menu, Resume, POST /api/sessions and /api/quick-start, and
says cron jobs do not use them.

The Settings Reference lists the two new rows in the Agents & CLIs table. The
neighbouring "Bypass approvals and sandbox" row described Pi's project trust;
it is the Codex --dangerously-bypass-approvals-and-sandbox toggle, so its note
says that now.

The PR's own changeset is removed: the release writes one consolidated
changeset at COM, and the PR's text overstated the scope (it included cron).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 04:56:29 +02:00
Codeman maintainer 5c08e29a08 Merge #546: a synced default Codex model and reasoning effort for new local Codex sessions 2026-10-09 04:53:49 +02:00
Codeman maintainer 7f26b4ba46 fix(screenshots): #551 landing fixes
Pin the deprecation warning on every /api/screenshots route. The PR's test
sends two GET /api/screenshots requests and checks that exactly one warning
comes out, which proves the once-flag but not the individual calls: the
POST and GET /:name warn calls could be deleted and it would stay green. A
new it.each sends one request per route (GET list, a non-multipart POST that
reaches the handler before the content-type check, and GET /:name for a
missing file) on the fresh per-test harness, and each case asserts exactly
one warning naming POST /api/sessions/:id/paste-image. Removing any single
warn call now fails its own case (checked by deleting each call in turn).

The deprecation's CHANGELOG note, required by docs/versioning-policy.md for a
deprecated covered surface, rides the consolidated release changeset rather
than a file here, since the PR added none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 04:52:24 +02:00
Codeman maintainer 5c357d699f Merge #551: remove the broken tunnel upload page and deprecate /api/screenshots 2026-10-09 04:50:52 +02:00
Codeman maintainer c2340ae88a fix(tests): #556 landing fixes
Move test/sse-tile-grid-filter.test.ts to an ephemeral port. The release
added it with #561 on fixed port 3287, after #556 was cut, so it is not on
the guard's LEGACY_FIXED_PORT_FILES and test/test-ports-guard.test.ts failed
on the merged tree. It now builds new WebServer(0, ...) and its url() helper
reads server.boundPort (only ever called inside tests, after beforeAll).
Converting it is preferred over listing it, since the legacy list is
shrink-only.

Update the five docs that still told contributors to pick a unique fixed
port, which the new guard now rejects for any WebServer test: CLAUDE.md
(Adding Features and Testing), AGENTS.md, .github/CONTRIBUTING.md and the
wiki's Contributing page (mirrored to the public GitHub wiki). They now say
to bind port 0 and read boundPort (or address().port for a raw server), and
note that the mobile suite keeps its fixed ports for now, because
test/mobile/helpers/server.ts caches servers by port, so createTestServer(0)
from two callers would share one server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 04:48:43 +02:00
Codeman maintainer 0f5613ba46 Merge #556: the server reports the port it bound, and the port-sharing tests bind ephemeral ports 2026-10-09 04:47:53 +02:00
Codeman maintainer a049c69cbb Merge #550: keep Respawn and Ralph visible in Session Options for Claude sessions 2026-10-09 04:46:45 +02:00
Codeman maintainer 2c066eb2f0 Merge #552: the wiki's Contributing page says npm test is the CI gate, as CONTRIBUTING.md does
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 04:46:27 +02:00
Codeman maintainer 3bfb3ddfc5 Merge #559: range-check the remote-wake readiness budget instead of pinning it to the millisecond 2026-10-09 04:44:53 +02:00
Codeman maintainer 7e2b9ed8f6 feat(opencode): show the model an opencode session runs in its tile and split headers
An opencode tile showed only the session name, while Claude Code, codex and
DeepSeek tiles add `· <model>`: opencode declared no `modelDetect`, so its
screen was never read for a model and it is launched without a model param.

opencode draws the model on its composer's agent row, directly above the
box's bottom edge: `┃  Build  Big Pickle OpenCode Zen`. Read from its own
1.3.0 source, the row is the agent, the model's name, the provider's name and
`· <variant>` when the model has one, and only colour tells model from
provider. So the field is all of it, exactly what opencode itself shows (the
owner's choice over a short id that only appears after the first reply).

- The pattern anchors on that row sitting directly above the `╹` edge, ends
  the field at a double space (where the 200-column layout's sidebar shares
  the row), skips the `No provider selected` placeholder, and takes the LAST
  such row in the window through a lookahead, so a composer-shaped row the
  agent prints higher up can never stand in for it. A test with a forged pair
  inside the window fails without the lookahead.
- It reads 8 rows: the home screen puts up to five rows of opencode's own
  chrome under the composer (key hints, a tip, the cwd/version row). The
  schema's `screenLines` bound goes from 4 to 8, the reader's own cap; the
  comment there records why a taller window is only safe with such a pattern.
- A permission prompt or shell mode hides the row; the last model is kept.

Measured against every captured opencode 1.3.0 frame (home screen and in
session, 40/60/120/200 columns, mid-turn and at rest, permission prompt):
the model was read everywhere it is drawn and nowhere else. Live on an
isolated instance from this branch, a restored opencode session published
`displayModel: Big Pickle OpenCode Zen` (source: screen) and its tile header
rendered `oc-home · Big Pickle OpenCode Zen`. The owner's own home-screen pane
on the 1.36.0 beta reads the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 03:37:42 +02:00
Codeman maintainer 7d8c188f83 fix(gemini): read Gemini CLI's composer bar and spinner line so a turn can end
A gemini session stayed "working" for good after its first turn. Its braille
spinner trips the generic SPINNER_PATTERN and marks the pane working, but only
a composer glyph arms the idle confirmation and gemini declared none, so it
fell back to Claude's `❯`, which gemini never draws.

Measured on live Gemini CLI 0.63.0 panes (capture-pane every 300 ms through
real turns with a shell call at 40, 120 and 200 columns, YOLO and default
approval mode, plus the raw PTY stream). The turns ran against a local
stand-in for the Gemini API (GOOGLE_GEMINI_BASE_URL, which Codeman's custom
endpoint support already sets), since the CLI's TUI does not depend on the
backend and no account is needed for it:

- The TUI repaints its whole bottom region every frame, composer included,
  and the composer sits between a `▄` bar and a `▀` bar; the submitted prompt
  is echoed between the same bars. The `▀` bar arms the idle check: every
  repaint carries it, tmux's reattach repaint too. The composer's prompt
  character is no good: it follows the approval mode (`*` in YOLO), and its
  `>` also starts the echoed prompt, which would make the submit verifier
  read a submitted prompt as stranded and press Enter again.
- While a turn runs a line `⠦ Thinking... (esc to cancel, 6s)` animates about
  every 80 ms (largest gap mid-turn: 214 ms). The label can be any loading
  phrase, so the working line is the `(esc to cancel, <n>` suffix, or a
  spinner frame opening a line for when a long phrase wraps that suffix.
  Nothing at rest matches either.
- A tool confirmation (default mode) replaces the composer, stops the
  spinner and the pane goes silent (3.9 s gap), so it reads as idle.

Verified on an isolated instance from this branch: a YOLO turn emitted one
session:working (+170 ms) and one session:idle (2.5 s after the last output);
a default-mode turn went working -> idle while the confirmation waited ->
working once allowed -> idle at the end; after a server restart four restored
gemini panes went busy -> idle in about 4 s; a fresh launch settled in 3 s.

The launch-settle and uncharacterised-CLI tests that used gemini as their
example of a CLI without work detection now use grok and deepseek.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 03:37:41 +02:00
Codeman maintainer f3b2080e69 fix(codex): see the background-terminal row under codex 0.162's hint row
A codex session waiting on a background terminal read as plainly idle,
with no "1 background terminal" badge. Codex pins that row above its
composer, and the registry looked for it in the last three non-blank
rows. Codex 0.162.0 added a hint row under the status line at rest
(`  ← for agents · ? for shortcuts`), which pushes the chip to FOURTH from
the bottom exactly when the idle probe reads it. Measured live on the
1.36.0 beta with `sleep 600` started as a background terminal: chip,
composer, status line, hint row; the server reported `watching: null`.
While a prompt is typed the hint goes away and the chip is third again.

The codex entry now declares `watchingLines: 4`. The trade is stated in
the entry: with no terminal running, the fourth row from the bottom is
the last transcript row (the last two while typing), which the agent
writes. As before, this is contained by codex having no hooks: a forged
row costs a wrong badge, never a silenced alert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 03:29:37 +02:00
Codeman maintainer a58991e6d8 fix(pi): read the model off pi's footer so pi tabs and tiles name it
A pi session showed no model in its tab or tile unless one was passed at
launch, and none at all for the default route. pi draws its model in its
own footer, but its registry entry declared no modelDetect, so the pane
probe never read it.

The footer, from pi 1.1.0's footer code (0.84.4's is the same) and a live
pane (`0.8%/253k (auto)       qwen3.8-27b-pi • xhigh`): usage and context
on the left, then at least two spaces and `[(provider) ]<model>`, with
` • <thinking>` for a reasoning model and ` → <routed model>` when
routed. The last two rows are read (an extension status row can sit
below), and the context field picks the stats row out of them.

pi truncates the right side to fit a narrow pane with no ellipsis,
leaving exactly two spaces of padding. A name with nothing after it is
therefore read only with three or more spaces in front, and with two only
when a following ` •`/` →` proves it whole, so a cut-off name is never
shown. `no-model`, pi's placeholder, is rejected.

The read rides the idle confirmation pi gained with its workDetect entry.
Verified on an isolated instance: a fresh pi session published
qwen3.8-27b-pi (source screen) about 8 s after launch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 03:10:43 +02:00
Codeman maintainer 3fe5d1b278 fix(codex): read the model above codex 0.162's new footer hint row
Every codex tile and tab showed no model, unlike claude and deepseek.
Codex reports its model only in the footer under the composer
(`  GPT-6-Luna default · ~/codeman-cases/testcase`), and the registry read
the pane's LAST row for it. Codex 0.162.0 added a hint row under the
footer at rest (`  ← for agents · ? for shortcuts`, or `  ? for
shortcuts`), so the last row was always the hint and `displayModel`
stayed null. Measured live on the 1.36.0 beta: the hint is there at rest
and after a turn, and gone while a prompt is being typed (the footer is
the last row again then).

The codex modelDetect window is now two rows, and the footer must be
either the last row or followed by exactly one more two-space-indented
row. Anchoring to the end of the window keeps the guard the one-row rule
had: with the footer hidden, the last two rows are a transcript line and
the `›` composer, so a footer-shaped line the agent printed is not read.
Tests cover both 0.162 hint variants, the typing layout, the hint never
read as a model, and a forged footer-plus-indented pair above the
composer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 03:10:36 +02:00
Codeman maintainer 112c533ac7 fix(omp): read omp's status bar so an omp turn can end
An omp session stayed "working" for good once a turn started, the same
latch pi had: omp's braille spinner trips the SPINNER_PATTERN fast path,
and only a composer glyph arms the idle confirmation. omp declared none,
so it fell back to Claude's `❯`, which omp never draws once its setup
wizard is done.

Measured on live omp 18.8.6 and 18.0.11 panes, holding a turn open
against a local endpoint that never answers: the input row is `╰─ <text>`
and is redrawn at submit, at the end of a turn, at launch and on
reattach. While a turn runs, the status bar's leading `π` becomes a
braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > ...`; 18.0.11
pads it with two spaces, past a minute it reads `1m`), with a
`⎋ Working…` row above it. The registry entry now names the input row as
the glyph and either working signal as the working line.

The glyph also switches the submit verifier on for omp, which reads the
input row the way it reads Claude's composer. A prompt sent mid-turn goes
to omp's Steering queue and clears the row, so the verifier stands down.
Text left in the row after an Enter is the one case it re-presses.

Verified end to end on a sandboxed instance (own HOME and PATH, omp
18.8.6): session:idle at launch, session:working during a turn,
session:idle about 3 s after it ended, and a restored pane settled idle
about 3 s after a server restart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 02:43:22 +02:00
Codeman maintainer e7d661158b fix(opencode): read opencode's composer bar and footer spinner so a turn can end
An opencode session that ran a tool stayed "working" for good. The running
tool row draws a braille spinner (`⠋ Sleep for 12 seconds...`), which trips
the generic SPINNER_PATTERN and marks the pane working, but only a composer
glyph arms the idle confirmation and opencode declared none, so it fell back
to Claude's `❯`, which opencode never draws. Measured on an isolated
instance: a 16 s turn latched busy/isWorking for the rest of the session.
A text-only turn had the opposite problem and never showed as working.

Measured on live opencode 1.3.0 panes (capture-pane every 250-300 ms through
real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream):

- Every composer row starts with a `┃` bar, and the submitted prompt lands in
  the transcript with the same bar, so a turn's first repaint arms the idle
  check, and tmux's reattach repaint does the same for a restored pane.
- While a turn runs the footer row starts with an 8-cell knight-rider
  spinner, `⬝■■■■■■⬝  esc interrupt`, redrawn about every 40 ms (largest
  gap mid-turn: 121 ms). At rest the TUI is silent and nothing on screen
  draws a `⬝`/`■` run, the 200-column sidebar included.
- The working line is the spinner run, `[⬝■]{8}`, not the label: tmux ships
  `esc` and `interrupt` as separate words joined by cursor moves, so the
  label never reaches the stream detector, and at 40 columns the footer
  wraps it to `esc` / `interr` / `upt`. All 344 spinner chunks of a turn
  match the run after Codeman's ANSI strip.
- A pending permission prompt replaces the composer and stops the spinner,
  so it reads as idle (waiting on the user).
- The last `┃` row on screen is the composer's agent/model row, or the
  permission box's closing bar, never the prompt text, so the submit
  verifier stands down and can never press Enter into a dialog.

Verified on an isolated instance from this branch: a 15 s tool turn emitted
exactly one session:working (+271 ms) and one session:idle (3 s after the
spinner stopped); a permission prompt read idle and the allowed turn went
working -> idle; after a server restart both restored opencode panes (one
at rest, one on a permission prompt) went busy -> idle in about 4 s; a
fresh launch reached an open page as idle in 3 s.

The launch-settle tests that used opencode as their example of a CLI
without work detection now use gemini and antigravity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 02:40:04 +02:00
Codeman maintainer db168cdbe5 fix(session): never settle a just-prompted pane idle at launch
40560ace made the 3 s launch settle announce its idle. For a CLI with
work detection that edge could now land in the middle of a turn: a
prompt sent ~2.9 s after launch has not been marked working yet (the
working line goes through the deferred parsers), so the settle called
the pane idle and a send-and-wait registered for that prompt resolved
before the turn even started. Before 40560ace the settle was silent, so
this edge is new.

The settle now also leaves a pane to `_confirmIdle()` when a prompt was
submitted since the timer was armed, the same way it already does for a
pane marked working; that confirmation reads the screen before it ends
the turn. A CLI without work detection still settles: nothing else ever
would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 02:18:26 +02:00
Codeman maintainer b539780f33 fix(pi): read pi's composer rule so a pi turn can end
A pi session stayed "working" for good once a turn started. pi's braille
spinner trips the SPINNER_PATTERN fast path, which marks the pane working,
but only a composer glyph arms the idle confirmation and pi declared none,
so it fell back to Claude's `❯`, which pi never draws. Measured on beta136:
an errored turn stayed busy/isWorking for 3+ minutes after pi was back at
rest.

pi has no composer glyph. Measured on a live pi 1.1.0 pane (capture-pane
every 250 ms through a turn): its composer sits between two `─` rules, and
while a turn runs it rewrites the top rule as `── ⠏ Working ───` on every
frame. The registry entry now names the rule as the glyph that arms the
check and a spinner frame inside it as the working line.

The same glyph settles a reattached pi pane (a restored pane gets no launch
timer): tmux's reattach repaint carries `─`, which arms the confirmation.
The submit verifier reads the last rule, finds no prompt text and stands
down, so it can never press Enter on a pi pane.

Verified on an isolated instance from this branch: a real pi turn emitted
session:working then session:idle, and after a server restart the restored
pi pane went busy -> idle in about 3 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:49:19 +02:00
Codeman maintainer 40560aced1 fix(session): announce when a fresh or re-attached agent pane goes idle
A fresh codex, pi or opencode tile could spin "working" forever while the
pane sat at its composer. The external-CLI launch timer set the status to
idle WITHOUT an event, only `needsRefresh`. When the launch paint never
marked the pane working, the later idle confirmation found the status
already idle and announced nothing either, so every open browser kept the
`busy` from the spawn broadcast. A reload fixed it, which is why only open
pages were stuck. Measured on the 1.36.0 beta: w8 (codex) had
`lastPromptTime: 0`, i.e. no idle edge ever, and a page held a fresh pi
session at `busy` while GET /api/sessions said `idle`. pi and opencode hit
it on every launch (no work detection, so the timer is all they have);
codex only when its launch paint lost the race against the timer.

- `_concludeIdle()` is now the one place a pane is concluded idle: status,
  working flag and prompt stamp change together and `idle` is emitted
  (session:idle + state broadcast). `_confirmIdle()` uses it too.
- The launch settle (`_settlePaneStartup`) concludes a pane still in its
  spawn-time `busy`. A pane already working is left to `_confirmIdle()`
  only when its CLI declares `capabilities.workDetect`; for the rest the
  timer is the only thing that can settle it, so it also clears a working
  flag a launch spinner glyph latched.
- `_armPaneSettle()` also arms it for a RESTORED pane (Codeman restart,
  auto-reattach, tile Attach) of a CLI without work detection (at this
  commit shell, opencode, gemini, antigravity, pi, grok, deepseek and omp),
  which used to stay `busy` server-side with nothing to clear it. Restored claude and
  codex panes stay on their composer glyph, so a restart mid-turn is never
  called idle. No `needsRefresh` there: an attach refetches by itself.

Pre-existing since the OpenCode integration, not a regression of this
release. Restore-path gap reported by the opencode and pi sessions working
the same symptom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:49:04 +02:00
Codeman maintainer 09d3b9a3bf feat(tabs): every agent tab shows its CLI logo, claude included
A claude tab drew no harness mark at all and every other agent CLI a
two-letter text pill (DS, CX, ...), so a claude tab read as "no harness"
next to its neighbours. Session tabs (header strip, side rail, sidebar,
phone chips) and the desktop home rail now draw the agent through PR
#532's run-mode-dot <id> slot, the id as data, the same mark the Run
menus and the tile and split headers use. The shell is not an agent and
keeps its SH pill; a CLI added through clis.json gets the slot's plain
dot instead of nothing.

The per-CLI tab pill colours and their light-skin ink overrides are gone
(the monochrome marks follow the tab's own text colour), and the logo
steps aside with the other adornments while a compact rail row renames.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:02:57 +02:00
Codeman maintainer 34cbd5015b fix(welcome): judge-panel fixes on the new launcher row
Design B ("one primary + chips") won a two-judge panel over a launcher grid
and a command panel. Their defect list, fixed here:

- The tunnel spinner's track was the shared translucent white and vanished on
  light skins; it now follows the link's own text colour.
- A running tunnel was only green text above its QR; it now shows as a quiet
  green pill, and the resting link uses --text-dim (contrast).
- A long custom CLI label could push a horizontal scrollbar into the welcome
  column: labels sit in a .welcome-label span that ellipsizes (still one text
  node, so i18n.js matches it), and both buttons cap at the column width.
- Chips are 40px tall on coarse pointers (tablets reach this view).
- The OG primary's colours now name the toolbar Run rule they mirror.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 23:07:55 +02:00
Codeman maintainer a1d07a56e5 feat(welcome): one primary launcher plus a calm chip row
The welcome overview drew every CLI as its own gradient pill (one colour,
gradient and glow per CLI, plus skin overrides that re-tinted three of them),
all at the same size with the same generic play icon, wrapping into an
unaligned cluster with Cloudflare Tunnel styled as one more launcher. Nothing
said which action mattered.

Now the screen has one obvious thing to press:

- A single primary button for the first AGENT in the registry catalog (Claude
  Code on a stock install), in the accent fill the toolbar's Run button uses,
  with the CLI's real logo on a small light disc (a brand mark straight on the
  accent turns muddy) and the same translatable "Run <label>" text.
- Every other enabled CLI as a slim, uniform pill under it, in the Compact
  header's language: control-bg surface, control-border, small type, the
  logo from the shared run-mode-dot slot, the bare name, and "Run <label>" as
  tooltip and accessible name. Catalog order, centred, wrapping when needed.
- Cloudflare Tunnel as a quiet text link under the launchers (same id,
  handler and cloudflared gate; text colour carries the active and connecting
  states), still directly above the QR it reveals.

The primary is chosen by catalog order and kind, never by an id, so with
Claude disabled the next agent takes the slot and a custom CLI listed first
gets it like any other. The buttons carry no per-CLI class any more: the id
travels only as data-mode and the logo slot, and all the per-CLI welcome
gradients and their skin overrides are gone. Everything is token-driven, so
it follows every skin; OG gets its own toolbar Run blue with light text
because its --accent-ink is an unused placeholder. Focus rings sit offset
from the fills, hover motion is off under prefers-reduced-motion, and the
phone rules (reached only with the phone overview off) span the primary and
keep the chips wrapping.

Tests: run-mode-ui pins catalog order across primary and chips, the
kind-based primary choice (Claude off, shell listed first, custom agent
first, shell only), the logo slot and absence of id classes, the token-only
CSS with no .welcome-btn rule left, and the tunnel link's place between the
launchers and the QR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 23:05:02 +02:00
Codeman maintainer 2accd804f9 feat(toolbar): move "+" and the case gear into the case picker
The owner asked to drop the "+" (Add Case) and gear (case settings) buttons
beside the toolbar case picker. Their two actions now close the picker's own
list instead, as "New or link a case…" and "Case settings…" rows in a sticky
footer, so nothing becomes unreachable: on desktop the "+" was the only door to
Add Case (create, link, clone, manage), and the gear the only one to the
per-case Agent Teams / 1M Opus overrides.

The rows are part of the arrow-key walk (after the last case) and Enter runs
them, they also show when nothing matches the filter, and the case settings
popover still opens anchored to the case group. The picker input gets its
right-hand corners back, and the dead .btn-case-add / .btn-case-settings rules
(desktop, phone and two skin selector lists) are gone. Phones keep their own
case sheet and gear.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:22:40 +02:00
Codeman maintainer 6644962d70 feat(defaults): Compact header, Tiles button on, Classic tab layout
The owner's picks after testing the 1.36.0 beta:

- Header Stats Style defaults to Compact (two pills with rings) instead of
  Tiles. The resolver, the pre-paint stamp and the App Settings option all
  agree; an unknown value now reads as compact.
- The Tiles header button is ON by default everywhere but handhelds (their
  defaults object keeps it off, and the button still needs a 1180px window).
  The Ctrl+Shift+G gate in tileShortcutFor() now resolves an absent key
  through the device defaults too, so the chord and the button cannot
  disagree; before, it required a stored true.
- Tab Layout defaults to Classic (the single strip, as before). By state, By
  case and Ledger stay available as opt-ins. The tile-grid beta the owner used
  last had only this layout, and it is the one they wanted back.

zh-CN option labels follow the new "(default)" markers; docs and wiki updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:17:27 +02:00
Codeman maintainer 1568e489eb fix(tabs): never size the state label column from hidden headings
#538's by-state header strip lines its labels up in a column measured into
--tab-triage-gutter by _sizeTabTriageGutter(). Under 600px mobile.css hides
the headings (the phone row keeps its chips, no labels), but the sizer
measured them anyway: every part was 0 wide, yet `width + 5 * (parts - 1)`
made each labelled heading 5, so the "nothing to size" guard never tripped,
the column became 15px and the group key was cached as if measured. A phone
turned to landscape (>= 768px) or a foldable opened (Find N5: folded under
600, unfolded 1124 wide) then crossed into the wrapping strip with no tab
render behind it (the resize handler only calls updateTabOverflowMode()),
the key had not changed, and "WAITING 2" sat in a 15px column on top of the
first tab of its row.

Only the wrapping strip reads the column, so the sizer now measures nothing
and forgets its key while the strip does not wrap, counts only parts that
are laid out (a hidden heading sizes and keys nothing), and
updateTabOverflowMode() calls it right after deciding the wrap. Phones and
tablets therefore never measure (no layout read per render pass), and every
flip into the wrapping strip, the 768px breakpoint included, measures
afresh. The 600px breakpoint only matters below 768px, where nothing is
measured.

Tests in test/tab-triage.test.ts pin that hidden headings size and key
nothing, that the real updateTabOverflowMode() measures on the unfold with
no render, and that wrapping again re-measures with the counts unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00
Codeman maintainer 4ee382832a fix(tabs): keep the active tab in view when it changes state band on a phone
On phones and 600-767px tablets the header strip stays one horizontally
scrolling row. #538 (Tab Layout, default "by state") orders that row in one
flex `order` band per state, so when the ACTIVE session changes state (a
prompt sent: idle to working; a permission prompt: needs you) its chip
moves to another band while scrollLeft stays put, and the tab in use left
the screen (measured at 390px: x 165 to -870). #257's reveal rules only
covered a CHANGED active tab: _updateActiveTabImmediate reveals on a
switch, and _fullRenderSessionTabs restores scrollLeft and re-reveals only
when _lastRenderedActiveTabId changed, while a state change is an
incremental pass that never reveals at all.

_noteActiveTabBand() records the active tab's band (read off the element,
so it is what is on screen) and reports when it moved while the tab stayed
active. Both render paths reveal on that, in the single scrolling row only
(_isScrollingTabRow: not wrapping, not a vertical list). It is keyed on the
band, not the raw order value, because another tab entering or leaving the
active tab's band shifts that value by one and another tab's move must not
yank a strip the user is browsing. _updateActiveTabImmediate records too,
so the pass right after a switch still counts (viewing a waiting tab spends
its alert and drops it into the idle row). The incremental path reveals
after updateTabOverflowMode(), and only from the branch that reached
_syncTabTriageChrome(), so a pass that falls through to a full rebuild
mid-loop leaves the record for the rebuild to compare.

Tests in test/tab-triage.test.ts pin the reveal on both paths and after a
switch, and that another tab's band change, a wrapping strip, a vertical
list and an active web tab never scroll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00
Codeman maintainer 1296223403 fix(i18n): translate the Tab Layout and Header Stats Style settings
#538 added three App Settings rows (Tab Layout, State Order, Header Stats
Style) with their descriptions and nine options, and none of them had a
zh-CN entry, while #561 in the same release translated every string it
put on screen. With the language set to Chinese those rows stayed English
in the middle of a translated settings page.

Adds the 15 entries next to the other tab-bar settings. The header
style's "Tiles" is 磁贴, not 平铺: 平铺 is #561's word for the tile grid
(the Tiles button and its setting), and "Tiles (default)" must not read as
the grid. The test runs the real index.html through the real translator
in JSDOM, checks every label, description and option of the three rows
(the shared `desktop` tag aside), that English is unchanged, that each key
is in the dictionary once, and that the strip's state-row headings
translate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer deb11bade2 fix(tabs): keep the in-tiles tab marker in the ledger layout
#561 marks the tab of every tiled session with an inset underline
(`.session-tab.in-tiles:not(.active) { box-shadow: inset 0 -2px 0 ... }`,
0,3,0). #538's ledger draws each cell's status bar as an inset box-shadow
too (`.session-tabs-host > .session-tabs.tabs-ledger > .session-tab`,
0,4,0), so in the ledger the bar replaced the marker and nothing in the
strip said which sessions were on the grid.

The ledger now restates the marker beside its bar for a tiled, inactive
cell; the bar still follows --ledger-bar, so needs-you, waiting and exited
cells keep their colours. By state, by case, classic and the side rail
paint no cell shadow of their own and already showed it. The test scans
every arrangement-scoped rule that paints a tab cell's shadow and requires
an .in-tiles variant that keeps both, so a new arrangement cannot drop it
again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:36:04 +02:00
Codeman maintainer bd3c368512 fix(header): keep open Tiles and Split pressed in the boxed header styles
#538's Compact and Tiles header styles give every header icon button a box
through `html[data-header-stats] .header-right > .btn-icon-header` (0,3,1,
hover 0,4,1), which sets background, border and colour. That outranks the
open-state accent of #561's Tiles button (`.tiles-open`, 0,3,0) and of the
Split button (`.split-open`), hover included, so under the default Tiles
style an open grid or split looked exactly like a closed one and nothing
said the next click would close it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:45:12 +02:00
Codeman maintainer d630e62114 Merge #538: Tab Layout (by state, by case, ledger, classic) and three header stats styles (Discussion #426) 2026-10-08 19:44:22 +02:00
Codeman maintainer b1def488c4 Merge #544: lineage lines as routed trees, every family shown, the selected one emphasized 2026-10-08 19:44:20 +02:00
Codeman maintainer b8dbef6241 Merge #532: each CLI's logo in the Run menus instead of a colour dot 2026-10-08 19:44:16 +02:00
Codeman maintainer f01f54e614 Merge #561: tile grid, up to 6 live sessions side by side 2026-10-08 19:44:14 +02:00
Codeman maintainer 6fa807c2c3 Merge #560: TerminalTile, a reusable live terminal pane (foundation for the tile grid) 2026-10-08 19:44:14 +02:00
JD 17bc2f02e9 test(remote-wake): stop pinning the readiness budget to the millisecond
ensureAwake() hands the readiness poll the caller's budget minus the wake step's own elapsed time, so on a busy runner the poll gets 39999 ms and the two tests that expected exactly REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS flaked (seen in CI on #550 and in a local gate run). Both now check the value sits within a second under the budget, the shape the host-scoped wake test already uses, which still fails if the 90 s session default leaks through.
2026-10-08 10:39:19 -04:00
JD 354c4641a9 fix(session-options): keep the external flag when App Settings resyncs the CLI catalog
_syncCliLaunchCatalog() rebuilt window.__codemanCliCatalog from /api/clis rows, which carry no capabilities, so after App Settings loaded the CLI list isExternalCliSession('claude') fell back to the kind check and Respawn and Ralph disappeared again until a reload. The rebuild now carries external over from the served catalog; a newly created custom CLI has no previous entry and falls back to kind, which is right since custom entries are external. Tests pin the resync and the openSessionOptions() call site, and the /api/clis comment names the page catalog as the deliberate capabilities exception.
2026-10-08 10:05:57 -04:00
RandalixandClaude Opus 5.5 bb4e7943c5 test: bind the port-sharing test servers to ephemeral ports; guard new fixed ports
Four ports were shared by two files each — 3162 (qr-auth / auth-security), 3170
(multiuser-auth / routes/ws-routes), 3230 and 3231 (cod54-hook-event-auth /
routes/voice-routes). Files run serially (`fileParallelism: false`), so the pairs
never met inside one run; they collide between two runs on one host, or with
anything else holding the port. All six files now bind port 0 and read the
number back (`boundPort` for WebServer, `server.address()` after each listen for
the raw Fastify / ws servers).

test/test-ports-guard.test.ts fails on a WebServer built under test/ whose port
argument is not the literal 0 — `new WebServer(…)`, a subclass, or a destructured
alias (`{ WebServer: T }`, as quick-start.test.ts does) — outside a legacy list of
the 43 files that construct one with a non-zero port today; the follow-up sweep
converts them. A converted file cannot stay listed. What it does not cover (helper
parameters, `import { WebServer as X }`, raw listen sites) is written down in it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:56:42 +02:00
RandalixandClaude Opus 5.5 03629c966e fix(server): report the port actually bound, so new WebServer(0) is usable
`port: 0` already bound an ephemeral port at the socket level, but `this.port`
stayed 0: the banner printed `:0`, CODEMAN_API_URL pointed panes at `:0`, the
docker bridge listener and the unauthenticated-bind warnings read 0, and the
route context's `port` was a by-value snapshot taken in setupRoutes(), before
listen() runs, so `tunnelManager.start(ctx.port, …)` would have been handed 0.

- After `app.listen()`, `this.port` takes the number from
  `this.app.server.address()` (string/null addresses are left alone).
- The route context exposes `port` as a getter, and the cron routes get only
  the `cron` their CronPort declares instead of a spread copy of the context.
- `get boundPort()`: the readonly accessor tests use instead of a private field.

test/webserver-bound-port.test.ts compares each reader against the socket's own
`address().port`; all three tests fail with only the write-back removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:56:41 +02:00
RandalixandClaude Opus 5.5 5ba729fcbb fix(terminal): strip opencode's mouse DECSETs so a drag selects text again
opencode's TUI enables mouse tracking. tmux runs with `mouse off`, so it passes
the PANE's DECSETs straight through to the tmux client, and the browser's xterm
obeyed them: `mouseTrackingMode` flipped to 'any' (measured 62 none / 18 any over
16s) and xterm then reported DRAGS to the TUI instead of selecting locally.

In that state marking text produced no selection at all, so copy-on-select
silently did nothing (5/5 dead drags while `any`), and the obvious fallback —
Ctrl+C — is opencode's `app_exit`, which ended the session. Both were hit here.

opencode needs the middle strip: alt-screen toggles AND mouse DECSETs, but NOT
`3J` (a TUI is not a `clear` consumer). That is `altScreen: 'strip-mux-and-mouse'`
+ `isMuxMouseStripMode`, applied to the live stream (session.ts) and the replay
of a stored buffer, now the exported `stripReplayBuffer()` (session-routes.ts).

The browser's mouse-report gate keeps no mode list any more:
`_shouldReportMouseToCli()` reads only `cliMouseTracking`. The server sets that
flag solely in the mouse-strip branch (`_recordStrippedMouseMode`, one caller),
so it can only be true for a mode whose DECSETs are stripped, and whichever modes
the registry strips, the browser follows. Clicks still reach opencode through the
hand-encoded SGR tap it gates.

The `altScreen` JSDoc gets the decision table its three independent choices need
(alt-screen / `3J` / mouse DECSETs), written from the predicates, including that
`preserve` and `strip-mux-only` take the same runtime row. The table is pinned for
every stock CLI, with and without tmux, on both the live strip and the replay
strip, plus the published flag (test/claude-scrollback-strip.test.ts), so the two
halves cannot drift and a mis-ordered replay branch fails.

Docs and comments that still said opencode keeps its mouse reporting or gets the
narrow strip are updated (CLAUDE.md, architecture-invariants, scrollback and
copy-shortcut plans, session.ts, terminal-ui.js).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:55:02 +02:00
Randalix 5a0018fc86 fix(terminal): page the CLI transcript for opencode's hollow local buffer
opencode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
`alternate_on=1`, `history_size=0`), so tmux keeps no history for the pane and
the browser's normal buffer never grows past one screen (`baseY === 0`). The
plain wheel therefore scrolled a buffer with nothing in it — dead in every
opencode tab, on desktop and touch alike.

opencode is not a forwarding candidate: it IGNORES SGR wheel reports (six
`\x1b[<64;…M` reports against an idle pane left the capture byte-identical),
but it does page its own transcript on PageUp/PageDown (`messages_page_up/down`,
verified on the same pane). The hollow-buffer rescue already sends exactly those
keys — it was just gated to `claude`. Widen the gate to opencode so the wheel
and touch gestures reach the CLI's own transcript instead of a no-op.

Every other mode stays out: shell/pi own real terminal scrollback, and
codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified
(docs/scrollback-fix-plan.md).

Test: test/terminal-scroll-routing.test.ts — new opencode case (Red before the
fix, Green after); the "real local scrollback is untouched" case now also pins
antigravity as not-paged.
2026-10-08 14:28:59 +02:00
JD 9230b53ccd docs(wiki): say npm test is the CI gate, as CONTRIBUTING.md does
The wiki's Contributing page still warned against bare npm test and pointed at test:ci, from before 947ff6f6 made npm test the CI gate and gave the browser, mobile and perf suites their own runners. It now matches .github/CONTRIBUTING.md and CLAUDE.md.
2026-10-08 00:35:36 -04:00
JD 66c8fef97f chore(uploads): remove the broken upload page and deprecate /api/screenshots
The tunnel Upload URL page (upload.html) has been broken since the response envelope landed in 458fb81c: it reads j.filename and j.files while the server answers { success, data: { filename } } and { success, data: { files } }, so every upload reported "Saved: undefined" and the recent list stayed empty. Nothing else reads ~/.codeman/screenshots/, and handing a file to an agent goes through POST /api/sessions/:id/paste-image into the session's own workspace, so the page, its Settings row, the suffix branch of the tunnel row helper (now folded into its one caller) and the Upload URL i18n key go.

The three /api/screenshots routes keep working unchanged and log one deprecation warning per process on first use, naming paste-image as the replacement. Per docs/versioning-policy.md they are removed in a later MAJOR, after at least one MINOR release that carries the warning; the docs, CLAUDE.md and the multi-user plan say so.

Static caching: with upload.html gone no HTML is served by @fastify/static any more (every page has its own no-cache route; on a built tree only the precompressed index.html.gz artifact is reachable, as application/gzip, and nothing requests it). The .html branch of setHeaders was therefore dead and goes with the test that fetched upload.html to reach it; a comment now says a new static HTML page needs its own route. The index.html no-cache assertion on the route stays.
2026-10-08 00:24:55 -04:00
JD 45492a3013 fix(session-options): keep Respawn and Ralph visible for Claude sessions
Since the CLI registry gave claude kind 'agent' (#476), isExternalCliRunMode() reads claude as an external CLI, so Session Options opened every Claude session on Summary and hid the Respawn and Ralph tabs and every Claude-only control (auto-resume, the respawn loop). The browser catalog now carries the registry's capabilities.external, the flag the server's isExternalCliMode() already reads, and Session Options asks that instead. run() keeps isExternalCliRunMode(): choosing a launch path is a different question, and custom agents rely on it.
2026-10-08 00:15:50 -04:00
shenlvkang-collab c30128d3f0 fix(codex): limit launch defaults to local sessions 2026-10-07 17:48:46 +08:00
shenlvkang-collab fc6e911888 style(codex): format launch default translations 2026-10-07 17:42:09 +08:00
shenlvkang-collab 16e44aa1d1 feat(codex): add synced model and reasoning defaults 2026-10-07 17:34:25 +08:00
Codeman maintainer 107e87e457 feat(tabs): draw every lineage family, emphasize the selected tab's
Drawing only the selected tab's family hid every other connection until you
clicked into one. All families are drawn again (still as routed trees); the
selected tab's families (what it spawned, and the family it was spawned into)
get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of
0.75, a larger end dot, and drawn last so nothing covers them.

Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never
the selection, so a family's lines never move when you click a tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:35:42 +02:00
Codeman maintainer 09daf0fe49 feat(tabs): draw lineage lines as one tree for the selected tab's family
Lineage lines used to draw one glowing dashed bezier per parent/child pair,
hanging below the tab strip, for every family at once. With a parent on row 3
of a wrapped strip and ten children below it, the curves crossed every lower
row's tab names and ran through the terminal text.

- One rounded orthogonal tree per spawning tab: every route starts at the
  parent, so siblings share a trunk. 1.5px, solid, 1px dark outline; only a
  working child's branch is dashed.
- Routes run only through the gaps between tab rows, joined by a spine left of
  every row (computeLineageTree/computeLineageRows in constants.js), so they
  never cross a tab or reach the terminal. The vertical rail gets the same tree
  on its existing left track.
- Only the selected tab's family is drawn (what it spawned, plus its parent and
  siblings). Selection redraws, and the strip's size transitionend redraws once
  more, since the active tab widens for ~150ms after the selection redraw.
- The routing room is reserved by .session-tabs.lineage-tree, keyed on whether
  any lineage exists, never on the selection, so a tab switch never resizes the
  header or the PTY.
- Colours stay per spawning tab, now claimed in strip order for every family so
  selection order never decides who gets the skin blue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 00:03:37 +02:00
DevvynandClaude Sonnet 5.5 3ae22f64a4 feat(git-status): configurable max repositories and git timeout; keep unreadable repos listed
Settings (per device): Git status: max repositories (1-50, default 12) and git timeout (5-120 s, default 30, was a fixed 10). Both go to /git-status and /git-diff as maxRepos / timeout query parameters, clamped server-side (an empty value means the default). A repository whose git status fails stays in the list with the reason instead of being dropped silently, shows as '? N' in the indicator, and the truncation line now names the limit and the setting. The discovery cache is keyed by the limit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 20:04:45 +08:00
DevvynandClaude Sonnet 5.5 90649fc363 fix(ui): make the Run dropdown scrollable
The menu opens upward (bottom: 100%) with no max-height or overflow, so with the stock CLIs plus custom endpoint entries it was taller than the room above the toolbar and its top was off-screen and unreachable, worst on phones. Cap it to the space between the header and the toolbar (dvh, vh fallback), scroll inside it with overscroll containment, and stop its overflow:auto children (history, saved URLs) being squashed.

Tests: a CI-gate source guard and a real mobile-browser test (touch swipe reaches the last entry), which fails without the CSS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 19:06:39 +08:00
DevvynandClaude Sonnet 5.5 f21ab39a89 fix(terminal): settle the edit-sync diff at the next keydown so Enter cannot erase the line (#541 review)
A pending 229 edit plus Enter in one page task: xterm clears the textarea for CR before the edit timer runs, so the timer diffed the whole line against '' and sent one DEL per character ahead of the submitted line. The pending diff is now applied synchronously from handleKeyEvent, before flushPending() (which keeps the orphan candidate from sending the character twice).

Tests: unit (3) and browser (4, local echo on and off, plain last character and autocorrect), each verified to fail without the settle call. xterm-private-api guard now names the CompositionHelper fields this depends on and checks the shipped bundle; CLAUDE.md notes the edit-based 229 diff.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 17:56:16 +08:00
DevvynandClaude Sonnet 5.5 0c71b753ef fix(terminal): stop Android autocorrect duplicating the typed line
xterm diffs the helper textarea with newValue.replace(oldValue, ''), which only works when the keyboard appended. SwiftKey/Gboard autocorrect on space deletes a word and inserts the corrected one, so xterm sent the whole value and then the inserted text again (testing the peompt + space became 'testing the peompttesting the prompt rompt '), and a multi-character delete was one DEL. The keyCode-229 controller now swaps in an edit-based diff against what was already sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 15:51:26 +08:00
Codeman maintainer 310f20b288 feat(header): rings for CPU and MEM in the Compact style (#426)
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:44:20 +02:00
Codeman maintainer 55cafc282f feat(tabs): no label on the idle row (#426)
Idle is the default state of a tab, so naming it only adds noise. The
idle group is now quiet: its heading element stays (it anchors the row's
order band and, as the first row, holds its place beside the brand) but
draws no label or count. Needs you, Waiting and Working are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 06:29:40 +02:00
Codeman maintainer bb5fd5ee97 fix(tabs): state rows start under the brand, labels left-aligned (#426)
In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 06:25:26 +02:00
Codeman maintainer bebf0db792 fix(header,tabs): tile-shaped header buttons beside the clustered stats, a bolder active ledger cell, one ledger row height (#426)
Header icon buttons next to the Tiles take the tile box (36px, border,
fill, 18px glyph) and turn into round chips beside the Compact pills;
the gear's quarter turn moves onto the glyph. The ledger's active cell
gets an inset second pixel and an accent bar (the skin's 1px !important
border was too quiet in a grid of look-alike cells), and every ledger
cell stretches to a 30px minimum so rows stay one height.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:02 +02:00
Codeman maintainer e3dfbf6591 feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' |
'ledger' | 'classic', default 'state'), the first row of App Settings,
Appearance, Tabs, so the old and the new strip are one choice apart.

- By case (option A): each case's tabs sit in one .tab-cluster box in
  first-appearance order, labelled with the case and its count, coloured
  by a stable hash into the session palette. Membership is
  _mobileOverviewCaseFor(), the home screens' own match. Inside a box with
  company a generated w75-api-gateway reads w75; the -<case> stays in the
  DOM in a .tab-name-case span only .tabs-clusters hides. The incremental
  render path rebuilds only when the cluster structure key changes. The
  rail and the sidebar get a labelled section per case; phones dissolve
  the boxes into the chip row. Drag stays inside one box.
- Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an
  auto-fill column grid of equal cells in mono type with a 3px status
  bar. Its markup is identical to classic's.
- State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the
  by-state groups so needs you can be the bottom row.
- By state: the label column is measured to the widest label on screen
  and the labels are right-aligned in it, instead of a fixed 92px gutter
  that left short labels far from their tabs.
- Tiles: a three-row grid (label, value, bar) with pixel line-heights in
  the bundled JetBrains Mono, 36px like the header. The bar used to lie
  over a fixed 28px tile, and a taller system mono (SF Mono) pushed the
  value into it. The WS tile's grid moved onto an inner .connection-tile
  span because JS writes the indicator's display inline. Compact uses the
  same font and a matched WS size.

Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed
order in test/tab-triage.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:02 +02:00
Codeman maintainer 9b0d305223 feat(tabs,header): group tabs by state and add header stats styles (#426)
Two directions from Discussion #426, each a per-device setting and the
new default.

Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.

Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.

Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:08:01 +02:00
Aamer Akhter aad9c248dc fix(preview): budget every start tag ExcelJS will parse
Admission counted cells, rows, merges and styles only in worksheets,
styles.xml and workbook.xml, so the objects ExcelJS builds per element
elsewhere (shared-string runs, fonts, fills, borders, comments, drawings,
VML, tables) were bounded only by the inflated-byte caps, and empty stored
deflate blocks pad a stream past the ratio cap. createXmlCounter now counts
every start tag in every part except the pure-bytes xl/media/<name>.<ext>
entries into counts.elements and refuses above LIMITS.maxElements
(2,000,000) as element-limit. Parts that read no attributes carry only a
trailing '<' between chunks, so long text or binary is never taken for an
oversized tag.

Very tall sheets now scale only the scroll position: the scroll range maps
onto the sheet's whole range and the tile is laid out at real row heights
and column widths, with spans clipped at the spacer, instead of dividing
every cell and heading by the scale.
2026-10-05 21:30:29 -04:00
DevvynandClaude Sonnet 5.5 4c2fdd5f5a test: make opencode-resize and split-pane browser tests environment-proof
- opencode-resize: record WebSocket resize frames as well as POST /resize,
  seed the needsRefresh test with real PTY output, skip the OpenCode close
  modal test when opencode is not installed
- split-pane: send the marker with useMux:false (plain prompts otherwise go
  through tmux send-keys, which test mode does not have) and retry past
  Codeman's own post-create clear

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-06 09:20:55 +08:00
DevvynandClaude Sonnet 5.5 ab7e89873f ci: nightly browser suite; fix stale session-id extraction in browser tests
WIP: the suite still has failures on a clean master that are not fixed here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 09:20:55 +08:00
Aamer Akhter c36be7bb94 Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts:
#	CLAUDE.md
#	src/web/public/styles.css
2026-10-05 21:19:54 -04:00
Aamer Akhter 51b6be3e7a fix(preview): bound rich-text run walks, fold format notices, match Excel number display
- richTextPrefix visits at most maxCellTextChars + 1 runs. An empty run adds
  no text, so a length check alone walked every run of a shared string for
  every cell referencing it, on every tile.
- Two or more unsupported number format warnings in a tile fold into one
  "N unsupported number formats" entry, and the notice bar has a max-height
  and scrolls.
- General-format and unsupported-format numbers render at 15 significant
  digits, as Excel does (0.1+0.2 shows 0.3).
- TIME_FORMAT accepts a trailing AM/PM, so h:mm AM/PM renders as 2:30 PM
  instead of falling back to a date.
- SPREADSHEET_ASSET_VERSION refreshed.
2026-10-05 09:51:04 -04:00
Aamer Akhter 0ae5ce017a fix(preview): cap cell text, bound merges workbook-wide, refuse runaway number formats 2026-10-04 20:15:09 -04:00
Aamer Akhter ab96e74e69 Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts:
#	CLAUDE.md
#	config/test-suites.ts
2026-10-04 20:08:01 -04:00
Codeman maintainer d00229ee29 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>
2026-10-04 23:52:31 +02:00
Aamer Akhter 7d3e27fb6d fix(preview): index rows and cells by their present keys, cap theme size
ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only
cell sits in XFD is a dictionary-mode array that `eachCell` and
`hasValues` (behind `eachRow`) walk to index 16,384. The worker walked
each row four times at load and once per tile, so a small file of
far-column rows took seconds to load and to tile.

`worksheetMetadata` now builds each sheet's row and cell index from
`Object.keys(sheet._rows)` and `Object.keys(row._cells)`, sorted
numerically, skipping falsy and Null-type cells exactly as
`eachCell({ includeEmpty: false })` does and keeping a row only when it
holds one such cell (`hasValues`). Styles, extent and row heights come
from that one pass and merges from `sheet._merges`; `sendTile` reads
each row's cells from the index.

`parseThemePalette` returns the default palette for a theme above
64 * 1024 characters, since its patterns are quadratic on unclosed tags.
2026-10-03 17:21:41 -04:00
Aamer Akhter c1811fd716 fix(preview): bound row and sheet indices before ExcelJS, reuse merges per tile, cap format decimals
ExcelJS stores a row at _rows[r - 1] and a sheet at _worksheets[sheetId], and
walks or slices those arrays up to the largest index, so the index a row or
sheet claims is a cost of its own. Admission now reads each <row> tag's
attributes in order and refuses an r outside 1-1048576 (absent r is fine), and
a counter for the resolved xl/workbook.xml reads every <sheet> tag and refuses
one that does not parse or whose sheetId is not plain digits up to
LIMITS.maxSheetId (65535).

sendTile no longer reads sheet.model, which rebuilt every row and cell model on
each tile: the merges read in worksheetMetadata are kept in mergesById next to
populatedRowsById, replaced on load and cleared on dispose.

Number formats cap decimals at 30, as Excel does; toLocaleString throws a
RangeError above 100 and the whole grid was replaced by the error.

Docs: CLAUDE.md and architecture-invariants describe both bounds and the merge
reuse. SPREADSHEET_ASSET_VERSION is recomputed for the edited worker and core.
2026-10-03 12:33:35 -04:00
Aamer Akhter 2d0ffb71aa fix(preview): normalize entry names the way ExcelJS sees them, skip defined names, pin fflate 0.8.3
Admission checked ZIP entry names as stored, but JSZip (inside ExcelJS)
resolves `.`, `..` and empty segments on load, and ExcelJS strips one
leading `/` and matches worksheets with an unanchored pattern. Names like
`/xl/worksheets/sheet1.xml` or `xl/worksheets/sheet1.xml.x` skipped every
counter. Admission now computes the name ExcelJS will see for each entry,
refuses two entries that resolve to the same name, keys the rebuilt
archive on it, and picks the worksheet/styles counters from it.

ExcelJS's DefinedNames model setter expands every range into one object
per cell. The preview never shows defined names, so the worker stubs
`_definedNames.model` before load.

Pin fflate to 0.8.3 (GHSA-px8p-9vwx-vf98) and refresh
SPREADSHEET_ASSET_VERSION.
2026-10-03 08:21:54 -04:00
Aamer Akhter d65ee4f89d fix(preview): parse admission tag attributes in order and count empty rows
XML allows a raw `>` and the other quote character inside an attribute
value, so a first-match search for `ref=`/`max=` could be fed a fake
value from an earlier attribute while saxes read the real one:

- <mergeCell>/<col> attributes are now read in order from the tag name
  with a sticky regex that consumes each quoted value whole. A tag whose
  attributes do not parse up to `>`, or that repeats a name, is refused.
- The chunk carry keeps everything from the last `<`, which can never
  appear inside an attribute value, instead of comparing against the
  last `>`.

ExcelJS keeps a Row object for every <row>, cells or not, so <row> tags
now count against per-sheet (100k) and total (250k) caps with their own
row-limit code, and the worker passes maxRows as a per-sheet backstop.

styles.xml counts every <xf> without tracking which list it sits in,
since a </cellXfs> inside a comment desynced that state.
2026-10-01 21:39:26 -04:00
Aamer Akhter e85b4f34dd Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts:
#	src/web/public/constants.js
2026-10-01 21:35:28 -04:00
Aamer Akhter bfab172608 fix(preview): bound what ExcelJS expands during XLSX admission
ExcelJS 4.4.0 expands three constructs into one object per cell or column
at load time, so a few KB admitted as one cell could cost a gigabyte:

- a <mergeCell> now costs its full area against the per-sheet and total
  cell caps, and a ref that does not parse is refused
- a <col> whose min or max is past 16384 is refused
- the worker loads with ignoreNodes: ['dataValidations']; the preview
  never shows validations, and a whole-column dropdown took 5 s

The XML counter now scans up to the last complete tag and carries the
rest, so a merge or col tag cut by an inflate-chunk edge is read whole.
A central-directory compressedSize that runs past the file is refused,
since the ratio cap divides by it.

The renderer and core axis offsets use prefix sums with a binary search
instead of walking every override per call.
2026-10-01 09:18:35 -04:00
Aamer Akhter 4edb7b8f80 fix(preview): address review of the XLSX preview
- Normalize the value shapes ExcelJS loads before formatting: Date cells are
  formatted from their serial (UTC), so they no longer render as a local-time
  string a day early at negative UTC offsets; rich text joins its runs,
  hyperlinks show their text, error values show the error, and formula and
  shared-formula results (including error results) recurse. Excel serials are
  rounded to whole milliseconds so 00:05 no longer shows as 00:04.
- sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a
  truncated tile with a warning instead of failing the whole preview.
- ExcelJS now parses a STORE-only archive rebuilt from exactly the entries
  admitXlsx() inflated and counted, never the fetched bytes. Admission walks
  local headers while JSZip reads the central directory, so overlapping
  entries could show the two readers different sheets. A duplicate local
  entry name is refused. The theme fallback reads the admitted entry too.
- Row and column headings take their size from the same axis math as cells.
- Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION
  rules in architecture-invariants, and list .xlsx in the attachments panel
  help and the `codeman attach` error text (built from the accepted list).
2026-09-27 07:55:29 -04:00
Aamer Akhter 0b122e2c76 feat(preview): render XLSX spreadsheets in the file-preview overlay
xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
2026-09-26 23:13:21 -04:00
shenlvkang-collabandClaude Opus 5 0720526b64 feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper
An Android WebView wrapper has no browser pop-ups, so a foldable could not
show two sessions, or a session and a file, side by side. A wrapper that can
open a window of its own now exposes window.CodemanHost.openWindow(url);
detachSession, detachFilePreview and openWebviewExternal hand their URL to
it, mobile.css keeps the pop-out icon under html.host-windows, and a solo
window closes and raises itself through the host when it offers the calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:19:47 +08:00
Codeman maintainer cbb1435a46 refactor(toolbar): one instance stepper, not two
The desktop toolbar carried two identical "minus 1 plus" instance steppers side
by side, one after Run and one after Run Shell. The second (#shellCount) is
gone for a cleaner strip.

Run Shell keeps the capability: both launch paths now read the remaining
#tabCount control through _toolbarInstanceCount(), which also makes an absent
stepper read as 1 instead of throwing. That matters because the group is
display:none on phones and tablets, and because the Run dropdown's
Terminal / Shell entry routes through runShell() too, where the visible counter
was previously ignored.

Desktop only: both steppers were already hidden under 1024px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:50:09 +02:00
Codeman maintainer 8e4606c57b feat(mobile): search the case picker
The phone case sheet listed every case with no way to narrow it, while the
desktop toolbar combobox has filtered for a while. The sheet now carries a
search field that runs the same matcher (filterCasePickerOptions), so both
pickers answer a query identically: every term has to appear in the option's
searchText, which already carries the name, the rendered label, the path and
the remote/docker fields.

Details worth keeping:

- The filter resets on every open. The sheet is a one-shot picker, and a
  leftover query would present a truncated list as the whole one.
- No autofocus. Focusing raises the keyboard over a sheet anchored to the
  bottom of the screen, so the user asks for it.
- The sheet is a third position:fixed bottom-anchored surface, so it joins the
  toolbar and the accessory bar in KeyboardHandler's keyboard lift. iOS does
  not shrink the layout viewport, so an unlifted sheet would sit behind the
  keyboard with its own search box out of sight. resetLayout() clears the
  offset unscoped, or a sheet closed while the keyboard was up would slide in
  already displaced next time.
- Enter takes a single remaining match and otherwise just dismisses the
  keyboard; Escape drops the filter before it closes the sheet.
- No match renders an empty state rather than a blank sheet.
- The clear button needs an explicit [hidden] rule: the UA's display:none is
  specificity (0,0,0) and loses to the button's own display:flex.
- The input drops the global input:focus-visible ring, which inside an already
  bordered row drew a second border a few pixels in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 00:49:56 +02:00
170 changed files with 21954 additions and 2139 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.35.0",
"version": "1.40.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+3 -1
View File
@@ -55,7 +55,9 @@ npm run test:all # literally everything, environmental failures included
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
+50
View File
@@ -0,0 +1,50 @@
name: Browser suite
# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
# so a browser-only regression can merge green. This job runs that suite on a schedule and on
# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
# push or a PR.
#
# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
on:
schedule:
- cron: '29 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
browser:
name: Playwright browser suite
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install tmux
run: |
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
- name: Install chromium
run: npx playwright install --with-deps chromium
- name: Run the browser suite
run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
+2
View File
@@ -219,6 +219,8 @@ jobs:
run: npx vitest run
working-directory: packages/xterm-zerolag-input
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
# that job is informational and never gates a push or a PR.
# Note: three suites are excluded from CI, each with its own local runner:
# npm run test:browser Playwright + chromium (+ a live server, and a real
# codex binary for codex-predictive-echo)
-1
View File
@@ -24,7 +24,6 @@ src/web/public/settings-ui.js
src/web/public/sw.js
src/web/public/terminal-ui.js
src/web/public/voice-input.js
src/web/public/upload.html
scripts/remotion/
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
+1 -1
View File
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
+51
View File
@@ -1,5 +1,56 @@
# aicodeman
## 1.40.0
### Minor Changes
- ### Thanks
- @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
- @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
- @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
- @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
- @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
**The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
**Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
**A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
**CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
**Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
**Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
**A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
**Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
**Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
**Spreadsheets in the File Viewer (#502).** `.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
**Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
**Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
**Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
**Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
**Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
**Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
**For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
**A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
**Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
## 1.35.0
### Minor Changes
+19 -15
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -385,6 +385,14 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
### Tile Grid
<p align="center">
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
</p>
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
### Persistent Sessions
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
+8
View File
@@ -386,6 +386,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
### 平铺网格
<p align="center">
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
</p>
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话,网格会被记住,下次直接恢复。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
### 持久化会话
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
+3
View File
@@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/terminal-tile-scroll.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
@@ -41,7 +42,9 @@ export const BROWSER_TEST_GLOBS = [
'test/git-status.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
];
/**
+11 -2
View File
@@ -45,6 +45,13 @@ payload return `{ "success": true, "data": {} }`.
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
>
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
> use. They are removed in a later MAJOR, after at least one MINOR release that
> carries this warning (see `docs/versioning-policy.md`). To hand
> a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into
> that session's workspace.
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
> are the only JSON endpoints that deliberately **hold the connection open**, for up
@@ -764,11 +771,13 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
**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.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). 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 }`:
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
- `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`.
File diff suppressed because one or more lines are too long
+22 -15
View File
@@ -71,17 +71,21 @@ We tested three browser automation frameworks against the Codeman web UI:
## Test File Structure
### Port Allocation
### Ports
| Port Range | Test File |
|------------|-----------|
| 3150-3153 | browser-e2e.test.ts (existing) |
| 3154 | file-link-click.test.ts |
| 3155 | browser-playwright.test.ts |
| 3156 | browser-puppeteer.test.ts |
| 3157 | browser-agent.test.ts |
| 3158-3160 | browser-comparison.test.ts |
| 3180-3182 | scripts/browser-comparison.mjs |
A test that starts a server binds an ephemeral port, never a fixed one:
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
excepted).
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
convention in `test/mobile/README.md` for now.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
fixed ports 3180-3182.
### File Purposes
@@ -106,7 +110,7 @@ const browser = await chromium.launch({
});
const page = await browser.newPage();
await page.goto('http://localhost:3000');
await page.goto(BASE_URL);
// Auto-waiting selectors
await page.click('.btn-claude');
@@ -140,7 +144,7 @@ const browser = await puppeteer.launch({
});
const page = await browser.newPage();
await page.goto('http://localhost:3000');
await page.goto(BASE_URL);
// Manual waiting often needed
await page.click('.btn-claude');
@@ -186,7 +190,7 @@ function agentBrowserJson<T>(cmd: string): T {
}
// Usage
agentBrowser('open http://localhost:3000');
agentBrowser(`open ${BASE_URL}`);
agentBrowser('click ".btn-claude"');
const title = agentBrowserJson<{title: string}>('get title');
@@ -246,11 +250,14 @@ npx playwright install chromium
### 4. Wait for Server Startup
```typescript
const server = new WebServer(PORT);
const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
await server.start();
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize
const BASE_URL = `http://localhost:${server.boundPort}`;
```
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
other snippets on this page is this one.
### 5. Clean Up Sessions
Track created sessions for cleanup:
+10 -6
View File
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // tab badge, e.g. 'CX'
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
@@ -51,6 +51,9 @@ interface CliEntry {
// 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)
// .launchDefaults?: { [launchParam]: settingsKey }
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -63,7 +66,7 @@ Five capability fields carry a regular expression an override file can set: `dis
`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.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 8, 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), codex's ` <model> <effort> · ` footer (its last row, or the row above codex 0.162's indented hint row, so a two-row window), and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. 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.
@@ -81,10 +84,11 @@ Two CLIs declare such a row today, and they put it in different places. Claude w
chip on the last row of the screen, so it keeps the default one-row window and anchors on
the `·` its footer joins items with. Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
puts the row third from the bottom once the status line and the composer are counted, so its
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
against live panes rather than read out of a binary, which is the standard for adding a
third.
puts the row third from the bottom once the status line and the composer are counted, and
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
the status line (it disappears while a prompt is typed). So its entry declares
`watchingLines: 4` and matches that row end to end. Both were measured against live panes
rather than read out of a binary, which is the standard for adding a third.
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
to wait for workers the CLI will resume from by itself. When background agents or an
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+2 -2
View File
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| `GET /api/away-digest` | Aggregate only owned sessions/events |
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
+7 -3
View File
@@ -163,9 +163,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
## Known gaps
- **No idle/completion hook.** Idle detection falls back to output-stabilization
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
POSTing to `/api/hook-event` would be the highest-value follow-up.
- **No idle/completion hook.** Idle detection reads the screen instead: the
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
Without it an omp session that had started a turn never left `busy`. If omp ever
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
the highest-value follow-up.
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
+2
View File
@@ -920,6 +920,8 @@ const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
### Goal
Detect OpenCode's state from terminal output (idle, working, ready).
+7 -3
View File
@@ -223,9 +223,13 @@ command override instead.
## Known gaps
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
idle detection falls back to output-stabilization like the other external CLIs.
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
signal; a Codeman pi extension using it is the highest-value follow-up.
idle detection reads the screen: the registry entry's `workDetect` names pi's
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
in that rule while a turn runs (`── ⠏ Working ───`) as the working line, measured
on pi 1.1.0. Without it a pi session that had started a turn never left `busy`,
since pi never draws Claude's `❯`. Pi 0.84.0 shipped an `agent_settled` extension
event that is a genuine idle signal; a Codeman pi extension using it is still the
highest-value follow-up.
- **No response viewer.** Pi writes JSONL v3 session files under
`~/.pi/agent/sessions/`; nothing reads them yet.
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
+2 -2
View File
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
- 40ms SGR coalescing: never send per-event writes to the server.
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
## Testing (per repo rules)
+3 -2
View File
@@ -354,8 +354,9 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
routes, attachments are intentionally **cross‑workspace** — so the effective gate
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
realpath containment.
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
other text), not realpath containment.
Two registration paths, with **different trust**:
+9 -1
View File
@@ -92,7 +92,15 @@ view needs a wide viewport). So:
keyboard accessory bar. On a desktop, typing directly into an xterm
instance with no overlay is exactly how Codeman behaved before the local-
echo overlay existed for touch devices — normal, not degraded, for a
keyboard-and-mouse user.
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
which has gained three pieces of the primary pane: hollow-buffer wheel
paging (#555) and the desktop click report for a CLI with
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile, and its own keyCode-229 soft-keyboard controller
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
width gate is all that keeps a phone out, and a wide Android tablet clears
it. See that file's fileoverview.)
If this asymmetry actually bothers you in daily use, promoting Pane B to full
parity is a scoped v2 (extract the shared logic already once you have two
+3 -2
View File
@@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
### 2.6 Helpers that already exist and should be reused
@@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
@@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
+34 -10
View File
@@ -1,8 +1,8 @@
# Tile Grid: Design Spec
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`; PR 2 (the grid) implemented on `feat/tile-grid`, both local only. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Status**: Merged for the 1.40.0 release as #560 (the TerminalTile foundation) and #561 (the grid). Where the "As built" section below differs from this spec, As built is authoritative. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
**Author**: Claude (planning session with the maintainer), 2026-10-06
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktrees `claudeman-tiles`, `claudeman-tilegrid`)
**Branches**: developed as PR 1 `feat/terminal-tile` and PR 2 `feat/tile-grid` stacked on it, both merged
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
## As built: where PR 2 differs from this spec
@@ -20,14 +20,22 @@ or settled a question the spec left open. The invariants as built are in
- **`Ctrl+Shift+G` follows `showTileGridButton`** (decided by the owner, decision 6): with
the setting off the toggle chord is inert. A grid opened another
way (Ctrl/Cmd+click, a dropped tab, "Open group as tiles") keeps all its chords.
- **The Tiles button ships ON** (owner, 1.36.0 beta): `showTileGridButton` defaults to on
everywhere but handhelds (their defaults object keeps it off) and, since the 1.40.0 final
checkup, devices whose primary pointer is coarse (touch tablets, opt-in there), so the
chord is live by default too. An absent key resolves through the device defaults in both the button
(settings-ui.js) and the chord (`tileShortcutFor`), so they cannot disagree.
- **Dividers are grid tracks.** Each gap between columns and rows is its own 6px track (the
grid gap is 0) and every tile and every empty slot is placed explicitly in its cell
(`grid.cells`, see "Tiles move"). Fractions reset when the column or row count changes.
- **Zoom follows tmux.** Moving focus to another tile restores the grid; an automatic zoom
(window too small for the minimum tile) follows focus instead.
- **Tile loads are bounded** (`boundedLoad`), carry a fetch deadline covering the body (Pane
B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps
its last frame.
B too), and a refresh fetches at its turn in the queue: the tile keeps its last frame
through its wait and its own round trip, and is reset with the queued in-stream `\x1bc`
only once the capture is in hand, right before the replay (never xterm's `clear()` before
the fetch). A failed, aborted or empty fetch writes nothing and resets nothing: the tile
keeps its last frame and every held live frame.
- **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile.
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
first build's 12px glyphs read as tiny next to the name), the size of the app header's own
@@ -211,8 +219,10 @@ no `+`, owner decision 9).
## Non-goals (v1)
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
- Phones. The grid is gated on width alone at 1180 px like the split
(`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`); a
wide tablet, or a large foldable unfolded in landscape, can reach it (see the
keyboard exception below).
- More than 9 tiles.
- WebGL rendering inside tiles (see "Rendering" below).
- Full parity with the main terminal's touch and IME features: local-echo
@@ -220,7 +230,11 @@ no `+`, owner decision 9).
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
history" banner. These exist for touch devices or rare cases; a desktop
keyboard user types straight into xterm, which is how Codeman behaved before
those features existed.
those features existed. (One exception, since the 1180 px gate is width
only and a wide Android tablet clears it: every tile wires the main
terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js,
so an Android autocorrect is sent as an edit rather than a duplicated line,
#541, and a character committed with Enter is not lost, #441.)
- Server-side persistence of grids (named presets per owner).
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
@@ -398,7 +412,8 @@ against the live list without rebuilding tiles that are still alive.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
settings PUT, NOT in `SettingsUpdateSchema`), default ON on desktop and OFF on
handhelds (specified OFF; superseded, see "As built"). Independent of
`showSplitButton`, which is unchanged; a desk can show both buttons.
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
@@ -993,8 +1008,17 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile.
4. Tile parity extras: a "Load full history" action inside a tile. (Done
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
the primary pane's #555 route, hand-reports a plain click while its session
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
renderer as SGR wheel reports from its own cells
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
since xterm turns it into a horizontal no-op off macOS.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
+4 -2
View File
@@ -57,8 +57,10 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
programmatically is not supported (there is no stable library entry point).
3. **Experimental / opt-in features**, regardless of the app's version:
Gesture Control (beta), Agent Teams
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
in the UI or docs. These may change or be removed at any time.
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
anything labeled experimental in the UI or docs. These may change or be
removed at any time.
## Deprecation policy
+27 -2
View File
@@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek yes; others no |
@@ -121,10 +121,24 @@ Renders its own TUI, so Codeman treats readiness as output stabilization rather
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
environment is injected through socket-scoped `tmux setenv` rather than the command line.
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
### Codex
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
controls. Enter a model ID supported by your Codex provider; available reasoning levels
depend on the model and CLI version. Empty defaults use Codex's own configuration.
The defaults apply to local Codex sessions started from the Run menu, from Resume, and through
`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them.
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
Changing a default affects new sessions and does not edit Codex configuration files.
Two behaviours that are deliberate and worth knowing:
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
@@ -148,6 +162,11 @@ needs `GOOGLE_CLOUD_PROJECT`, `GOOGLE_APPLICATION_CREDENTIALS`, and
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
only the CLI you spawned yourself.
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
working for good after its first turn.
### Antigravity
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
@@ -169,6 +188,10 @@ Pi needs the opposite instincts from every other CLI here.
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
once. They stay out.
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
as working for good.
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
@@ -222,7 +245,9 @@ documented default approval mode is `yolo`, so an OMP pane auto-approves tool us
flag from Codeman; change that in OMP's own config, not here.
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
same conversation with `--continue`.
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
an OMP session that had started a turn showed as working for good.
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
+13 -7
View File
@@ -43,8 +43,8 @@ npm run lint
npm run format:check
npm run check:frontend-syntax
npm run check:browser-excludes
npm test -- test/<file>.test.ts # one file, the normal way
npm run test:ci # the full CI sweep
npm test # the gate, exactly what CI runs
npm test -- test/<file>.test.ts # one file
```
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
@@ -53,13 +53,19 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
`pre-push` hook of your own is never overwritten.
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
suites that need a live server, Chromium, and environment-specific baselines; they hang or
fail on a normal machine. `test:ci` is the honest "run everything".
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot
provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at
3150 or above, and never 3000.
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
because that helper caches servers by port.
## Finding your way around
+2 -1
View File
@@ -15,7 +15,8 @@ Codeman-side configuration:
- Per-case toggles (Agent Teams, 1M Opus context).
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
Three ways to get one, all under **+** next to the case picker:
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
+1 -1
View File
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
## Lineage
A create request can name the session that spawned it, through a body field or a header, and
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
failing the spawn, because a cosmetic field must never break a worker.
+2
View File
@@ -62,7 +62,9 @@ codeman web # then open http://localhost:3000
| Page | What it answers |
| ------------------------------------------ | ---------------------------------------------------------- |
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
+1 -1
View File
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
enforces it.
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
- The desktop home tab rail, which needs a wide window.
- Lineage arcs, which are a desktop overlay.
- Lineage lines, which are a desktop overlay.
## Gotchas
+19 -8
View File
@@ -63,12 +63,19 @@ 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
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, 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.
window; off lists every file by its full path. **Git status: max repositories** (per device,
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
long one git command may run before that repository is reported as unreadable; raise it for
repositories on a slow network share.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
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. **Tiles** is desktop-only the same
@@ -88,10 +95,12 @@ every session or only the active tab.
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tab Layout | *Classic* (default): the single list as before. *By state*: a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. See [The Dashboard](The-Dashboard#tab-layouts). |
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
@@ -129,7 +138,9 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
@@ -154,7 +165,7 @@ 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. The **Diagnostics** group runs
Cloudflare tunnel controls including the tunnel URL. 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.
+63 -9
View File
@@ -15,7 +15,7 @@ page says so and names the setting.
| **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. |
| **Header, right** | Status chips and panel buttons, most of them off by default. |
| **Center** | The terminal for the active session, or the home screen when nothing is selected. |
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counters. |
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counter. |
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
## Session list layout
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it 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. **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. |
@@ -35,6 +35,40 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
per device, so a sidebar on your desktop does not force one onto your phone.
## Tab layouts
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
| Layout | What it does |
| ---------------------- | ------------------------------------------------------------------------------------ |
| **By state** | Groups the tabs by what each session needs from you (below). |
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
| **Classic** (default) | The single list in tab order, as before. |
**By state** groups the tabs like this, most urgent on top:
| Group | Who is in it |
| ------------- | ----------------------------------------------------------------------------------- |
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
| **Working** | A turn is running. |
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
In the header each group is a row with its name and count on the left (Idle, the quiet
default, carries no label); a group with more tabs than fit on one line continues on the
next line. **State Order → Needs you at the
bottom** turns the rows the other way up, so the needs-you row sits right above the
terminal. Empty groups are not shown. These are the same states the phone overview and the
desktop home rail use, and tabs move between groups on their own as their state changes.
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
within a group or box. On a phone the strip stays a single scrolling row in group order,
without labels or boxes. If you have named tab groups in the vertical rail, those take
precedence there.
## Session tabs
One tab per session, in your order, and that order syncs across your devices.
@@ -84,13 +118,18 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
### Lineage arcs
### Lineage lines
When one session spawns another (an agent starting a worker through the API), Codeman draws
a coloured arc under the strip connecting parent to child, with one colour per child. It is
how a fan-out of eight workers stays readable.
lines from the parent to each child, in the parent's colour, routed through the gaps between
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
a tab draws its own family thicker and brighter. A dashed branch means that child is
working. It is how a fan-out of eight workers stays readable.
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
While any tab has spawned another, the strip keeps a little extra room between rows for the
lines, so switching tabs never changes the header height.
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
skipped for tabs scrolled out of the strip.
## Header controls
@@ -102,7 +141,7 @@ The right side of the header. Almost all of these are off until you enable them
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
| Connection dot | Always on | SSE connection health. Green is connected. |
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
| CPU / MEM bars | On | Server resource use. |
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. |
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
@@ -118,10 +157,23 @@ 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). |
| Tiles | On, 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. |
### Header Stats Style
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
| Style | Look |
| -------------- | -------------------------------------------------------------------------------------- |
| **Tiles** | One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
| **Compact** | The default. Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
Hiding System Stats or Plan Usage still hides them in every style.
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
@@ -166,7 +218,9 @@ Worth knowing:
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally.
always local scrollback. OpenCode's wheel and swipes page its own conversation
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
too. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+5 -4
View File
@@ -9,9 +9,10 @@ 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`.
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
on desktops and laptops and off on phones and tablets, and the button only appears in a
window at least 1180px wide.
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
## Opening a grid
@@ -58,7 +59,7 @@ Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
| `●` | 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. |
| 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. OpenCode shows the model and its provider together (`Big Pickle OpenCode Zen`), exactly as its own composer does. |
| `⋯` | 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. |
+12 -6
View File
@@ -22,13 +22,18 @@ transcript: what it was asked to do, what it is doing, and what it returned.
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
eight workers looks like a stalled terminal for several minutes.
## Session lineage arcs
## Session lineage lines
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
child. That covers the other direction of fan-out: not subagents inside one session, but
whole sessions started by an agent through the API.
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
direction of fan-out: not subagents inside one session, but whole sessions started by an
agent through the API.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
The lines form one tree per spawning tab, in that tab's colour, and run only through the
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
dashed branch means that child is working.
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
for tabs scrolled out of view.
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
@@ -42,7 +47,8 @@ in the CLI's own environment:
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
```
and turn the per-case **Agent Teams** toggle on in the case settings gear.
and turn the per-case **Agent Teams** toggle on under **Case settings…** at the bottom of the case
picker (the gear beside the case button on a phone).
Codeman watches the team directory and matches teammates to the session leading them.
Teammates are in-process threads rather than separate CLI processes, so they show up as
+6 -2
View File
@@ -17,6 +17,7 @@ It renders what it can:
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
| PDF and Office documents | Converted for preview when a converter is available. |
| Anything else | Download. |
@@ -168,7 +169,7 @@ surface as an artifact attachment rather than a path you have to go and find.
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.
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
Click it for a draggable window, in the style of the File Viewer:
@@ -187,7 +188,10 @@ Click it for a draggable window, in the style of the File Viewer:
**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
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
when there are more). A repository git could not read, typically a timeout on a slow network
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
left out; the **git timeout** setting raises how long it waits. 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.
+1006 -2
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.35.0",
"version": "1.40.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",
@@ -129,6 +129,8 @@
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"pngjs": "^7.0.0",
+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.35.0",
"version": "1.40.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+31
View File
@@ -4,6 +4,7 @@
* Extracted from the package.json one-liner for readability and debuggability.
*
* Steps:
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
* 1. TypeScript compilation
* 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles
@@ -13,6 +14,7 @@
*/
import { execSync } from 'child_process';
import { createRequire } from 'module';
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url';
@@ -25,6 +27,31 @@ function run(label, cmd) {
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
}
// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
// with dist/web/public already wiped, so the running server kept serving an index.html
// whose hashed assets were gone. Keep the list in step with every require.resolve in
// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
// Only specifiers that resolve without an exports map in the way: a subpath of a package
// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
const requireFromBuild = createRequire(import.meta.url);
const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
try {
requireFromBuild.resolve(specifier);
return false;
} catch {
return true;
}
});
if (missingModules.length > 0) {
console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
process.exit(1);
}
// 1. TypeScript compilation
run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js');
@@ -49,6 +76,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
appendFileSync(
@@ -129,6 +159,7 @@ console.log('\n[build] content-hash cache busting');
'api-client.js',
'subagent-windows.js',
'image-input.js',
'spreadsheet-preview.js',
'vendor/xterm-zerolag-input.js',
'vendor/xterm-predictive-echo.js',
];
+39 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
// They load only inside the spreadsheet worker, but a dependency bump that
// balloons them should be a deliberate decision, not a silent one.
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
function collectTextAssets(dir) {
const files = [];
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
const files = collectTextAssets(publicRoot);
const failures = [];
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
// the content hash of everything the worker loads, or a deploy can pair a new
// worker with a stale cached core/vendor file (static assets are cached 1y).
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
}
const expectedVersion = createHash('sha256')
.update(readFileSync(spreadsheetWorker))
.update(readFileSync(spreadsheetCore))
.update(readFileSync(spreadsheetVendors[0]))
.update(readFileSync(spreadsheetVendors[1]))
.digest('hex')
.slice(0, 12);
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
if (actualVersion !== expectedVersion) {
failures.push(
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
);
}
} else {
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
}
for (const file of files) {
const rel = relative(repoRoot, file);
const data = readFileSync(file);
+16
View File
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
}
}
// ----------------------------------------------------------------------------
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
} catch (err) {
hasWarnings = true;
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
console.log(colors.dim(` ${err.message}`));
}
}
// ----------------------------------------------------------------------------
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
* in package.json, and check-public-assets.mjs hashes the output into
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
* that changes the bytes fails that check until the token is refreshed.
* Source-map comments are stripped: the maps are not shipped.
*/
import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
const require = createRequire(import.meta.url);
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
function copyBrowserBundle(source, outputName) {
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
if (/sourceMappingURL/.test(content)) {
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
}
writeFileSync(join(outputDir, outputName), content, 'utf8');
}
mkdirSync(outputDir, { recursive: true });
copyBrowserBundle(excelSource, 'exceljs.min.js');
copyBrowserBundle(fflateSource, 'fflate.min.js');
+9 -3
View File
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
*/
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
/**
* Document types an attachment card previews. Also the list `codeman attach`'s
* error text names, so the help cannot drift from what is accepted. `xlsx` is
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
*/
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'pdf',
'docx',
'pptx',
...DOCUMENT_ATTACHMENT_EXTENSIONS,
'md',
'txt',
...VIDEO_ATTACHMENT_EXTENSIONS,
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'xlsx') return 'spreadsheet';
if (normalized === 'md') return 'markdown';
// Everything else in the text family reads as text, including code and
// config: the card and the preview both treat it as a plain-text file.
+6 -2
View File
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
import { installService, serviceStatus, uninstallService } from './service-installer.js';
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
@@ -111,7 +111,11 @@ program
.action(async (filePath, options) => {
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
console.error(
palette.err(
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
)
);
process.exit(1);
}
+41 -4
View File
@@ -15,7 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
import type { LaunchDefaultSettingKey, 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
@@ -289,7 +289,7 @@ const capabilitiesSchema = z
requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
@@ -385,8 +385,10 @@ const capabilitiesSchema = z
)
.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(),
// itself may be able to write. 8 is the reader's own cap (readScreenModel); a
// window taller than the CLI's footer needs a pattern only that CLI's chrome can
// satisfy at its position, as opencode's does by taking the LAST composer row.
screenLines: z.number().int().min(1).max(8).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.
@@ -407,6 +409,17 @@ const capabilitiesSchema = z
'rejectWords has nothing to filter without a screenLine'
)
.optional(),
// Launch param -> synced App Settings key. The values are a closed enum, like
// configResolver: a clis.json override names one of the settings this build knows
// how to validate, never an arbitrary key. Params are checked against the declared
// ones in the superRefine below.
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
privilegedParams: z
.array(
z
@@ -625,6 +638,30 @@ export const CliEntrySchema = z
}
});
// Same silent-no-op class again: a launch default for a param the entry never declared
// would be filled into the config object and then read by nothing. And without a
// `legacyConfigField` the entry's params are read off the request body itself, where a
// filled `model` would be a different field (claude's per-session one), so refuse it.
const { launchDefaults } = entry.capabilities;
if (launchDefaults !== undefined) {
if (entry.launch.legacyConfigField === undefined) {
ctx.addIssue({
code: 'custom',
message: 'launchDefaults needs launch.legacyConfigField to fill',
path: ['capabilities', 'launchDefaults'],
});
}
for (const param of Object.keys(launchDefaults)) {
if (!declaredParams.has(param)) {
ctx.addIssue({
code: 'custom',
message: `launchDefaults param "${param}" is not a declared launch param`,
path: ['capabilities', 'launchDefaults', param],
});
}
}
}
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
+142 -13
View File
@@ -491,8 +491,45 @@ const OPENCODE: CliEntry = {
},
capabilities: {
...agentDefaults(),
altScreen: 'strip-mux-only',
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
// with the same bar, so a turn's first repaint arms the idle confirmation and tmux's
// reattach repaint does the same for a restored pane. While a turn runs the footer row
// starts with an 8-cell knight-rider spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about
// every 40 ms (never a 2.5 s gap mid-turn, so silence cannot end one early); at rest the
// row holds only the key hints and nothing on screen draws a `⬝`/`■` run, the wide
// layout's sidebar included. The working line is the spinner run, not the label: tmux
// ships `esc` and `interrupt` as separate words joined by cursor moves, and below about
// 45 columns the footer wraps the label itself. A pending permission prompt replaces
// the composer and stops the spinner, so it reads as idle (waiting on the user).
// ⚠️ Without this entry an opencode session latched `busy` after any turn that ran a
// tool: the braille spinner on a running tool row trips SPINNER_PATTERN, and opencode
// never draws Claude's `❯`, the fallback that would have armed the idle check. The
// last `┃` row on screen is the composer's agent/model row (or the permission box's
// closing bar), never the prompt text, so the submit verifier stands down.
workDetect: {
promptGlyph: '┃',
workingLine: '[⬝■]{8}',
},
// The composer's agent row, measured on live opencode 1.3.0 panes (home screen and in
// session, at 40, 60, 120 and 200 columns, 2026-10-09): `┃ Build Big Pickle OpenCode
// Zen`, directly above the box's bottom edge `╹▀▀▀`. opencode renders it as the agent,
// then the model's name, then the provider's name (then `· <variant>` when the model
// has one), and only colour tells model from provider, so the field is all of it: what
// opencode itself shows, owner's choice. A double space ends it, which is where the
// 200-column layout's sidebar shares the row. The lookahead takes the LAST such row in
// the window, so nothing the agent prints higher up can stand in for it; below the
// composer there is only opencode's own chrome (key hints, a tip, the cwd/version
// row), which is why the window can be 8 rows: the home screen puts up to 5 of those
// rows under it. A permission prompt or shell mode hides the row, and the last model
// is kept. `No provider ` is opencode's placeholder before a provider is connected.
modelDetect: {
screenLine: String.raw`┃ {2}[^\s·]+ {2}(?!No provider )([^ \n](?:[^ \n]| (?! ))*)(?: {2}.*)?\n *╹(?![\s\S]*\n *╹)`,
screenLines: 8,
},
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
@@ -598,10 +635,15 @@ const CODEX: CliEntry = {
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
// the terminal starts, follows the composer down as the conversation grows, and is
// gone after `/stop`.
// ⚠️ Codex 0.162.0 (measured 2026-10-09) draws a hint row under the status line at
// rest (` ← for agents · ? for shortcuts`) and drops it while a prompt is typed, so
// the chip is FOURTH from the bottom at rest and third while typing. A three-row
// window never saw it at rest, which is exactly when the idle probe reads it, so a
// session waiting on its terminal read as plainly idle. Four rows cover both.
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
// layout rather than its pattern. The third row from the bottom is the chip only
// layout rather than its pattern. The fourth row from the bottom is the chip only
// while a terminal runs; with none running it is the last row of the transcript,
// which the agent writes. Matching the complete row raises the bar — an assistant
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
@@ -620,18 +662,37 @@ const CODEX: CliEntry = {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
watchingLines: 4,
},
// 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.
// cwd). It sits 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.
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
// that level never named its model. Every word is plain letters, so the join adds no
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
// drops it again while a prompt is being typed. With a one-row window the footer was
// never seen and every codex tile showed no model. So the window is two rows and the
// footer is either the last one or followed by exactly one more two-space-indented
// row. The `$` (no `m` flag: the end of the window) is what keeps the guard the
// one-row rule had: with the footer hidden, the last two rows are a transcript line
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read.
modelDetect: {
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2,
},
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// 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,
@@ -754,6 +815,28 @@ const GEMINI: CliEntry = {
...agentDefaults(),
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
// turns with a shell call at 40, 120 and 200 columns, YOLO and default approval mode,
// plus the raw PTY stream, 2026-10-09). The TUI repaints its whole bottom region on
// every frame, composer included, and the composer sits between a `▄` bar and a `▀`
// bar; the submitted prompt is echoed between the same bars. So the `▀` bar arms the
// idle confirmation (every repaint and tmux's reattach repaint carry it), and it is the
// glyph rather than the composer's prompt character, which follows the approval mode
// (`*` in YOLO) and whose `>` also starts the echoed prompt. While a turn runs a line
// `⠦ Thinking... (esc to cancel, 6s)` animates about every 80 ms (largest gap mid-turn:
// 214 ms); the label can be any loading phrase, so the working line is the
// `(esc to cancel, <n>` suffix, or a spinner frame opening a line where a long phrase
// pushed that suffix onto the next one. At rest nothing on screen matches either. A tool
// confirmation (default mode) replaces the composer and stops the spinner, and the pane
// goes silent, so it reads as idle (waiting on the user).
// ⚠️ Without this entry a gemini session latched `busy` after its first turn: the braille
// spinner trips SPINNER_PATTERN, and gemini never draws Claude's `❯`, the fallback that
// would have armed the idle check. A line starting with `▀` is a bar, never prompt text,
// so the submit verifier stands down.
workDetect: {
promptGlyph: '▀',
workingLine: String.raw`\(esc to cancel, \d|(?:^|\n) ?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] `,
},
// gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
@@ -935,6 +1018,37 @@ const PI: CliEntry = {
...agentDefaults(),
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
// while a turn runs it embeds its status in the TOP rule as `── ⠏ Working ───…`, the
// braille frame animating every ~80 ms; at rest both rules are plain `─`. So the rule
// is the glyph that arms the idle confirmation, and a spinner frame inside it is the
// working line (the frame, not the word: an extension can replace "Working").
// ⚠️ Without this entry a pi session never left `busy` once marked working: the
// braille spinner trips SPINNER_PATTERN, and pi never draws Claude's `❯`, the
// fallback that would have armed the idle check. The rules carry no prompt text, so
// the submit verifier reading them stands down instead of re-pressing Enter.
workDetect: {
promptGlyph: '─',
workingLine: '── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] ',
},
// pi's footer stats row, read from pi 1.1.0's footer code (0.84.4's is the same) and
// measured live as `0.8%/253k (auto) qwen3.8-27b-pi • xhigh`: usage and
// context on the left, then at least two spaces and `[(provider) ]<model>` followed
// by ` • <thinking>` for a reasoning model and ` → <routed model>` when routed. Only
// the last two rows are read, which sit below the composer where the transcript never
// reaches (an extension's status row may sit under the stats row), and the context
// field (`12.3%/253k`, `?/128k`) picks the stats row out of them.
// ⚠️ A narrow pane truncates the right side with NO ellipsis, leaving exactly two
// spaces of padding. So a model with nothing after it is read only with 3+ spaces in
// front; with two, only when a following ` •`/` →` proves the name is whole (the
// bullet only ever follows a complete name, even when the cut lands right after it).
// A cut name is never shown. `no-model` is pi's placeholder when none is selected.
modelDetect: {
screenLine: String.raw`[%?]/[\d.]+[kKM]?(?: \(auto\))?(?: • xp)? {2}(?: +|(?=(?:\(\S{1,40}\) )?\S{1,80} [•→]))(?:\([\w.@-]{1,40}\) )?([A-Za-z0-9][\w.:/@+-]{0,79})(?= [•→]|\n|$)`,
screenLines: 2,
rejectWords: ['no-model'],
},
// pi's absent-config default is an interactive trust PROMPT the session user could
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
@@ -1052,8 +1166,9 @@ const GROK: CliEntry = {
},
capabilities: {
...agentDefaults(),
// Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity:
// only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
// erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only',
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
@@ -1386,13 +1501,27 @@ const OMP: CliEntry = {
},
capabilities: {
...agentDefaults(),
// Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only',
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
// makes an omp conversation survive a full session kill.
transcript: 'omp-jsonl',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on live omp 18.8.6 and 18.0.11 panes (2026-10-09, a turn held open against
// an endpoint that never answers): the input row is `╰─ <text>`, redrawn when a turn
// ends, at launch and on reattach. While a turn runs the status bar's leading `π`
// becomes a braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > 📁 ~/dir ▶──`;
// 18.0.11 pads it with two spaces, past a minute it reads `1m`), and a `⎋ Working…`
// row appears above it. At rest the bar starts ` π > `.
// ⚠️ Without this entry an omp session never left `busy` once marked working, like pi:
// the spinner trips SPINNER_PATTERN and omp never draws Claude's `❯` after setup.
// The glyph also switches the submit verifier on for omp. A prompt sent mid-turn goes
// to omp's `Steering` queue and clears the input row, so the verifier stands down.
workDetect: {
promptGlyph: '╰─',
workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working',
},
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
// whole privileged surface here is env-shaped.
privilegedParams: [],
+57 -6
View File
@@ -96,6 +96,14 @@ 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 synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
* A closed list rather than any settings key, so a clis.json override cannot feed an
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** 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';
@@ -365,8 +373,8 @@ export interface CliCapabilities {
/**
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
* its own above the composer, which puts it third from the bottom, so it declares
* more. Keep each number as small as that CLI's layout allows: every extra row is
* its own above the composer, which puts it third or fourth from the bottom (its hint
* row comes and goes), so it declares more. Keep each number as small as that CLI's layout allows: every extra row is
* another row an agent might be able to write, and the label is what silences an
* alert. See `watchingLabel()` in `session-activity.ts`.
*/
@@ -439,11 +447,40 @@ export interface CliCapabilities {
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
* What the server strips from this CLI's output stream before the browser sees it.
* The value encodes three independent choices (predicates in session.ts):
*
* | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs |
* |-----------------------|---------------------|-------------------------|---------------------|
* | `strip-full` | stripped | stripped | stripped |
* | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux |
* | `strip-mux-only` | stripped under tmux | kept | kept |
* | `preserve` | stripped under tmux | kept | kept |
*
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
* reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a
* direct-PTY fallback the `?1049h` is the program's own and must stay.
* - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's
* redraw-driven `3J` (strip-full) is noise.
* - mouse DECSETs: stripping them keeps a drag a local selection instead of a report
* to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated
* on the `cliMouseTracking` the server records as it strips. Kept where a program's
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
*/
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve';
echo: {
policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */
@@ -500,6 +537,20 @@ export interface CliCapabilities {
rejectWords?: string[];
configResolver?: ModelConfigResolverName;
};
/**
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
*
* Filled into the entry's `launch.legacyConfigField` object at create time by
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
* Absent = no launch defaults.
*/
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
/**
* 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.
+75 -35
View File
@@ -15,8 +15,8 @@
* 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;
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, 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.
*
@@ -52,7 +52,10 @@ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
export const MIN_GIT_TIMEOUT_MS = 5_000;
export const MAX_GIT_TIMEOUT_MS = 120_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. */
@@ -258,9 +261,9 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
// ---------------------------------------------------------------------------
/** 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 type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
export const runGit: GitRunner = async (cwd, args) => {
export const runGit: GitRunner = async (cwd, args, opts) => {
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
@@ -269,7 +272,7 @@ export const runGit: GitRunner = async (cwd, args) => {
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
}
@@ -288,17 +291,14 @@ function describeFailure(err: unknown): { notARepo: boolean; message: string } {
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
}
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
let statusText: string;
try {
statusText = await git(cwd, [
'status',
'--porcelain=v2',
'--branch',
'-z',
'--untracked-files=normal',
'--ignore-submodules=dirty',
]);
statusText = await git(
cwd,
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
{ timeoutMs }
);
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
@@ -307,7 +307,7 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
const safe = async (args: string[]): Promise<string> => {
try {
return await git(cwd, args);
return await git(cwd, args, { timeoutMs });
} catch {
return '';
}
@@ -415,10 +415,12 @@ async function singleFlight<T>(
*/
export async function getGitWorkspaceStatus(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit;
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
collect(cwd, git, opts.timeoutMs)
);
}
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
@@ -427,12 +429,12 @@ 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 }
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
): 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();
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
} catch (err) {
const f = describeFailure(err);
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
const DISCOVERY_MAX_ENTRIES = 300;
/** Repositories reported for one workspace. */
export const MAX_REPOS = 12;
/** The most repositories a caller may ask for: each one costs several git processes per poll. */
export const MAX_REPOS_LIMIT = 50;
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** 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. */
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
reason?: 'remote' | 'docker';
error?: string;
repos: GitRepoEntry[];
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
/** More than `repoLimit` repositories were found; only the first are reported. */
reposTruncated: boolean;
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
repoLimit?: number;
checkedAt: number;
}
@@ -553,7 +567,8 @@ async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] |
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
export async function discoverChildRepos(
cwd: string,
excludeRealRoots: string[] = []
excludeRealRoots: string[] = [],
maxRepos: number = MAX_REPOS
): Promise<{ dirs: string[]; truncated: boolean }> {
const found: string[] = [];
let level = [cwd];
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
}
level = next;
}
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
}
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
maxRepos?: number;
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
timeoutMs?: number;
}
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
maxRepos: number;
timeoutMs: number;
} {
return {
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
};
}
type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
| { kind: 'children'; dirs: string[]; truncated: boolean };
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
@@ -617,6 +647,7 @@ type WorkspaceRepos =
*/
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
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.
@@ -624,7 +655,7 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
// 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);
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
if (top.state === 'error') return { kind: 'error', error: top.error };
if (top.state === 'ok') {
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
@@ -633,18 +664,20 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
}
// Not inside a repository of this workspace: look below for projects.
const hit = discoveryCache.get(cwd);
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
const discoveryKey = `${cwd}\0${maxRepos}`;
const hit = discoveryCache.get(discoveryKey);
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 });
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
discoveryCache.set(discoveryKey, { 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 };
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
}
/**
@@ -659,7 +692,7 @@ export async function getGitWorkspaceOverview(
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);
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
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;
@@ -671,14 +704,21 @@ export async function getGitWorkspaceOverview(
};
}
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
);
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 });
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
// list with its error, so it is visible that something is not being reported; only a folder
// that turned out not to be a repository after all is left out.
if (status.state === 'ok' || status.state === 'error')
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() };
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
}
/**
@@ -726,7 +766,7 @@ export function isSafeRepoRelativePath(p: string): boolean {
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
opts: { git?: GitRunner; timeoutMs?: number } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
let out: string;
let cutShort = false;
try {
out = await git(repoRoot, args);
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
} 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.
+128 -33
View File
@@ -277,10 +277,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
* with the replay-side strip in session-routes.ts.
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
* stock CLI in test/claude-scrollback-strip.test.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
@@ -300,8 +301,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
/**
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
* excludes, but ONLY when the session is tmux-backed (`useMux`).
* and the mouse-tracking DECSETs alone. Applies to every mode that is neither
* `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode),
* so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed
* (`useMux`).
*
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
@@ -326,7 +329,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
*/
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
return useMux && !isAltScreenStripMode(mode);
if (!useMux) return false;
const altScreen = getCli(mode)?.capabilities.altScreen;
return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse';
}
/**
* Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone:
* `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI.
*
* Why this exists (opencode, measured 2026-09-16): the TUI enables tracking
* DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs
* straight through to the tmux client, and the browser's xterm obeyed them —
* `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI
* instead of selecting locally. "Mark text, copy on select" silently did nothing
* (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and
* the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also
* ended sessions. Stripping at the source keeps xterm in selection mode; clicks
* still reach the CLI through the browser's hand-encoded tap, which this strip
* publishes as `cliMouseTracking` (`_recordStrippedMouseMode`).
*
* Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY
* fallback the program's own DECSETs really do reach xterm and must be honoured.
*/
export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean {
return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
@@ -1355,7 +1382,7 @@ export class Session extends EventEmitter {
/**
* True when this session's PTY is a tmux client rather than the program itself.
* Read by the replay-side alt-screen strip, which must apply the same
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode).
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode, isMuxMouseStripMode).
*/
get usesMux(): boolean {
return this._useMux;
@@ -2529,14 +2556,21 @@ export class Session extends EventEmitter {
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
//
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
// scrollback there is tmux's own client-side smcup at attach, not anything the
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
//
// The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI:
// it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking
// reaches xterm and every drag becomes a mouse report instead of a text
// selection, which is what killed mark-and-copy in opencode — while 3J stays,
// because a TUI is not a `clear` consumer.
const fullStrip = isAltScreenStripMode(this.mode);
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
if (fullStrip || altOnlyStrip) {
const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
if (fullStrip || mouseStrip || altOnlyStrip) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
@@ -2555,14 +2589,18 @@ export class Session extends EventEmitter {
// eslint-disable-next-line no-control-regex
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
if (fullStrip) {
data = data
// eslint-disable-next-line no-control-regex
data = data.replace(/\x1b\[3J/g, '');
}
if (fullStrip || mouseStrip) {
data = data.replace(
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
(seq) => {
this._recordStrippedMouseMode(seq);
return '';
});
}
);
}
}
@@ -2783,19 +2821,14 @@ export class Session extends EventEmitter {
this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
// For RESTORED mux sessions: leave the buffer alone - client will fetch it on tab switch
if (!isRestored) {
if (isExternalCliMode(this.mode)) {
// External CLIs use custom TUIs — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready.
// Don't clear the buffer — the TUI's initial render IS the useful content.
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
this._promptCheckTimeout = setTimeout(() => {
this._promptCheckTimeout = null;
if (this._isStopped) return;
this._status = 'idle';
this.emit('needsRefresh');
}, 3000);
this._armPaneSettle(false);
} else {
// Claude mode: wait for ❯ prompt
this._promptCheckInterval = setInterval(() => {
@@ -2827,6 +2860,8 @@ export class Session extends EventEmitter {
this._promptCheckTimeout = null;
}, 5000);
}
} else {
this._armPaneSettle(true);
}
} catch (err) {
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
@@ -3433,14 +3468,74 @@ export class Session extends EventEmitter {
// 1. Claude was working and is now at prompt (normal case)
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
const wasWorking = this._isWorking;
const isInitialReady = this._status === 'busy' && !this._isWorking;
if (wasWorking || isInitialReady) {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
if (wasWorking) this._maybeCaptureOmpSessionId();
this.emit('idle');
}
if (wasWorking || this._status === 'busy') this._concludeIdle(wasWorking);
}
/**
* The one place a pane is concluded idle: status, working flag and prompt stamp
* change together, and the change is ANNOUNCED. The `idle` event is what the web
* server turns into `session:idle` plus a state broadcast, so a path that flips
* `_status` without it leaves every browser on the `busy` it was last sent. A fresh
* codex pane used to stay "working" in the UI for its whole life that way.
*
* @param turnEnded a real turn just finished (not a pane becoming ready at launch)
*/
private _concludeIdle(turnEnded: boolean): void {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
// Only a finished turn proves omp has written its session file; a pane that is
// merely ready has nothing to resolve yet and could claim a neighbour's file.
if (turnEnded) this._maybeCaptureOmpSessionId();
this.emit('idle');
}
/**
* Arm the launch settle (`_settlePaneStartup`) for a pane `startInteractive()` just
* started or re-attached, when one applies:
* - a NEW pane of an external CLI, whose TUI has no ❯ for the Claude wait to find;
* - a RESTORED pane (Codeman restart, auto-reattach, tile Attach) of a CLI that
* declares no `capabilities.workDetect`. It is `busy` from `_resetBuffers()` like a
* new pane, and with no composer glyph to arm `_confirmIdle()` nothing else would
* ever settle it. A restored claude or codex pane is left to its glyph, which
* reads the screen first, so a restart in mid-turn is never called idle.
*/
private _armPaneSettle(isRestored: boolean): void {
const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode);
if (!applies) return;
const armedAt = Date.now();
this._promptCheckTimeout = setTimeout(() => this._settlePaneStartup(!isRestored, armedAt), 3000);
}
/**
* The launch settle: 3 s after a NEW external-CLI pane spawned, or after ANY pane of a
* CLI without work detection was re-attached, its TUI is taken to have rendered. A pane
* still in its spawn-time `busy` is concluded idle (announced, see `_concludeIdle`),
* then, for a new pane, the browser is told to refetch the rendered screen.
*
* ⚠️ This used to set `_status = 'idle'` without an event. When the launch paint
* never tripped `_markWorking()`, the later `_confirmIdle()` found the status
* already idle and emitted nothing, so no browser ever learned the pane was ready.
*
* A pane is left to `_confirmIdle()` only when its CLI declares
* `capabilities.workDetect` (its composer glyph arms that confirmation, which reads
* the screen first) AND it is already working, or a prompt was submitted since the
* timer was armed: a turn started 2.9 s in is not marked working before the deferred
* parsers run, and an idle edge here would end a send-and-wait registered for it.
* For every other CLI this timer is the only thing that ever settles a fresh pane,
* so it settles it even if a stray spinner glyph in the launch paint latched
* `_isWorking`.
*
* @param refreshScreen emit `needsRefresh` (a new pane; an attach refetches by itself)
* @param armedAt when the timer was armed (a submit at or after it means a prompt)
*/
private _settlePaneStartup(refreshScreen: boolean, armedAt: number): void {
this._promptCheckTimeout = null;
if (this._isStopped) return;
const busyWithTurn = this._isWorking || this.lastSubmitAt >= armedAt;
const leaveToConfirm = busyWithTurn && !!getCli(this.mode)?.capabilities.workDetect;
if (this._status === 'busy' && !leaveToConfirm) this._concludeIdle(false);
if (refreshScreen) this.emit('needsRefresh');
}
/**
@@ -4428,7 +4523,7 @@ export class Session extends EventEmitter {
: this._mux.capturePaneText?.(this._muxSession.muxName),
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
// Only claude and codex declare a promptGlyph; the other eight modes would fall back
// Only claude, codex, pi, opencode, omp and gemini declare a promptGlyph; the other modes would fall back
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
// on screen for as long as the command runs, promptStillInComposer() reads that as
+2
View File
@@ -56,3 +56,5 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
+2 -1
View File
@@ -812,7 +812,8 @@ export interface SessionState {
/**
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
* by the server on its way out of the stream (those sequences are stripped for
* claude/codex/gemini, so the browser can never see them itself). The browser
* the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode
* under tmux, so the browser can never see them itself). The browser
* hand-encodes a click report ONLY when this is true; without it, every click
* sent mouse reports to a CLI that never asked for them.
*/
+1
View File
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
| 'pdf'
| 'document'
| 'presentation'
| 'spreadsheet'
| 'markdown'
| 'text';
+49
View File
@@ -0,0 +1,49 @@
/**
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
*
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
* unchecked value onto the command line.
*
* Scope is the caller's decision: the create and quick-start routes apply it to local
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
* a CLI's own config files.
*/
import { getCli } from '../config/cli-registry/registry.js';
import { SettingsUpdateSchema } from './schemas.js';
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
/**
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
* declares no defaults, `customEndpoint` is set, or no setting names a value.
*/
export async function applyLaunchDefaults<T extends object>(
mode: string,
configs: T,
customEndpoint = false
): Promise<T> {
const entry = getCli(mode);
const declared = entry?.capabilities.launchDefaults;
const field = entry?.launch.legacyConfigField;
if (customEndpoint || !declared || field === undefined) return configs;
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
const aliases = entry.launch.legacyConfigAliases ?? {};
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
const defaults: Record<string, unknown> = {};
for (const [param, settingKey] of Object.entries(declared)) {
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
// '' is the settings' "leave it to the CLI" value, the same as unset.
const value = parsed.success ? parsed.data || undefined : undefined;
const wireKey = aliases[param] ?? param;
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
}
if (Object.keys(defaults).length === 0) return configs;
return { ...configs, [field]: { ...current, ...defaults } };
}
+673 -32
View File
@@ -985,6 +985,7 @@ class CodemanApp {
// Last rendered connection-indicator tuple; the hot input path skips DOM
// writes when the freshly computed descriptor is identical (COD-136).
this._lastIndicatorDescriptor = null;
this._lastIndicatorLanguage = null; // the UI language it was rendered in
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
// session they are bound to: the split pane's second terminal registers its
@@ -1164,6 +1165,8 @@ class CodemanApp {
this._selectUrlSession();
});
}
// mobile.css keeps the pop-out icon off phones unless a host can open windows.
document.documentElement.classList.toggle('host-windows', this.hasHostWindows());
// Initialize mobile handlers
KeyboardHandler.init();
SwipeHandler.init();
@@ -1667,6 +1670,24 @@ class CodemanApp {
// false only when we owned a now-closed window (re-dock + fall through to
// genuinely re-open below).
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
// A native wrapper (an Android WebView app) has no browser pop-ups, but can
// open the solo URL in a window of its own, beside this one on a foldable or
// a split screen. There is no WindowProxy to poll, so the tab is tracked the
// way a dashboard reload tracks it: the solo window's channel announcements
// plus the roll-call liveness check. Without a channel there is no roll-call
// either, so a hosted tab could never re-dock: refuse before asking the host.
const hosted = this.hasHostWindows() && !this.windowChannel
? false
: this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
if (hosted !== null) {
if (!hosted) {
this.showToast?.('Could not open a new window for this session', 'error');
return;
}
this._markDetached(id, true);
this._postWindowMessage({ type: 'detached', id });
return;
}
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
@@ -1681,6 +1702,44 @@ class CodemanApp {
try { win.focus(); } catch {}
}
/**
* The embedding app's window opener, when there is one. A native wrapper
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
* counts as opened) to say it can put a page in a window of its own; browsers
* never define it.
* @returns {boolean} whether a host window opener is present
*/
hasHostWindows() {
try {
return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function';
} catch { return false; }
}
/**
* The tab pop-out setting, defaulting ON under a host that opens windows.
* The one resolver for the tab icon, App Settings and the tab action menu.
* @param {object} settings stored per-device App Settings
* @param {object} [defaults] the device's default settings
* @returns {boolean} whether the pop-out control shows
*/
tabDetachButtonEnabled(settings, defaults = {}) {
return settings?.showTabDetachButton ?? (this.hasHostWindows() || (defaults?.showTabDetachButton ?? false));
}
/**
* Open an http(s) URL in a host window, usually Codeman's own origin (a saved
* web tab passes its own).
* @param {string} url absolute or base-relative URL
* @returns {boolean|null} null when there is no host (use window.open),
* otherwise whether the host opened a window
*/
openInHostWindow(url) {
if (!this.hasHostWindows()) return null;
try {
return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false;
} catch { return false; }
}
/** Raise the popup for an already-detached session. Returns true if the raise
* was handled (caller should stop); false if we owned a now-closed window and
* re-docked it (caller should fall through to inline / re-open). Unifies the
@@ -1810,8 +1869,12 @@ class CodemanApp {
// Roll-call has no id (broadcast to all) — answer before the id filter.
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
if (msg.id !== this.soloSessionId) return;
if (msg.type === 'close-request') { try { window.close(); } catch {} }
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
// A host window ignores window.close()/focus() from script it did not
// open by window.open, so ask the host when it offers the call.
if (msg.type === 'close-request') { this._closeSoloWindow(); }
else if (msg.type === 'focus-request') {
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
}
return;
}
// Dashboard side.
@@ -1863,6 +1926,14 @@ class CodemanApp {
}, 1200);
}
/** Solo window: close itself (the re-dock button and a dashboard close-request). */
_closeSoloWindow() {
try {
if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow();
else window.close();
} catch {}
}
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode() {
@@ -1894,7 +1965,7 @@ class CodemanApp {
el.className = 'solo-gone-overlay';
el.innerHTML = '<h2>Session unavailable</h2>'
+ '<p>This session has ended or is no longer available.</p>'
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
+ '<button class="btn-primary" onclick="app._closeSoloWindow()">Close window</button>';
document.body.appendChild(el);
document.title = (window.codemanT?.('Session ended') || 'Session ended')
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
@@ -3405,14 +3476,20 @@ class CodemanApp {
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
// Every window also carries a ring (the Compact header style) and a meter
// (Tiles). styles.css hides both in the classic style, so the chip there
// reads exactly as before. `fill` is clamped for the two graphics only; the
// label keeps the real number.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
return `<span class="pu-win pu-win-idle"><span class="pu-ring" style="--pu:0"></span><span class="pu-label">${label}</span><span class="pu-val">—</span><span class="pu-meter"><i style="width:0%"></i></span></span>`;
}
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
const fill = Math.min(100, Math.max(0, n));
const cls = colorClass(n);
return `<span class="pu-win"><span class="pu-ring ${cls}" style="--pu:${fill}"></span><span class="pu-label">${label}</span><span class="pu-val ${cls}">${n}%</span><span class="pu-meter ${cls}"><i style="width:${fill}%"></i></span></span>`;
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
@@ -4248,6 +4325,51 @@ class CodemanApp {
};
}
/**
* The two words the Tiles header style shows for the connection indicator
* (label over value), derived from the descriptor rather than added to it,
* so the descriptor and its pinned strings stay exactly what they were. The
* classic text line (queued bytes and all) stays in the DOM and the full
* detail stays in the tooltip.
* @param {{dotClass: string, text: string}} desc
* @returns {{label: string, value: string, state: string}}
*/
_connectionTileWords(desc) {
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
const text = desc.text || '';
switch (state) {
case 'connected':
return { label: 'WS', value: 'live', state };
case 'fallback':
return { label: 'HTTP', value: 'fallback', state };
case 'offline':
return { label: 'NET', value: 'offline', state };
case 'draining':
return { label: 'SEND', value: 'queued', state };
case 'reconnecting':
// The same dot covers the terminal WebSocket and, with no session
// open, the SSE event stream; the classic text already tells them apart.
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
default:
return { label: '', value: '', state };
}
}
/**
* The tile's value word as shown: i18n.js's scoped 'Connection tile: <word>'
* entry in Chinese, the English word otherwise. Never a bare-word key: those
* would also translate other text ("retry" is the orchestrator's Retry button),
* which is why the value span carries data-i18n-skip.
* @param {string} value
* @returns {string}
*/
_connectionTileValueText(value) {
if (!value) return '';
const key = `Connection tile: ${value}`;
const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key;
return translated && translated !== key ? translated : value;
}
_updateConnectionIndicator() {
const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot');
@@ -4259,8 +4381,12 @@ class CodemanApp {
// writes when nothing changed (COD-136) — the compute above is DOM-free.
const next = this._computeConnectionDescriptor();
const prev = this._lastIndicatorDescriptor;
// The tile's value word is written in the UI language, so a language
// switch counts as a change too.
const language = window.CodemanI18n?.language || 'en';
if (
prev &&
language === this._lastIndicatorLanguage &&
prev.display === next.display &&
prev.dotClass === next.dotClass &&
prev.text === next.text &&
@@ -4269,12 +4395,21 @@ class CodemanApp {
return;
}
this._lastIndicatorDescriptor = next;
this._lastIndicatorLanguage = language;
indicator.style.display = next.display;
if (next.display !== 'none') {
dot.className = next.dotClass;
text.textContent = next.text;
indicator.title = next.title;
const tileLabel = this.$('connectionTileLabel');
const tileValue = this.$('connectionTileValue');
if (tileLabel && tileValue) {
const words = this._connectionTileWords(next);
tileLabel.textContent = words.label;
tileValue.textContent = this._connectionTileValueText(words.value);
tileValue.className = `connection-tile-value ${words.state}`.trim();
}
}
}
@@ -4526,11 +4661,15 @@ class CodemanApp {
}
this.notificationManager.groupingMap.clear();
}
// Disconnect terminal resize observer (prevents memory leak on reconnect)
if (this.terminalResizeObserver) {
this.terminalResizeObserver.disconnect();
this.terminalResizeObserver = null;
}
// ⚠️ The terminal resize observer is NOT reset here. initTerminal() owns its
// lifecycle (it runs once per page and disconnects any previous observer
// before creating one), and this reset runs on EVERY SSE init, page load
// included. It used to disconnect the observer "to prevent a leak", which
// left the terminal with no observer from the first init on: only a WINDOW
// resize ever refit it, so anything that resized just the terminal box
// (state rows and lineage room appearing in the header, the tab strip
// wrapping) clipped xterm's bottom rows behind the toolbar until a tab
// switch or a window resize.
// Clear any other orphaned timers
if (this.planLoadingTimer) {
clearInterval(this.planLoadingTimer);
@@ -5003,6 +5142,355 @@ class CodemanApp {
return out;
}
/**
* True when the tab list groups by state (`tabArrangement: 'state'`, opt-in;
* Discussion #426 option C): a row per state in the header strip, a section
* per state in the flat side rail and the sidebar, most urgent on top.
*
* Read off <html> like the rail gates (applyTabOrientation() owns the
* attribute). Named groups in the vertical rail still win, because they are
* the user's own structure: `_tabTriageLayout()` returns null while the
* grouped projection is on, and the grouped tree renders as it always did.
*/
isTabTriage() {
return document.documentElement.dataset.tabArrangement === 'state';
}
/**
* Order values and visible groups for one render pass, or null when the list
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
* (constants.js); this only feeds it the same classification both home
* screens and the sorted rail use.
*
* Inside a group a row keeps its tab order on the header strip, so the strip
* only moves a tab when its state changes. A sorted rail ranks rows inside
* each section the way it ranks the whole flat rail (`railSortOrder`).
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
*/
_tabTriageLayout(ids, groupProjection, railSortOrder) {
if (groupProjection || !this.isTabTriage()) return null;
if (!window.CodemanTabTriage || typeof this._mobileOverviewState !== 'function') return null;
const rows = [];
for (let i = 0; i < ids.length; i++) {
const session = this.sessions.get(ids[i]);
if (!session) continue;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
rows.push({
id: ids[i],
state,
exited: !!this._mobileOverviewExit?.(state, session),
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
});
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
}
/**
* Keep the state headings, the row breaks and the web tabs' `order` in step
* with one pass's triage layout. Runs after BOTH render paths, because a
* session changing state is an incremental pass (no tab is added or removed)
* and can still empty a group or fill a new one.
*
* Headings and breaks are keyed by group and reconciled in place, never
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
* direct children of #sessionTabs placed purely by `order`, so where they sit
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
* whose children must all be tabs. (A tab's state is not announced either way:
* its status dot is aria-hidden, as before.) With `triage` null this removes
* them all and clears the web tabs' inline order, which is what leaves the
* ungrouped strip exactly as it was.
*/
_syncTabTriageChrome(container, triage) {
if (!container) return;
this._lastTabTriage = triage;
container.classList.toggle('tabs-triage', !!triage);
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
if (!wanted.has(el.dataset.triageGroup)) el.remove();
}
const ensure = (className, key) => {
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
if (!el) {
el = document.createElement('div');
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
el.dataset.triageGroup = key;
el.setAttribute('aria-hidden', 'true');
container.appendChild(el);
}
return el;
};
for (const group of wanted.values()) {
const head = ensure('tab-triage-head', group.key);
// A quiet group (idle) keeps its heading as the row's anchor but draws
// no label or count.
head.classList.toggle('tab-triage-head--quiet', !!group.quiet);
if (!group.quiet && !head.firstElementChild) {
const label = document.createElement('span');
label.className = 'tab-triage-label';
label.textContent = group.label;
const count = document.createElement('span');
count.className = 'tab-triage-count';
head.append(label, count);
}
const count = String(group.count);
if (!group.quiet && head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
// The first row's heading is the one that starts beside the brand in the
// header strip (styles.css); every later row starts under it.
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
const headOrder = String(group.headOrder);
if (head.style.order !== headOrder) head.style.order = headOrder;
const brk = ensure('tab-triage-break', group.key);
const breakOrder = String(group.breakOrder);
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
}
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
const wid = web.dataset.webviewId;
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
if (web.style.order !== value) web.style.order = value;
}
this._sizeTabTriageGutter(container, triage);
}
/**
* Size the header strip's two measured lengths (styles.css, "Header strip,
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
* label on screen so a row never carries a fixed gutter's worth of empty
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
* over the strip's top-left corner and only the FIRST row starts beside it,
* every later row starting under it.
*
* The labels are measured only when their text changes (a group appears,
* goes, or its count gains a digit), when the strip starts wrapping, and
* once more when the web fonts finish loading. Only the WRAPPING strip reads
* the label column, so the phone and tablet row (headings hidden under
* 600px, inline dividers above) is never measured and keeps no measurement:
* a phone turned to landscape or a foldable opened crosses into the wrapping
* strip with no tab render behind it, and updateTabOverflowMode(), which the
* resize handler calls, sizes it right after deciding to wrap. The brand is
* watched by a ResizeObserver (a display-name change, the sidebar toggle
* appearing), so a render pass never forces a layout read for it. The
* vertical lists use neither length and are never measured.
*/
_sizeTabTriageGutter(container, triage) {
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
if (!triage || !inHeader) {
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
this._tabTriageGutterKey = null;
return;
}
this._watchTabTriageBrand(container);
if (Number.isFinite(this._tabTriageBrandWidth)) {
const brand = `${this._tabTriageBrandWidth}px`;
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
container.style.setProperty('--tab-triage-brand', brand);
}
}
// Not wrapping: forget the measurement, so wrapping again measures afresh.
if (!container.classList.contains('tabs-auto-wrap') && !container.classList.contains('tabs-two-rows')) {
this._tabTriageGutterKey = null;
return;
}
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
if (key === this._tabTriageGutterKey) return;
let widest = 0;
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
// Laid-out parts only. A hidden heading measures 0 per part, and counting
// the 5px gap between its parts anyway turned that into a 15px column
// that was then cached as if measured.
let width = 0;
let parts = 0;
for (const part of head.children) {
const partWidth = part.getBoundingClientRect().width;
if (partWidth > 0) {
width += partWidth;
parts++;
}
}
if (parts) widest = Math.max(widest, width + 5 * (parts - 1));
}
// Hidden (display: none, or a detached strip): nothing to size, and no key
// either, so the next pass measures again.
if (!widest) return;
this._tabTriageGutterKey = key;
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
this._tabTriageFontsHooked = true;
document.fonts.ready.then(() => {
this._tabTriageGutterKey = null;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
}
}
/**
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
* step with the brand, once per page. The first observation arrives right
* after `observe()`, so the width is known from the first frame on.
*/
_watchTabTriageBrand(container) {
if (this._tabTriageBrandObserver !== undefined) return;
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
if (!brand || typeof ResizeObserver !== 'function') {
this._tabTriageBrandObserver = null;
return;
}
const gap = 8;
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
const box = entries[0]?.borderBoxSize?.[0];
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
if (width === this._tabTriageBrandWidth) return;
this._tabTriageBrandWidth = width;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
this._tabTriageBrandObserver.observe(brand);
}
/**
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
* group. Inside a group the rows sit in tab order, so a drop there moves the
* tab exactly where it was dropped; across groups the dragged tab would stay
* in its own group (its state or case did not change) and land somewhere the
* user did not put it. State groups are bands of `order` values, so comparing
* bands is enough; case clusters are boxes, so the box decides.
*/
_isTabDropAcrossGroups(targetTab) {
const container = this.$('sessionTabs');
if (!container || !this.draggedTabId || !targetTab) return false;
const triage = container.classList.contains('tabs-triage');
const clusters = container.classList.contains('tabs-clusters');
if (!triage && !clusters) return false;
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
if (!dragged) return false;
// Clusters are real boxes: a drop belongs to the box it lands in.
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
return band(dragged) !== band(targetTab);
}
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
isTabClusters() {
return document.documentElement.dataset.tabArrangement === 'case';
}
/**
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
* Discussion #426 option B): the flat list on an aligned column grid with a
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
* header strip; the rail and the sidebar keep their flat list.
*/
isTabLedger() {
return document.documentElement.dataset.tabArrangement === 'ledger';
}
/**
* Which case a session belongs to, for clustering: the case whose path is the
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
* home screens' own match), else the directory itself, else the session alone.
*/
_tabClusterIdentity(session, id) {
const dir = (session.workingDir || '').replace(/\/+$/, '');
const match =
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
if (match) return { key: match.path, label: match.name || '' };
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
return { key: `session:${id}`, label: '' };
}
/**
* The cluster layout for one render pass, or null when the list is not
* clustered. Named groups in the vertical rail win, exactly as for the state
* grouping. `key` is the whole structure as a string: the incremental render
* path compares it with the last full render's and rebuilds when it differs,
* because a cluster is a real box and a patch in place cannot move a tab into
* another one. Membership only changes when sessions come and go (already a
* full rebuild) or when the case list arrives, so this rarely fires.
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
*/
_tabClusterLayout(ids, groupProjection) {
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
const rows = [];
for (const id of ids) {
const session = this.sessions.get(id);
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
}
const clusters = window.CodemanTabClusters.compute(rows);
// Only a cluster with company drops the case from its tab names.
const labelFor = new Map();
for (const cluster of clusters) {
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
return { clusters, labelFor, webviewIds, key };
}
/**
* The cluster boxes for the full render: one box per case, labelled with its
* colour swatch, name and count, and a box per open web tab, which has no
* case. The header strip shows only the swatch for a case with one tab
* (styles.css); the rail and the sidebar label every case. Rows are the
* caller's own markup, so a tab is byte-identical to the flat strip's apart
* from its name split.
*/
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
const parts = [];
for (const cluster of layout.clusters) {
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
const single = cluster.ids.length < 2;
const head =
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
parts.push(
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
);
}
layout.webviewIds.forEach((wid, i) => {
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
});
return parts.join('');
}
/**
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
* just the description, the generated id kept in a hidden prefix span. Inside
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
* name stays in the DOM (copy, find-in-page, the rename editor).
*/
_tabNameHtml(name, clusterLabel) {
const parsed = parseSessionPrefix(name);
if (parsed && parsed.suffix) {
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
}
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
return escapeHtml(name);
}
/**
* The arrangement classes on #sessionTabs that are not owned by a sync of
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
* ledger never applies inside the grouped rail.
*/
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
if (!container) return;
container.classList.toggle('tabs-clusters', !!clusters);
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -5255,6 +5743,9 @@ class CodemanApp {
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
@@ -5492,6 +5983,13 @@ class CodemanApp {
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
// Where the new active tab sits now, so the render pass that follows can
// tell when it changes state band (viewing a waiting tab spends its alert
// and drops it into the idle row).
this._noteActiveTabBand(container);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
}
/**
@@ -5549,6 +6047,54 @@ class CodemanApp {
}
}
/**
* Record the active tab's state band and report whether it MOVED band since
* the last record while staying the active tab.
*
* Grouped by state (tabArrangement 'state'), the phone/tablet strip is still
* one scrolling row, but its tabs come in bands of `order` values, one band
* per state (CodemanTabTriage, constants.js). The active session changing
* state (a prompt sent: idle to working; a permission prompt: needs you)
* moves its tab into another band while scrollLeft stays put, so the tab in
* use slid out of view (measured at 390px: x 165 to -870). Both render paths
* reveal it when this says so.
*
* The band, not the raw order: another tab entering or leaving the active
* tab's band shifts its order value by one, and that must not yank a strip
* the user may be browsing. A changed active tab is not a move either: the
* switch already revealed it (#257). Read off the element, so the record is
* what is on screen, and called from _updateActiveTabImmediate() too, so a
* band change in the pass right after a switch still counts.
*
* @returns {boolean}
*/
_noteActiveTabBand(container) {
const id = this.activeWebviewId ? null : this.activeSessionId;
const tab = id && container ? container.querySelector(`.session-tab[data-id="${id}"]`) : null;
const prev = this._activeTabBand;
if (!tab) {
this._activeTabBand = null;
return false;
}
const order = tab.style.order;
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = order === '' ? null : Math.floor(Number(order) / stride);
this._activeTabBand = { id, band };
return !!prev && prev.id === id && prev.band !== band;
}
/**
* True when the tab list is the header's one horizontally scrolling row
* (phones and tablets): neither wrapping into rows nor a vertical list.
*/
_isScrollingTabRow(container) {
return (
!this._isVerticalTabList() &&
!container.classList.contains('tabs-auto-wrap') &&
!container.classList.contains('tabs-two-rows')
);
}
/**
* Where a floating window (subagent / ultracode) attaches to its parent tab.
* Header strip: below the tab, connector runs vertically. Sidebar AND the
@@ -5663,11 +6209,18 @@ class CodemanApp {
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
// Case clusters are boxes too: the same rule, keyed on their structure.
const clusterLayout = this._tabClusterLayout(
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
groupProjection
);
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
!this._isTabGroupStructureStale(groupProjection) &&
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
let activeBandMoved = false;
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
// the DOM and the loop below runs for every session on every SSE tick.
@@ -5676,7 +6229,13 @@ class CodemanApp {
// that sees one — a session going working→idle never adds or removes a
// tab, so the full rebuild below is not reached. Recomputed per pass for
// the same reason the rich meta line is: the order IS the state.
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state: same reasoning, a state change moves a tab between
// rows. Its order values replace the rail sort's (which it already folded
// in as the rank inside each section).
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -5684,7 +6243,7 @@ class CodemanApp {
// An empty string clears the property, which is also what un-sorts the
// rail when the setting (or the layout) flips without a full rebuild.
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
if (tab.style.order !== railOrder) tab.style.order = railOrder;
// A web tab owns the active state while one is open. activeSessionId stays
@@ -5786,11 +6345,17 @@ class CodemanApp {
const _p = parseSessionPrefix(name);
if (nameEl.dataset.fullName !== name) {
nameEl.replaceChildren();
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
if (_p && _p.suffix) {
const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix));
} else if (_split) {
const caseSpan = document.createElement('span');
caseSpan.className = 'tab-name-case';
caseSpan.textContent = _split.hidden;
nameEl.append(document.createTextNode(_split.shown), caseSpan);
} else {
nameEl.textContent = name;
}
@@ -5865,6 +6430,11 @@ class CodemanApp {
this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
// A state change is this path's job, and so is the active tab changing
// state band: revealed below, once the wrap mode is current.
activeBandMoved = this._noteActiveTabBand(container);
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
@@ -5876,6 +6446,7 @@ class CodemanApp {
this._lastRenderedActiveTabId = this.activeSessionId;
this.updateTabOverflowMode();
if (activeBandMoved && this._isScrollingTabRow(container)) this._scrollActiveTabIntoView(this.activeSessionId);
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
this._applyTabEntrances?.();
@@ -5887,13 +6458,15 @@ class CodemanApp {
this._refreshHomeSessionsIfVisible?.();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage arcs off their anchors. Only pay for it when there
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
// (sidebar, where lineage is skipped and the edge count stays 0, or the
// rail, which can show connectors with zero lineage edges too) — the
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
// Same widening as the strip-scroll listener in session-lineage.js.
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
// it, sliding the lineage lines off their anchors. Only pay for it when there
// is something anchored to tab rects: any lineage at all (not just what is
// drawn now: a parentSessionId or a child's working state can arrive on this
// path and change the selected family's tree), or, in a VERTICAL list (the
// sidebar, where lineage is skipped, or the rail, which can show connectors
// with zero lineage edges too), the subagent/ultracode connectors, whose
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
// refreshed _lineageTotalEdges.
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
}
@@ -5905,6 +6478,10 @@ class CodemanApp {
const container = this.$('sessionTabs');
if (!container) return;
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
// The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) {
@@ -5930,6 +6507,19 @@ class CodemanApp {
if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap');
// Wrap decided: the state labels' column follows it (_sizeTabTriageGutter).
this._sizeTabTriageGutter(container, this._lastTabTriage);
return;
}
// Grouped by state, the header strip IS rows (one per state), so it always
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
// is a grid of rows, so the same holds. Narrower screens keep the single
// scrolling row above, where state headings read as inline dividers and the
// ledger stays the plain strip.
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
container.classList.add('tabs-auto-wrap');
this._sizeTabTriageGutter(container, this._lastTabTriage);
return;
}
@@ -5942,12 +6532,29 @@ class CodemanApp {
tabCount: this.sessions.size,
scrollWidth: container.scrollWidth,
clientWidth: container.clientWidth,
innerWrap: container.classList.contains('tabs-clusters') && this._tabClustersWrapInside(container),
})
: container.scrollWidth > container.clientWidth + 1;
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
* strip then has rows although it never overflows, so it must still wrap, or
* the lineage routing room (`.lineage-tree.tabs-auto-wrap`) is never reserved
* and the routes have no gap between the box's rows to run in. Read right
* after the overflow measure, so layout is already clean.
*/
_tabClustersWrapInside(container) {
for (const box of container.querySelectorAll(':scope > .tab-cluster')) {
const tabs = box.querySelectorAll(':scope > .session-tab');
if (tabs.length > 1 && tabs[tabs.length - 1].offsetTop > tabs[0].offsetTop + 4) return true;
}
return false;
}
// Middle-click closes a tab, mirroring browser tab strips. Session tabs go
// through requestCloseSession (the same confirm modal as the x button), web
// tabs through closeWebviewTab (same as theirs). Delegated on the container:
@@ -6025,7 +6632,17 @@ class CodemanApp {
// below still counts the strip, not the sorted list. Null in every other
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state (tabArrangement 'state', opt-in): the same `order` mechanism,
// one band of values per state. Null in the grouped rail and with grouping
// off, and the rows then carry exactly the inline order they did before.
const groupProjection = this._projectTabGroups();
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// Clustered by case: rows are wrapped in one box per case below, and a tab
// in a cluster with company drops the `-<case>` from its name.
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
@@ -6034,7 +6651,7 @@ class CodemanApp {
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
@@ -6067,9 +6684,7 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
@@ -6090,6 +6705,15 @@ class CodemanApp {
// (direct-PTY, remote SSH, docker). See paneExitLabel().
const paneExitBadge = paneExitLabel(session.paneExit);
// Which harness runs here. A shell keeps its SH pill (it is not an agent);
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
const tabModeHtml = mode === 'shell'
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
@@ -6099,7 +6723,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
${tabModeHtml}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
@@ -6116,8 +6740,12 @@ class CodemanApp {
_tabIdx++;
}
const groupProjection = this._projectTabGroups();
if (groupProjection) {
if (clusterLayout) {
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
// every session), each in a box of its own.
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
this._hiddenTabGroupByRef = new Map();
} else if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
@@ -6143,6 +6771,7 @@ class CodemanApp {
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
@@ -6151,16 +6780,20 @@ class CodemanApp {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
// only when it CHANGED, or moved to another state band in the scrolling row
// (_noteActiveTabBand), or on the first paint. Restoring unconditionally
// and revealing conditionally is what lets someone browse the far end of
// the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft;
container.scrollTop = prevScrollTop;
this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
if (isFirstRender || prevActiveTabId !== this.activeSessionId || activeBandMoved) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
}
@@ -6225,9 +6858,13 @@ class CodemanApp {
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted()) {
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
tabs.sort((a, b) => orderOf(a) - orderOf(b));
// Case clusters are boxes in DOM order and a sorted rail orders rows
// INSIDE each one, so the box goes first in the key.
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
}
const currentIndex = tabs.indexOf(document.activeElement);
@@ -7431,6 +8068,9 @@ class CodemanApp {
});
tab.addEventListener('dragover', (e) => {
// Grouped by state: a tab in another group is not a drop target, and
// leaving the event alone (no preventDefault) is what shows "no drop".
if (this._isTabDropAcrossGroups(tab)) return;
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
@@ -7460,6 +8100,7 @@ class CodemanApp {
tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
if (this._isTabDropAcrossGroups(tab)) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
+489 -117
View File
@@ -207,6 +207,9 @@ function shouldAutoWrapTabs(input) {
if (!input || input.deviceType !== 'desktop') return false;
if (input.manualTwoRows) return false;
if ((input.tabCount || 0) < 2) return false;
// A box in the strip already wraps inside itself (a case cluster wider than the
// whole strip): the strip has rows although nothing overflows.
if (input.innerWrap) return true;
const scrollWidth = Number(input.scrollWidth) || 0;
const clientWidth = Number(input.clientWidth) || 0;
@@ -296,139 +299,307 @@ function computeTabScrollLeft(input) {
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
// Session lineage lines: geometry for the lines joining a tab to the tabs it
// spawned (a worker started through the codeman agent skill, which passes its own
// id as parentSessionId). Pure: the caller measures and appends, this decides.
//
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
// bracket joining two tabs rather than as a line crossing them. The dip grows with
// horizontal distance and with `depth` (the child's index among its siblings), so
// several children of one parent nest instead of overprinting.
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
// a parent's routes share their first stretch exactly: overlaid, they read as one
// trunk with a branch per child, and a dashed (working) route stays in phase with
// its siblings along the shared part.
//
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
// had a 4px span to work with and drew a flat horizontal line inside the row gap
// (reported as "they connect already, but the lines are straight and not easy visible"),
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
// row once, and comes back up under the child. Same formula, no branch.
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
// child hanging below the strip, which in a wrapped strip crossed every lower
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
// row 3 with ten children, "too confusing"). A route now moves horizontally only
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
// the gap right under it) or along the SPINE, a channel left of every row that
// joins the gaps of different rows. styles.css reserves that room
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
// row's gap, and the spine channel on the left of a wrapped strip).
//
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
// scrolled-out tab still HAS a rect — one lying over the logo or the header
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
// directions, so treat these numbers as a corridor rather than a dial to crank:
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
// bottom when the strip rect is missing or shorter than its tabs), which buys two
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
// drawn through them (the retune's own first draft had exactly that regression).
const LINEAGE_DIP_BASE_PX = 14;
const LINEAGE_DIP_PER_PX = 0.06;
const LINEAGE_DIP_MIN_PX = 22;
const LINEAGE_DIP_MAX_PX = 64;
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
// The channel is at the strip's left edge, except where a tab arrangement puts
// something there: grouped by state, the edge holds the label column and the
// channel opens between the labels and the tabs. session-lineage.js measures it
// and passes its left edge as `spineLeft`; without one it is the strip's edge.
//
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
// taller), or siblings in one row would hang their bus at different heights.
//
// computeLineageTree() returns null when the parent cannot be drawn (missing or
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
// would point at a tab that is not there.
//
// A child is also left out when its route has nowhere to run: rows whose spans
// overlap are one row (no gap between them), a row with no gap under it routes
// nothing, and a route that would still cross a tab is dropped whole. So no tab
// arrangement can put a line through a tab; a layout without the reserved room
// loses lines instead.
const LINEAGE_CORNER_RADIUS_PX = 10;
// Families drawn together take separate lanes: gap lines this far apart, spines
// LINEAGE_SPINE_STEP_PX apart.
const LINEAGE_LANE_STEP_PX = 3.5;
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
const LINEAGE_MAX_LANES = 3;
const LINEAGE_SPINE_INSET_PX = 6;
const LINEAGE_SPINE_STEP_PX = 4;
// The last row has no row below it; with no strip rect to measure, its gap is
// taken to be this deep.
const LINEAGE_LAST_GAP_PX = 12;
// Narrower than this, the space between two rows is not a gap a route can run in.
const LINEAGE_MIN_GAP_PX = 2;
const LINEAGE_ROW_TOLERANCE_PX = 6;
const LINEAGE_STRIP_TOLERANCE_PX = 4;
// How far the vertical bracket sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
// glow, so a track at 6px had half of that glow clipped away and the arc read
// as a thin thread pinned to the window frame. The rail reserves the channel
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
// computeLineagePath still clamps the track to stay left of both tabs.
// How far the vertical rail's track sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
// to the window frame. The rail reserves the channel itself
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
// still clamped to stay left of every endpoint.
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
// (session-lineage.js). Every line leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
// which every skin block tunes for its own background, so a lone arc keeps the
// which every skin block tunes for its own background, so a lone family keeps the
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
// they ride the same double glow as the blue, which is what keeps them legible over
// terminal text on every skin.
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
function computeLineagePath(input) {
const parent = input?.parent;
const child = input?.child;
if (!parent || !child) return null;
/** A rect normalized to numbers with its edges and center, or null if unusable. */
function lineageRect(rect) {
if (!rect) return null;
const left = Number(rect.left);
const top = Number(rect.top);
const width = Number(rect.width);
const height = Number(rect.height);
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
cx: left + width / 2,
cy: top + height / 2,
};
}
const pw = Number(parent.width) || 0;
const ph = Number(parent.height) || 0;
const cw = Number(child.width) || 0;
const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
/**
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
* gap for everyone in it.
*
* ⚠ Rows never overlap. Tabs whose spans overlap are in one row however far apart
* their tops are: case clusters stack and centre tabs inside their boxes, and two
* overlapping "rows" put the gap of one in the middle of the other's tabs.
*/
function computeLineageRows(rects) {
const sorted = [];
for (const raw of rects || []) {
const r = lineageRect(raw);
if (r) sorted.push(r);
}
sorted.sort((a, b) => a.top - b.top);
const rows = [];
for (const r of sorted) {
// Sorted by top, so only the last row can take this rect, and merging into it
// keeps every earlier row clear of it.
const row = rows[rows.length - 1];
if (row && (r.top < row.bottom || r.top - row.top <= LINEAGE_ROW_TOLERANCE_PX)) {
row.bottom = Math.max(row.bottom, r.bottom);
} else {
rows.push({ top: r.top, bottom: r.bottom });
}
}
return rows;
}
/** Does an axis-aligned segment pass through the inside of a rect? Touching an edge is fine. */
function lineageSegmentCrosses([x1, y1], [x2, y2], r) {
const eps = 0.5;
if (Math.max(x1, x2) <= r.left + eps || Math.min(x1, x2) >= r.right - eps) return false;
return Math.max(y1, y2) > r.top + eps && Math.min(y1, y2) < r.bottom - eps;
}
/**
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
* (never more than half of either segment, so short stems stay short). Repeated and
* collinear points are dropped first, so a degenerate corner draws nothing odd.
*/
function lineagePolylinePath(points, radius) {
const pts = [];
for (const p of points) {
const prev = pts[pts.length - 1];
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
const before = pts[pts.length - 2];
if (before && prev) {
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
if (Math.abs(cross) < 0.01) pts.pop();
}
pts.push(p);
}
if (pts.length < 2) return null;
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
for (let i = 1; i < pts.length - 1; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const [x2, y2] = pts[i + 1];
const lenIn = Math.hypot(x1 - x0, y1 - y0);
const lenOut = Math.hypot(x2 - x1, y2 - y1);
const r = Math.min(radius, lenIn / 2, lenOut / 2);
if (!(r > 0.5)) {
d += ` L ${r1(x1)} ${r1(y1)}`;
continue;
}
const ax = x1 - ((x1 - x0) / lenIn) * r;
const ay = y1 - ((y1 - y0) / lenIn) * r;
const bx = x1 + ((x2 - x1) / lenOut) * r;
const by = y1 + ((y2 - y1) / lenOut) * r;
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
}
const last = pts[pts.length - 1];
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
}
/**
* Routes from one parent tab to each of its children.
*
* input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?,
* orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in
* the strip (rows are derived from it); `spineLeft` is the left edge of the
* spine channel (default: the strip's left edge); `lane`/`laneCount`
* separate families drawn together.
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
*/
function computeLineageTree(input) {
const parent = lineageRect(input?.parent);
if (!parent) return null;
const strip = lineageRect(input?.strip);
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const strip = input?.strip;
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pLeft = Number(parent.left);
const cLeft = Number(child.left);
const pTop = Number(parent.top);
const cTop = Number(child.top);
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
const children = [];
for (const child of input?.children || []) {
const rect = lineageRect(child?.rect);
if (rect) children.push({ id: child.id, rect });
}
const tol = LINEAGE_STRIP_TOLERANCE_PX;
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
const routes = [];
if (orientation === 'vertical') {
const py = pTop + ph / 2;
const cy = cTop + ch / 2;
if (strip && Number(strip.height) > 0) {
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
if (py < min || py > max || cy < min || cy > max) return null;
// The rail: one track down the empty left gutter, shared by every sibling.
if (!inStripY(parent)) return null;
const visible = children.filter((c) => inStripY(c.rect));
if (visible.length === 0) return { routes };
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const trackX = Math.min(
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
);
for (const { id, rect } of visible) {
const points = [
[parent.left, parent.cy],
[trackX, parent.cy],
[trackX, rect.cy],
[rect.left, rect.cy],
];
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
}
const stripLeft =
strip && Number.isFinite(Number(strip.left))
? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const requestedTrack =
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
return { d, endX: cLeft, endY: cy, sameRow: false };
return { routes };
}
const px = pLeft + pw / 2;
const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null;
if (!inStripX(parent) || !inStripY(parent)) return null;
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
if (visible.length === 0) return { routes };
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
// gap never pushes a lane into the tabs on either side of it. Null when there is
// no gap: the row is not found, or the next row starts (nearly) where it ends.
const gapUnder = (i) => {
const row = rows[i];
if (!row) return null;
const next = rows[i + 1];
let bottom;
if (next) bottom = next.top;
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
if (!(bottom - row.bottom >= LINEAGE_MIN_GAP_PX)) return null;
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
};
const pRow = rowOf(parent);
const gp = gapUnder(pRow);
if (gp === null) return { routes };
// The spine runs from one row's gap to another's, so it only ever passes BESIDE
// rows after the first, and only their tabs bound it. The first row may start
// left of the channel (grouped by state it starts after the brand and a label
// of its own width), and clamping to it would pull the spine back over the
// label column.
const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)]
.filter((r) => r && rowOf(r) > 0)
.map((r) => r.left);
const minLeft = lowerLefts.length
? Math.min(...lowerLefts)
: Math.min(parent.left, ...visible.map((c) => c.rect.left));
const channelLeft = Number(input?.spineLeft);
const spineBase = strip
? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left)
: minLeft - LINEAGE_SPINE_INSET_PX * 2;
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
// Every tab a route must stay out of. A segment can only cross one if the rows
// above failed to describe the layout, so this is a backstop that drops the
// route whole rather than drawing it through a tab.
const obstacles = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)].filter(Boolean);
for (const { id, rect } of visible) {
const cRow = rowOf(rect);
const gc = gapUnder(cRow);
if (gc === null) continue;
const points =
cRow === pRow
? [
[parent.cx, parent.bottom],
[parent.cx, gp],
[rect.cx, gp],
[rect.cx, rect.bottom],
]
: [
[parent.cx, parent.bottom],
[parent.cx, gp],
[spineX, gp],
[spineX, gc],
[rect.cx, gc],
[rect.cx, rect.bottom],
];
// The rounded corners cut inside each turn by a few pixels at most
// (lineagePolylinePath), so the segments are what has to clear the tabs.
const blocked = points.some((p, i) => i > 0 && obstacles.some((r) => lineageSegmentCrosses(points[i - 1], p, r)));
if (blocked) continue;
const d = lineagePolylinePath(points, radius);
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
}
return { routes };
}
const pBottom = pTop + ph;
const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
// sitting above further rows (see the corridor note above the constants).
const span = Math.abs(cx - px);
const stripBottom =
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
? Number(strip.top) + Number(strip.height)
: Number.NEGATIVE_INFINITY;
const baseline = Math.max(pBottom, cBottom, stripBottom);
const dip =
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
depth * LINEAGE_SIBLING_STEP_PX;
const yc = baseline + dip;
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
return { d, endX: cx, endY: cBottom, sameRow };
function roundPoints(points) {
return points.map(([x, y]) => [r1(x), r1(y)]);
}
// One decimal is plenty for a screen-space path and keeps the `d` string short.
@@ -658,6 +829,194 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
}
// Tab grouping by state (`tabArrangement: 'state'`, Discussion #426 option C).
//
// The tab list answers "who wants me?" the way the home screens do: a row (the
// header strip) or a section (the flat side rail, the sidebar) per state, most
// urgent on top. The states are the home screens' own, from
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
// four groups a strip can hold:
//
// needs red: a permission or question dialog is blocking the agent. A
// failed session joins it, since it also needs a human and the home
// screens rank it right below.
// waiting yellow: the agent finished its turn and is waiting on you.
// working a turn is running.
// idle everything quiet: idle, ended, and an agent that exited inside a
// live pane (#446), which may still read as working on screen but is
// running nothing. Web tabs close the group.
//
// Applied as the flex `order` property, never by reordering the DOM, the same
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
// the list they always read, and a state change moves one inline style instead
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
// values: its heading at the start of the band, its rows after it, its web tabs
// after those and the line break that ends the header row at the very end.
//
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
// `quiet` groups keep their heading element (it anchors the row and holds the
// row's place beside the brand) but draw no text: everything quiet is the
// default state of a tab, so naming it only adds noise.
const TAB_TRIAGE_GROUPS = [
{ key: 'needs', label: 'Needs you' },
{ key: 'waiting', label: 'Waiting' },
{ key: 'working', label: 'Working' },
{ key: 'idle', label: 'Idle', quiet: true },
];
const TAB_TRIAGE_GROUP_OF_STATE = {
needs: 'needs',
error: 'needs',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'idle',
};
const TAB_TRIAGE_STRIDE = 10000;
/** Offset of a group's web tabs inside its band, past any plausible session count. */
const TAB_TRIAGE_WEB_OFFSET = 5000;
/**
* Which group a session belongs to.
* @param {string} state a `_mobileOverviewState()` value
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
* @returns {'needs'|'waiting'|'working'|'idle'}
*/
function tabTriageGroupFor(state, exited) {
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
return exited && group === 'working' ? 'idle' : group;
}
/**
* Order values and visible groups for one pass.
*
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
* live sessions; `pos` ranks a row inside its group (tab order on the header
* strip, the activity sort's position on a sorted rail). Rows without one keep
* the order they were passed in.
* @param {Array<string>} webviewIds open web tabs, in their own tab order
* @param {{reverse?: boolean}} [options] `reverse` puts the groups the other
* way up (`tabStateOrder: 'urgent-last'`): idle first, needs you last, for a
* strip read from the bottom. Rows inside a group keep their order.
* @returns {{
* order: Map<string, number>,
* webOrder: Map<string, number>,
* groups: Array<{key: string, label: string, quiet: boolean, count: number, headOrder: number, breakOrder: number}>
* }} `groups` lists only the non-empty groups, in display order (most urgent
* first, or last with `reverse`).
*/
function computeTabTriageLayout(rows, webviewIds, options) {
const list = Array.isArray(rows) ? rows : [];
const webs = Array.isArray(webviewIds) ? webviewIds : [];
const sequence = options && options.reverse ? TAB_TRIAGE_GROUPS.slice().reverse() : TAB_TRIAGE_GROUPS;
const baseOf = {};
const counts = {};
sequence.forEach((group, i) => {
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
counts[group.key] = 0;
});
const placed = list
.filter((row) => row && typeof row.id === 'string')
.map((row, i) => ({
id: row.id,
group: tabTriageGroupFor(row.state, !!row.exited),
pos: Number.isFinite(row.pos) ? row.pos : i,
index: i,
}));
const byGroup = {};
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
const order = new Map();
for (const key of Object.keys(byGroup)) {
// Stable: equal positions keep the order the caller passed.
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
counts[key] = byGroup[key].length;
}
const webOrder = new Map();
webs.forEach((id, i) => {
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
});
counts.idle += webOrder.size;
const groups = sequence.filter((group) => counts[group.key] > 0).map((group) => ({
key: group.key,
label: group.label,
quiet: !!group.quiet,
count: counts[group.key],
headOrder: baseOf[group.key],
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
}));
return { order, webOrder, groups };
}
// Tab clusters by case (`tabArrangement: 'case'`, Discussion #426 option A).
//
// One cluster per case, in the order the case first appears in the tab order,
// so the strip keeps the user's arrangement at the case level. Inside a cluster
// with two or more tabs the `-<case>` part of a generated `w<n>-<case>` name is
// redundant and is hidden (`tabClusterNameSplit()`), which is what lets 18 tabs
// read as 7 things. A case with one tab is still its own (unlabelled) box.
//
// Cluster colours come from the session palette (`--session-<colour>`) by a
// stable hash of the case key, so a case keeps its colour across reloads and
// devices without anything being stored.
//
// Pure: no DOM, no `this`. Unit-tested in test/tab-clusters.test.ts.
const TAB_CLUSTER_COLORS = ['blue', 'green', 'purple', 'orange', 'pink', 'yellow', 'red'];
/** Palette colour for a cluster key: a djb2 hash, so the same key always gets the same colour. */
function tabClusterColorFor(key) {
const text = String(key || '');
let hash = 5381;
for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
return TAB_CLUSTER_COLORS[Math.abs(hash) % TAB_CLUSTER_COLORS.length];
}
/**
* Group tabs by case.
* @param {Array<{id: string, key: string, label: string}>} rows live sessions in
* tab order; `key` identifies the case (its path), `label` names it
* @returns {Array<{key: string, label: string, color: string, ids: string[]}>}
* clusters in first-appearance order, members in tab order
*/
function computeTabClusters(rows) {
const clusters = [];
const byKey = new Map();
for (const row of Array.isArray(rows) ? rows : []) {
if (!row || typeof row.id !== 'string') continue;
const key = typeof row.key === 'string' && row.key ? row.key : `session:${row.id}`;
let cluster = byKey.get(key);
if (!cluster) {
cluster = { key, label: typeof row.label === 'string' ? row.label : '', color: tabClusterColorFor(key), ids: [] };
byKey.set(key, cluster);
clusters.push(cluster);
}
cluster.ids.push(row.id);
}
return clusters;
}
/**
* Split a generated `w<n>-<case>` / `s<n>-<case>` name into the part a cluster
* shows and the case suffix it hides, or null when the name is anything else
* (a custom name, a described `w3-x: fix login`, another case's name). Case is
* compared case-insensitively; the hidden part keeps its original spelling so
* the full name is still in the DOM.
* @returns {{shown: string, hidden: string}|null}
*/
function tabClusterNameSplit(name, label) {
if (typeof name !== 'string' || typeof label !== 'string' || !label) return null;
const match = name.match(/^([ws]\d+)(-.+)$/);
if (!match) return null;
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -961,12 +1320,13 @@ if (typeof window !== 'undefined') {
plan: planWsReconnect,
};
window.CodemanLineage = {
computePath: computeLineagePath,
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
computeTree: computeLineageTree,
computeRows: computeLineageRows,
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
MAX_LANES: LINEAGE_MAX_LANES,
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS,
};
window.CodemanConnectionLoss = {
@@ -983,6 +1343,18 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanTabClusters = {
COLORS: TAB_CLUSTER_COLORS,
colorFor: tabClusterColorFor,
compute: computeTabClusters,
nameSplit: tabClusterNameSplit,
};
window.CodemanTabTriage = {
GROUPS: TAB_TRIAGE_GROUPS,
STRIDE: TAB_TRIAGE_STRIDE,
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1458,7 +1830,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1476,7 +1848,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+1
View File
@@ -692,6 +692,7 @@ Object.assign(CodemanApp.prototype, {
<span class="tab-number">${base + i + 1}</span>
<span class="tab-status idle" aria-hidden="true"></span>
<span class="tab-info"><span class="tab-name-row">
<span class="tab-harness run-mode-dot claude" aria-hidden="true"></span>
<span class="tab-name">w${base + i + 1}-demo</span>
</span></span>`;
container.appendChild(tab);
+55 -9
View File
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
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' : ''}`);
const query = new URLSearchParams(this.gitStatusLimits());
if (fresh) query.set('fresh', '1');
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
this._gitStatus = data ? { sessionId: sid, data } : null;
} catch {
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
},
/**
* How many repositories to list below a folder that is not itself a repository, and how long one
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
*/
gitStatusLimits() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const num = (v, min, max, fallback) => {
const n = Math.trunc(Number(v));
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
};
return {
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
};
},
/** Whether the Git window groups changed files under collapsible folders (default on). */
isGitStatusTree() {
const settings = this.loadAppSettingsFromStorage();
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
let uncommitted = 0;
let unpushed = 0;
let conflicted = 0;
let unreadable = 0;
for (const r of overview.repos) {
if (r.status.state === 'error') unreadable += 1;
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 };
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
},
/** One sentence for the tooltip and the screen-reader label. */
@@ -168,12 +190,14 @@ Object.assign(CodemanApp.prototype, {
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 (sum.unreadable)
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
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';
where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch';
}
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
},
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
if (!parts.length) parts.push('✓');
if (label) label.textContent = parts.join(' ');
const sentence = this._gitStatusSentence(data);
@@ -333,17 +358,23 @@ Object.assign(CodemanApp.prototype, {
}
const repos = overview.repos;
if (repos.length === 1) {
// One repository: the panel is that repository, as it always was.
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
// One repository: the panel is that repository, as it always was. One that git could not read,
// or the only one shown of several (the limit is 1), takes the list view below instead, so its
// error row or the "Showing the first" notice is not lost.
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`;
if (head) head.textContent = repos.length === 1 ? '1 repository' : `${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.`)
el(
'div',
'git-status-more',
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
)
);
}
}
@@ -364,6 +395,16 @@ Object.assign(CodemanApp.prototype, {
_gitRepoSection(r) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const d = r.status;
if (d.state === 'error') {
// Kept in the list with the reason, rather than silently left out.
const row = el('div', 'git-status-repo git-status-repo--error');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
row.append(why);
return row;
}
const section = el('details', 'git-status-repo');
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
@@ -582,7 +623,12 @@ Object.assign(CodemanApp.prototype, {
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 qs = new URLSearchParams({
repo: repoRoot,
path: file.path,
kind: file.kind,
...this.gitStatusLimits(),
});
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;
+12 -9
View File
@@ -70,17 +70,13 @@ const HOME_SESSIONS_PILL_LABEL = {
done: 'done',
};
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
/**
* Text badge per backend, mirroring the tab strip: only the shell has one. Every
* agent CLI (claude included) shows its logo instead, through the same
* `run-mode-dot <id>` slot the strip uses (see _buildHomeSessionRow).
*/
const HOME_SESSIONS_MODE_BADGE = {
shell: 'sh',
opencode: 'oc',
codex: 'cx',
gemini: 'gm',
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
omp: 'om',
};
Object.assign(CodemanApp.prototype, {
@@ -430,6 +426,13 @@ Object.assign(CodemanApp.prototype, {
badge.setAttribute('data-i18n-skip', '');
badge.textContent = row.modeBadge;
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot).
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
line1.appendChild(logo);
}
const name = document.createElement('span');
// .session-name is in the i18n skip list: a session name is user content.
+117 -1
View File
@@ -45,6 +45,15 @@
// Exact English-source translations. Technical names, command examples, model
// names, keyboard chords, and user-authored content intentionally stay unchanged.
const ZH_CN = Object.freeze({
'Default Codex model': 'Codex 默认模型',
'Default Codex reasoning effort': 'Codex 默认思考强度',
'Use Codex configuration': '使用 Codex 配置',
'Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.':
'新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。',
'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.':
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
@@ -144,6 +153,14 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
'Only image files are supported': '仅支持图像文件',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
'Terminal not connected: its size is sent when it reconnects': '终端未连接:重新连接后会发送其尺寸',
'Could not determine terminal size': '无法确定终端尺寸',
'Failed to restore terminal size': '恢复终端尺寸失败',
// 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': '调整窗格列宽',
@@ -214,10 +231,16 @@
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run Shell': '运行 Shell',
'More tools': '更多工具',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
'Create new case': '新建案例',
'Link Existing': '关联现有目录',
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
// which replaced the "+" and gear buttons, and its empty state.
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -295,6 +318,7 @@
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Waiting: '等待中',
Today: '今天',
Home: '主页',
Local: '本地',
@@ -345,6 +369,53 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
'Tab Layout': '标签页布局',
'By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.':
'按状态:需要你、等待中、工作中和空闲各占一行。按案例:每个案例一个框。台账:对齐的列网格。经典:与以前相同的单一列表。按状态和按案例也会为侧边标签栏和侧边栏分组。Alt+1..9 仍按标签页顺序切换。',
'By state (rows per state)': '按状态(每种状态一行)',
'By case (clusters)': '按案例(分组框)',
'Ledger (aligned columns)': '台账(对齐的列)',
'Classic (default)': '经典(默认)',
'State Order': '状态顺序',
'For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.':
'用于按状态的标签页布局。选择在底部会倒转各行,让“需要你”紧挨在终端上方。',
'Needs you on top (default)': '“需要你”在顶部(默认)',
'Needs you at the bottom': '“需要你”在底部',
'Header Stats Style': '顶部栏状态样式',
'How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.':
'WS、CPU、MEM 和套餐用量窗口的显示方式。紧凑:在两个胶囊中每个数值旁显示一个圆环;磁贴:每个标签位于数值上方,下方带一条进度条。',
'As before (bars)': '与以前相同(进度条)',
'Compact (default)': '紧凑(默认)',
'Tiles (label over value)': '磁贴(标签在数值上方)',
// The connection tile's value word in that style (app.js
// _connectionTileValueText). Scoped keys on purpose: the bare words also
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
// badge in the resume list), and a bare key would translate those too.
'Connection tile: live': '已连接',
'Connection tile: fallback': '回退',
'Connection tile: offline': '离线',
'Connection tile: queued': '已排队',
'Connection tile: retry': '重连中',
// App Settings → Bottom bar, translated as one group (the Git status rows,
// #543's two included). Keys are the trimmed label text, without the scope tag.
'Bottom bar': '底部栏',
'Git status': 'Git 状态',
"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.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
'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.':
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
'Git status: max repositories': 'Git 状态:最多仓库数',
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
'Git status: git timeout': 'Git 状态:git 超时',
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
@@ -425,7 +496,6 @@
'Remote Access': '远程访问',
'Cloudflare Tunnel': 'Cloudflare 隧道',
'Tunnel URL': '隧道地址',
'Upload URL': '上传地址',
Updates: '更新',
'Current Version': '当前版本',
'Check for Updates': '检查更新',
@@ -666,6 +736,13 @@
'Nothing to copy': '没有可复制的内容',
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
'Session not found': '未找到会话',
// A native host that would not open a window (app.js openInHostWindow); the
// "dashboard" is a web tab.
'Could not open a new window for this session': '无法在新窗口中打开此会话',
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
// Dictation whose session closed before the text was sent (voice-input.js).
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
Copy: '复制',
@@ -827,6 +904,40 @@
'Wrap lines': '自动换行',
'Unsaved changes': '未保存的更改',
Saved: '已保存',
'Loading spreadsheet…': '正在加载电子表格…',
'This workbook is too large to preview (10 MB limit).': '此工作簿太大,无法预览(上限 10 MB)。',
'This workbook has no visible worksheets.': '此工作簿没有可见的工作表。',
'This worksheet is empty.': '此工作表为空。',
'Some workbook features are not shown': '部分工作簿功能未显示',
'Spreadsheet preview timed out.': '电子表格预览超时。',
'Spreadsheet preview failed': '电子表格预览失败',
'Spreadsheet parser failed.': '电子表格解析器出错。',
'Spreadsheet parser failed to start': '电子表格解析器启动失败',
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
'Spreadsheet parser message failed': '电子表格解析器消息出错',
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
// Worker refusals, one sentence per error code (spreadsheet-preview.js
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
// counted ones are patterns in translateDynamic below.
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
// The features the preview leaves out (spreadsheet-preview.js warningText).
// Scoped keys on purpose: a bare 'charts' or 'macros' key would also
// translate a folder or case of that name (a Helm chart's charts/, a dbt
// project's macros/) in the Files panel and the case picker.
'Spreadsheet feature: charts': '图表',
'Spreadsheet feature: drawings': '绘图',
'Spreadsheet feature: pivot tables': '数据透视表',
'Spreadsheet feature: external links': '外部链接',
'Spreadsheet feature: macros': '宏',
'Formula has no cached result': '公式没有缓存的计算结果',
'Unsupported cell value': '不支持的单元格值',
'Unsupported number format': '不支持的数字格式',
'Export as JSON': '导出为 JSON',
'Export as Markdown': '导出为 Markdown',
'Mark all read': '全部标为已读',
@@ -1054,6 +1165,11 @@
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
[
+91 -16
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -147,13 +147,14 @@
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="app._closeSoloWindow()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
</button>
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
<span class="connection-dot" id="connectionDot"></span>
<span class="connection-text" id="connectionText"></span>
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue" data-i18n-skip></span></span>
</div>
<div class="header-font-controls">
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
@@ -162,6 +163,7 @@
</div>
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
<div class="stat-item">
<span class="stat-ring" id="statCpuRing" aria-hidden="true"></span>
<span class="stat-label">CPU</span>
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
@@ -169,6 +171,7 @@
<span class="stat-value" id="statCpu">--%</span>
</div>
<div class="stat-item">
<span class="stat-ring" id="statMemRing" aria-hidden="true"></span>
<span class="stat-label">MEM</span>
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
@@ -457,9 +460,14 @@
<div class="welcome-content">
<h1 class="welcome-title">Codeman</h1>
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
<!-- Launchers (settings-ui.js renderWelcomeCliActions): one primary
button for the first agent in the registry catalog, then a chip
row for every other enabled CLI. The tunnel is not a launcher, so
it sits under them as a quiet link; settings-ui.js rewrites its
contents on every state change and owns its inline display. -->
<div class="welcome-actions">
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
<button type="button" class="welcome-tunnel-link" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
</button>
@@ -697,11 +705,9 @@
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
Enter
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
</div>
<!-- Run Shell had a second, identical instance-count stepper here. The
toolbar carried two of them side by side, so it is gone and Run
Shell reads the one above (#tabCount) like the Run button does. -->
<div class="case-select-group">
<div class="case-combobox" id="quickStartCasePicker">
<input
@@ -721,8 +727,9 @@
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
<option value="testcase">testcase</option>
</select>
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
<button class="btn-case-settings" onclick="app.toggleCaseSettings()" title="Case settings">&#x2699;</button>
<!-- "New or link a case…" and "Case settings…" are the last two rows of
the case picker's own list (CASE_PICKER_ACTIONS, session-ui.js); the
"+" and gear buttons that sat here were removed (owner, 1.36.0). -->
<div class="case-settings-popover hidden" id="caseSettingsPopover">
<label class="checkbox-inline">
<input type="checkbox" id="caseAgentTeams" onchange="app.onCaseSettingChanged()">
@@ -1937,6 +1944,17 @@
<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>
<div class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings">
<div class="set-row-text">
<span class="set-row-label">Header Stats Style <span class="set-tag">desktop</span></span>
<span class="set-row-desc">How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.</span>
</div>
<select id="appSettingsHeaderStatsStyle" class="set-select">
<option value="classic">As before (bars)</option>
<option value="compact">Compact (default)</option>
<option value="tiles">Tiles (label over value)</option>
</select>
</div>
</div>
</div>
@@ -1991,6 +2009,20 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git status maximum repositories folder many repos limit">
<div class="set-row-text">
<span class="set-row-label">Git status: max repositories <span class="set-scope">device</span></span>
<span class="set-row-desc">When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.</span>
</div>
<input type="number" id="appSettingsGitStatusMaxRepos" class="set-num" value="12" min="1" max="50" step="1">
</div>
<div class="set-row" data-search="git status timeout seconds slow share network">
<div class="set-row-text">
<span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span>
<span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span>
</div>
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
</div>
</div>
</div>
@@ -2089,6 +2121,28 @@
<div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="tab layout grouping group by state triage needs you waiting working idle case clusters ledger columns classic old new">
<div class="set-row-text">
<span class="set-row-label">Tab Layout</span>
<span class="set-row-desc">By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.</span>
</div>
<select id="appSettingsTabArrangement" class="set-select">
<option value="state">By state (rows per state)</option>
<option value="case">By case (clusters)</option>
<option value="ledger">Ledger (aligned columns)</option>
<option value="classic">Classic (default)</option>
</select>
</div>
<div class="set-row has-field" data-search="state order reverse needs you bottom top rows sections">
<div class="set-row-text">
<span class="set-row-label">State Order</span>
<span class="set-row-desc">For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.</span>
</div>
<select id="appSettingsTabStateOrder" class="set-select">
<option value="urgent-first">Needs you on top (default)</option>
<option value="urgent-last">Needs you at the bottom</option>
</select>
</div>
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text">
<span class="set-row-label">Tab Orientation</span>
@@ -2112,7 +2166,7 @@
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Order</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Layout by state or by case it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
</div>
<select id="appSettingsTabRailSort" class="set-select">
<option value="activity">By activity (home screen order)</option>
@@ -2172,7 +2226,7 @@
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
<span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
</div>
@@ -2546,6 +2600,30 @@
<div class="set-group" id="appSettingsCodexGroup">
<div class="set-group-head"><h4>Codex</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="codex default model">
<div class="set-row-text">
<span class="set-row-label">Default Codex model</span>
<span class="set-row-desc">Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.</span>
</div>
<input id="appSettingsCodexModel" class="set-input" type="text" maxlength="100" aria-label="Default Codex model" placeholder="Use Codex configuration" autocomplete="off" spellcheck="false">
</div>
<div class="set-row has-field" data-search="codex default reasoning effort thinking">
<div class="set-row-text">
<span class="set-row-label">Default Codex reasoning effort</span>
<span class="set-row-desc">Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.</span>
</div>
<select id="appSettingsCodexReasoningEffort" class="set-select" aria-label="Default Codex reasoning effort">
<option value="">Use Codex configuration</option>
<option value="none">none</option>
<option value="minimal">minimal</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
<option value="ultra">ultra</option>
</select>
</div>
<div class="set-row" data-search="codex bypass approvals sandbox">
<div class="set-row-text">
<span class="set-row-label">Bypass approvals and sandbox</span>
@@ -2952,10 +3030,6 @@
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">&#x229E;</button>
</div>
</div>
<div class="set-row" id="tunnelUploadUrlRow" style="display:none">
<div class="set-row-text"><span class="set-row-label">Upload URL</span></div>
<span id="tunnelUploadUrlDisplay" class="set-copy" title="Click to copy"></span>
</div>
</div>
</div>
</section>
@@ -3965,5 +4039,6 @@
<script defer src="ultracode-windows.js"></script>
<script defer src="session-lineage.js"></script>
<script defer src="image-input.js"></script>
<script defer src="spreadsheet-preview.js"></script>
</body>
</html>
+20
View File
@@ -410,6 +410,12 @@ const KeyboardHandler = {
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
// The mobile case picker is a third position:fixed bottom-anchored
// surface, and since it gained a search field the keyboard can open over
// it. iOS does not shrink the layout viewport, so an unlifted sheet sits
// BEHIND the keyboard with its own search box out of sight.
const caseSheet = document.querySelector('.mobile-case-picker.active .mobile-case-picker-sheet');
if (isSmallMedium) {
// Phones/small tablets: toolbar and accessory bar are position:fixed
// via CSS. Use translateY to lift them above the keyboard.
@@ -426,6 +432,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (caseSheet) {
caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
@@ -436,6 +445,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.bottom = `${keyboardHeight}px`;
}
if (caseSheet) {
caseSheet.style.bottom = `${keyboardHeight}px`;
}
}
// CJK textarea positioning (always position:fixed on touch devices).
@@ -464,6 +476,10 @@ const KeyboardHandler = {
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
// Not scoped to `.active`, unlike the lift above: a sheet closed while the
// keyboard was still up must still have its inline offset cleared, or the
// next open slides in already displaced.
const caseSheet = document.querySelector('.mobile-case-picker-sheet');
if (toolbar) {
toolbar.style.transform = '';
@@ -476,6 +492,10 @@ const KeyboardHandler = {
cjkInput.style.transform = '';
cjkInput.style.bottom = '';
}
if (caseSheet) {
caseSheet.style.transform = '';
caseSheet.style.bottom = '';
}
if (main) {
main.style.paddingBottom = '';
}
+111 -45
View File
@@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel {
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
rules in styles.css. A native wrapper that opens windows of its own (side
by side on a foldable) keeps it at tablet widths: app.js sets
html.host-windows. Phone widths hide it again in the 599px block. */
html:not(.host-windows) .session-tab .tab-detach {
display: none !important;
}
}
@@ -332,6 +334,34 @@ html.mobile-init .file-browser-panel {
}
}
/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to
767px). The strip is the tablet block's one scrolling row and
updateTabOverflowMode() never wraps it here, so the boxes, which may shrink
below 768px, squeezed and wrapped their tabs inside themselves instead of
overflowing: the strip never scrolled, and every tab past a box's first
line was clipped under the fixed header. The boxes dissolve into the chip
row as on phones (the 599px block), which keeps the tablet's own 40px chip
geometry; a 44px box would hang below the 48px header. Ends at 767px, not
at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the
desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the
strip. With the boxes, their labels and their case-colour borders gone, the
`-<case>` part of a generated name (.tab-name-case, which styles.css hides
in the clustered strip) is the only cue left to which case a chip is in: the
w<n> counter is per case, so w1-alpha and w1-beta would both read "w1". */
@media (min-width: 600px) and (max-width: 767px) {
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: contents;
}
:where(.header) .tab-cluster-label {
display: none;
}
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
display: inline;
}
}
/* Edge fade for the phone's header tab strip (used in the block below).
Registered so the keyframes can interpolate them as lengths; @property is
only valid at the top level, hence out here. */
@@ -755,6 +785,31 @@ html.mobile-init .file-browser-panel {
font-weight: 500;
}
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
row: the tabs still come in state order, most urgent first, but the
headings would cost chips and the dots already say which state is which.
The phone overview is where the labelled sections live. */
:where(.header) .tab-triage-head {
display: none;
}
/* Clustered by case, the same holds: the boxes dissolve into the one chip
row (in cluster order) and the labels go, since every chip still names
its session. It does so in full: the `-<case>` part of a generated name,
which styles.css hides in the clustered strip, shows again, or w1-alpha
and w1-beta (the w<n> counter is per case) would both read "w1". */
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: contents;
}
:where(.header) .tab-cluster-label {
display: none;
}
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
display: inline;
}
/* Only the active tab shows its action icons on a phone (see below), so on
every other tab the container is empty but still a flex item, and its gap
made the chip visibly wider on the right than on the left. */
@@ -852,6 +907,13 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones),
so the pop-out icon stays off phone tabs even under a window-opening host,
which offers the pop-out from its own chrome (app.detachSession). */
.session-tab .tab-detach {
display: none !important;
}
/* Gear icon on active tab - tiny, subtle */
.session-tab.active .tab-gear {
display: inline-flex;
@@ -1449,15 +1511,6 @@ html.mobile-init .file-browser-panel {
padding: 6px 14px;
}
/* Add case button - compact (keeping for when shown via menu) */
.btn-case-add {
display: none !important;
}
.btn-case-settings {
display: none !important;
}
/* When keyboard is visible, also move accessory bar up */
.keyboard-visible .keyboard-accessory-bar.visible {
/* Position is handled by JS transform along with toolbar */
@@ -1471,33 +1524,6 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Keep btn-case-add styling for tablet/future use */
.toolbar .btn-case-add {
min-width: 26px !important;
max-width: 26px !important;
width: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
height: 26px !important;
padding: 0 !important;
font-size: 0.9rem;
font-weight: bold;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.btn-case-add:hover,
.btn-case-add:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Panels on mobile - full width, positioned above toolbar, visibility controlled by JS */
.monitor-panel,
.subagents-panel {
@@ -1722,17 +1748,24 @@ html.mobile-init .file-browser-panel {
}
.welcome-actions {
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.welcome-btn {
/* Only reached with the phone overview switched off. The primary spans the
column; the chips keep wrapping at the finger size styles.css gives touch
screens (40px, its pointer: coarse rule). This rule loads later at equal
specificity, so a lower value here made a phone's chips shorter than a
tablet's; restating 40px also covers a narrow window with a mouse. */
.welcome-primary {
width: 100%;
justify-content: center;
min-height: 44px;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-size: 0.9rem;
}
.welcome-chip {
height: 40px;
border-radius: 20px;
}
.history-show-more {
@@ -2289,6 +2322,18 @@ html.mobile-init .file-browser-panel {
font-size: 1.5rem;
}
/* With the keyboard up the sheet is lifted above it (mobile-handlers.js), so
what is left to fit is much shorter than 80dvh of the layout viewport. Cap
the list rather than the sheet, so the search row and the Create button
stay on screen and only the rows scroll. */
.keyboard-visible .mobile-case-picker-sheet {
max-height: 45vh;
}
.keyboard-visible .mobile-case-picker-body {
max-height: 28vh;
}
.mobile-case-picker-footer {
padding-bottom: calc(12px + var(--safe-area-bottom));
}
@@ -3200,13 +3245,13 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
both stylesheets and repainted the armed modifier back to a resting button on
all four light skins. Excluding the state here fixes phone and tablet at once;
adding a class to the armed rules would only have moved the tie. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn:not(.armed)) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
background: var(--control-bg);
border-color: var(--control-border);
color: var(--text-dim);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
@@ -4038,3 +4083,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
touch-scrollable grid on phones. */
@media (max-width: 700px) {
.spreadsheet-sheet-tabs {
padding-inline: 4px;
scroll-snap-type: x proximity;
}
.spreadsheet-sheet-tab {
min-width: 96px;
min-height: 40px;
scroll-snap-align: start;
}
.spreadsheet-grid {
min-height: 55vh;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
}
+70 -1
View File
@@ -4178,6 +4178,8 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'xlsx') {
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
} else {
try {
// Bounded like the workspace text preview: a Range for the first
@@ -4274,6 +4276,9 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'spreadsheet') {
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
@@ -4329,6 +4334,12 @@ Object.assign(CodemanApp.prototype, {
*/
detachFilePreview() {
if (!this.filePreviewDetachUrl) return;
const hosted = this.openInHostWindow?.(this.filePreviewDetachUrl) ?? null;
if (hosted !== null) {
if (hosted) this.closeFilePreview();
else this.showToast('Could not open a new window for this preview', 'error');
return;
}
const win = window.open(this.filePreviewDetachUrl, '_blank');
if (!win) {
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
@@ -4347,6 +4358,9 @@ Object.assign(CodemanApp.prototype, {
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/
_stopFilePreviewMedia() {
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
// leaves both running, so tear them down with the rest of the media.
this._disposeSpreadsheetPreview();
const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) {
@@ -4361,6 +4375,42 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '';
},
/**
* Render an XLSX into the preview body via spreadsheet-preview.js, which
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
* so the server applies its preview size cap. Superseded by the next
* _stopFilePreviewMedia(), which runs on every open and on close.
*/
_openSpreadsheetPreview(bodyEl, url, size) {
this._disposeSpreadsheetPreview();
const renderer = window.CodemanSpreadsheetPreview;
if (!renderer?.open) {
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
return;
}
bodyEl.textContent = '';
const token = {};
this._spreadsheetPreviewToken = token;
this._spreadsheetPreview = renderer.open({
container: bodyEl,
url,
size,
isCurrent: () => this._spreadsheetPreviewToken === token,
});
},
_disposeSpreadsheetPreview() {
const handle = this._spreadsheetPreview;
this._spreadsheetPreview = null;
this._spreadsheetPreviewToken = null;
try {
handle?.dispose();
} catch (err) {
console.warn('Failed to dispose spreadsheet preview:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// File Viewer text view: rendered markdown, line numbers, wrap
// ═══════════════════════════════════════════════════════════════
@@ -5056,7 +5106,7 @@ Object.assign(CodemanApp.prototype, {
<div class="attachment-history-empty-title">No attachments yet</div>
<div>Show a file here by running:</div>
<code>codeman attach /absolute/path/to/file.pptx</code>
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
</div>
`;
return;
@@ -6028,6 +6078,11 @@ Object.assign(CodemanApp.prototype, {
}
}
// Rings for the Compact header style, kept current in every style (two
// style writes a poll) so switching styles never shows an empty ring.
this._setStatRing('statCpuRing', stats.cpu);
this._setStatRing('statMemRing', stats.memory?.percent);
if (memEl && memBar) {
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
memEl.textContent = `${memGB}G`;
@@ -6045,6 +6100,20 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Fill one stat ring (Compact header style) to `percent`, clamped to 0-100,
* and flag it `high` above 80%, the same threshold at which the value next
* to it turns red.
*/
_setStatRing(id, percent) {
const ring = this.$(id);
if (!ring) return;
const value = Number(percent);
const fill = Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : 0;
ring.style.setProperty('--pu', String(fill));
ring.classList.toggle('high', fill > 80);
},
// ─── Clipboard ──────────────────────────────────────────────────────────────
async _onClipboardWrite(data) {
+229 -86
View File
@@ -1,29 +1,41 @@
/**
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
*
* A session that starts another session (the `codeman` agent skill spawning a worker,
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
* state server-side. This module turns that field into the same kind of glowing
* connection line the subagent windows use, but tab → tab, so the strip shows at a
* glance which tab spawned which.
* state server-side. This module turns that field into a quiet orthogonal tree, one per
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
*
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
* active tab spawned, and the family it belongs to as a child, get the
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
* it). Drawing ONLY the selected family was tried first and rejected by the owner
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
* lineage exists (`_lineageTotalEdges`).
*
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
*
* Two constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
* Constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
* strip is also a scroller where both endpoints are rarely on screen at once.
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
* family exists, never on the selection, so switching tabs never resizes the header
* (and with it the terminal and the PTY).
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
* `_applyLineEntrances()` queries, so the draw-in animation and its
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
*/
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
applyLineageLineSettings() {
const prev = this._lineageLinesOn;
const next = this._syncLineageLinesEnabled();
if (prev !== next) this.updateConnectionLines();
if (prev !== next) {
// The reserved routing room follows the setting; updateTabOverflowMode()
// re-syncs it and re-measures the wrap with the new padding.
this.updateTabOverflowMode?.();
this.updateConnectionLines();
}
},
/**
* Every parent → child pair worth drawing, with the child's index among its siblings
* (that index is what nests sibling arcs instead of overprinting them).
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
* channel on the left of a wrapped strip (styles.css). Called at the top of
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
* measured.
*
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
* strip's own left-to-right order, which is what the user sees.
* Keyed on whether any family exists at all, never on which one is selected: a
* class that followed the selection would grow and shrink the header on every tab
* switch, and the header's height is the terminal's height.
*
* Only the header strip routes through reserved gaps. The vertical rail keeps its
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
*/
_syncLineageGutter() {
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
this._lineageTotalEdges = edges.length;
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const want = edges.length > 0 && !this._isVerticalTabList?.();
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
},
/**
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
* sessions Map so sibling order follows the strip's own left-to-right order, which
* is what the user sees.
*/
_collectLineageEdges() {
const edges = [];
if (!this.sessions || this.sessions.size < 2) return edges;
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
const seenPerParent = new Map();
for (const id of order) {
const session = this.sessions.get(id);
const parentId = session && session.parentSessionId;
// A parent that is gone (closed, or never came back after a restart) draws
// nothing: the field is decoration, so a dangling one is simply not rendered.
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
const depth = seenPerParent.get(parentId) || 0;
seenPerParent.set(parentId, depth + 1);
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
edges.push({ parentId, childId: id, status: session.status || 'idle' });
}
return edges;
},
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
_lineageFamilies(edges) {
const families = new Map();
for (const edge of edges) {
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge);
}
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
},
/**
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* Parent ids of the families the selection emphasizes: the family the selected tab
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
* when a web tab holds the stage, or the selected tab has no lineage at all.
*/
_lineageFocusParents(edges) {
const parents = new Set();
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return parents;
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
return parents;
},
/**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
*
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
* matter how many workers it spawns, so the strip reads as "these five came from
* w1, those two came from w2". Keying it per child instead gave one tab's own
* children a different colour each, which is the thing the colours exist to tell
* apart. A child that goes on to spawn its own workers is a parent in its turn and
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
* gets its own colour for the lines BELOW it, so a chain changes colour at each
* generation while each generation's fan-out stays uniform.
*
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). An empty string means "no override": the CSS falls back to
* flicker). The draw pass claims a colour for EVERY family in strip order before it
* draws anything, so neither the draw order (the selected family goes last) nor
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
* --session-blue, so the first spawning tab keeps the skin-aware blue.
*/
_lineageColorFor(parentId) {
@@ -140,19 +202,20 @@ Object.assign(CodemanApp.prototype, {
_appendLineageConnectionLines(svg, rects) {
this._lineageEdgeCount = 0;
if (!svg || !this._lineageLinesEnabled()) return;
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
// tab row. Against a vertical list the "strip bottom" is the bottom of the
// sidebar, so every arc would draw a giant loop to the foot of the list.
// Parent/child adjacency reads fine in a vertical list without arcs; a
// sideways lineage shape is a follow-up with its own visual tuning, not a
// by-product of a layout port.
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
// rail. The sidebar is a vertical list with its own scroller and no reserved
// channel; parent/child adjacency reads fine there without lines.
if (this.isSessionSidebarActive?.()) return;
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
if (!compute) return;
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
if (!computeTree) return;
const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length;
if (edges.length === 0) return;
// Claim colours in strip order for every family before drawing (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._lineageFamilies(edges);
const focusParents = this._lineageFocusParents(edges);
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,9 +225,9 @@ Object.assign(CodemanApp.prototype, {
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
// to anchor to, so its end of the line moves to that group's header (a
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
// its parent would be a line from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
@@ -173,71 +236,138 @@ Object.assign(CodemanApp.prototype, {
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
const measure = (endpoint) => {
if (!rects.has(endpoint.key)) {
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
return rects.get(endpoint.key);
};
const resolvedFamilies = [];
for (const family of families) {
const parentEndpoint = resolveEndpoint(family.parentId);
const parentRect = measure(parentEndpoint);
if (!parentRect) continue;
const children = [];
for (const edge of family.edges) {
const childEndpoint = resolveEndpoint(edge.childId);
if (childEndpoint.key === parentEndpoint.key) continue;
const rect = measure(childEndpoint);
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
}
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
}
if (resolvedFamilies.length === 0) return;
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
let spineLeft;
if (orientation === 'horizontal') {
// Where the spine channel is: the reserved --lineage-spine-channel just left
// of the strip's content edge. Usually that is the strip's own left edge,
// but grouped by state the label column comes first (styles.css), and a
// spine at the edge ran through every label. Read back from the padding
// the CSS laid out, so geometry and stylesheet cannot disagree.
const style = typeof getComputedStyle === 'function' ? getComputedStyle(strip) : null;
const channel = style ? parseFloat(style.getPropertyValue('--lineage-spine-channel')) : NaN;
if (Number.isFinite(channel)) {
const inset = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0);
spineLeft = stripRect.left + inset - channel;
}
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
if (key && rects.has(key)) tabRects.push(rects.get(key));
else {
const rect = tab.getBoundingClientRect();
if (key) rects.set(key, rect);
tabRects.push(rect);
}
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
// only the DRAW order changes (the emphasized families last, on top). A row gap
// fits a few lanes, so they cycle: families that share one are told apart by colour.
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
const drawOrder = resolvedFamilies
.map((resolved, index) => ({
...resolved,
lane: index % laneCount,
focus: focusParents.has(resolved.family.parentId),
}))
.sort((a, b) => a.focus - b.focus);
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
const geom = computeTree({
parent: parentRect,
child: childRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
strip: stripRect,
depth: edge.depth,
tabs: tabRects,
spineLeft,
orientation,
lane,
laneCount,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', geom.d);
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
const color = this._lineageColorFor(edge.parentId);
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
line.setAttribute('data-parent-tab', edge.parentId);
line.setAttribute('data-child-tab', edge.childId);
svg.appendChild(line);
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
// markers need a <defs> block and fight the dash pattern.
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(geom.endX));
dot.setAttribute('cy', String(geom.endY));
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
if (!geom || geom.routes.length === 0) continue;
const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its
// routes share (the trunk) do not stack into a brighter line than the branches,
// and the emphasis for the selected tab's families (styles.css).
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
// shared trunk and only the working child's own branch shows its dashes.
const routes = geom.routes
.map((route) => ({ route, child: byId.get(route.id) }))
.filter((r) => r.child)
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
for (const { route, child } of routes) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', route.d);
// `status` is the CHILD's, which is the interesting end: a working child's
// route is dashed (and marches, motion permitting).
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
line.setAttribute(
'class',
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
);
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
// owning weight and dash; an empty colour leaves the --session-blue fallback.
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
line.setAttribute('data-parent-tab', family.parentId);
line.setAttribute('data-child-tab', child.edge.childId);
group.appendChild(line);
}
// Direction marker at each CHILD end, after every path so no stroke covers it.
for (const { route, child } of routes) {
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX));
dot.setAttribute('cy', String(route.endY));
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
dot.setAttribute('r', '2.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
group.appendChild(dot);
}
svg.appendChild(group);
this._lineageEdgeCount += routes.length;
}
},
/**
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
* scroll moves both endpoints without firing any render, so the arcs would slide off
* their tabs. Passive listener, and the redraw is the normal coalesced one.
* scroll moves both endpoints without firing any render, so the lines would slide
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
*
* Installed once; the guard also keeps a re-init from stacking listeners.
*/
@@ -248,11 +378,24 @@ Object.assign(CodemanApp.prototype, {
this._lineageScrollHandler = () => {
// Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage arcs entirely, and the rail can
// show connectors with zero lineage edges, so _lineageEdgeCount alone
// would never redraw them).
// rects too (the sidebar skips lineage entirely, and the rail can show
// connectors with zero lineage edges, so _lineageEdgeCount alone would
// never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
// and close icons by transitioning their padding (styles.css), so it keeps
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
// which left a family's trunk hanging under the tab's OLD position. Redraw once
// a size transition inside the strip ends (coalesced, like every other redraw).
this._lineageTransitionHandler = (event) => {
const prop = event.propertyName || '';
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
};
strip.addEventListener('transitionend', this._lineageTransitionHandler);
},
});
+72 -22
View File
@@ -125,6 +125,17 @@ const RUN_MODE_LAUNCH = {
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
const CASE_PICKER_REFRESH_MS = 5000;
/**
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
* actions, one click away, without two extra controls in the toolbar. The arrow
* keys reach them after the last case; Enter or a click runs `run`.
*/
const CASE_PICKER_ACTIONS = [
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
];
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -140,6 +151,13 @@ function isExternalCliRunMode(mode) {
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
}
// Does this session lack the Claude-only features (Respawn, Ralph)? The registry's
// `capabilities.external`, the flag the server's isExternalCliMode() reads. Not
// isExternalCliRunMode(): that picks a launch path, and claude is `kind: 'agent'` too.
function isExternalCliSession(mode) {
return registryCliById(mode)?.external ?? isExternalCliRunMode(mode);
}
Object.assign(CodemanApp.prototype, {
/**
* Build envOverrides payload from case + global settings.
@@ -343,13 +361,32 @@ Object.assign(CodemanApp.prototype, {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
const selectedName = select.value || 'testcase';
const maxIndex = Math.max(0, options.length - 1);
// The arrow keys walk the cases, then the action rows under them.
const maxIndex = options.length + CASE_PICKER_ACTIONS.length - 1;
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
const actionRows = CASE_PICKER_ACTIONS.map((action, i) => {
const index = options.length + i;
const active = index === this._casePickerActiveIndex;
return `
<button
type="button"
id="quickStartCaseOption-${index}"
class="case-combobox-action ${active ? 'active' : ''}"
role="option"
aria-selected="false"
data-case-action="${action.id}">
<span class="case-combobox-action-icon" aria-hidden="true">${action.icon}</span>
<span class="case-combobox-option-label">${escapeHtml(action.label)}</span>
</button>
`;
}).join('');
const actionsBlock = `<div class="case-combobox-actions" role="presentation">${actionRows}</div>`;
if (options.length === 0) {
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>' + actionsBlock;
list.classList.remove('hidden');
input.removeAttribute('aria-activedescendant');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
return;
}
@@ -372,11 +409,22 @@ Object.assign(CodemanApp.prototype, {
</button>
`;
})
.join('');
.join('') + actionsBlock;
list.classList.remove('hidden');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
},
/** Run a case picker action row (`CASE_PICKER_ACTIONS`): close the list first, then act. */
runCasePickerAction(id) {
const action = CASE_PICKER_ACTIONS.find((a) => a.id === id);
if (!action) return;
const select = document.getElementById('quickStartCase');
if (select) this.updateCasePickerInput(select.value);
this.closeCasePicker();
document.getElementById('quickStartCaseSearch')?.blur?.();
action.run(this);
},
selectQuickStartCase(caseName, { save = true } = {}) {
const select = document.getElementById('quickStartCase');
if (!select) return;
@@ -419,18 +467,26 @@ Object.assign(CodemanApp.prototype, {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
if (event.key === 'ArrowDown') {
event.preventDefault();
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
this._casePickerActiveIndex = Math.min(
(this._casePickerActiveIndex || 0) + 1,
options.length + CASE_PICKER_ACTIONS.length - 1
);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'Enter') {
const option = options[this._casePickerActiveIndex || 0];
const index = this._casePickerActiveIndex || 0;
const option = options[index];
const action = this._casePickerOpen ? CASE_PICKER_ACTIONS[index - options.length] : undefined;
if (option) {
event.preventDefault();
this.selectQuickStartCase(option.name);
this.run?.();
} else if (action) {
event.preventDefault();
this.runCasePickerAction(action.id);
}
} else if (event.key === 'Escape') {
event.preventDefault();
@@ -443,6 +499,11 @@ Object.assign(CodemanApp.prototype, {
});
list.addEventListener('mousedown', event => event.preventDefault());
list.addEventListener('click', event => {
const action = event.target.closest?.('.case-combobox-action');
if (action?.dataset?.caseAction) {
this.runCasePickerAction(action.dataset.caseAction);
return;
}
const option = event.target.closest?.('.case-combobox-option');
if (option?.dataset?.case) {
this.selectQuickStartCase(option.dataset.case);
@@ -1803,19 +1864,6 @@ Object.assign(CodemanApp.prototype, {
input.value = Math.max(1, current - 1);
},
// Shell count stepper functions
incrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(20, current + 1);
},
decrementShellCount() {
const input = document.getElementById('shellCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
},
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
// w2-<case> for agents, s1-<case> for shells), shared by the local and
// remote/docker launch paths so all tabs follow the same naming convention.
@@ -2082,7 +2130,9 @@ Object.assign(CodemanApp.prototype, {
async runShell() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
@@ -2434,7 +2484,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = isExternalCliRunMode(session.mode);
const isAltMode = isExternalCliSession(session.mode);
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -3094,7 +3144,7 @@ Object.assign(CodemanApp.prototype, {
// Close on outside click (one-shot listener)
const closeHandler = (e) => {
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
if (!popover.contains(e.target) && !e.target.closest?.('.case-combobox-action')) {
popover.classList.add('hidden');
document.removeEventListener('click', closeHandler);
}
+252 -51
View File
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
@@ -430,7 +431,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('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
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
@@ -454,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
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;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
// 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
@@ -501,7 +504,11 @@ Object.assign(CodemanApp.prototype, {
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsTabRailSort').value =
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
@@ -527,6 +534,8 @@ Object.assign(CodemanApp.prototype, {
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? '';
this._applyCodexSettingsVisibility();
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
@@ -1642,40 +1651,88 @@ Object.assign(CodemanApp.prototype, {
return flags[tool] !== false;
},
/** Render the registry's enabled, available CLIs as welcome-screen actions. */
/**
* Render the registry's enabled, available CLIs as welcome-screen actions:
* ONE primary button, then every other entry as a slim chip in a row under it.
*
* The primary is the first AGENT in catalog order (the first entry whose kind
* is not 'shell'), so on a stock install it is Claude Code, and with Claude
* disabled or missing it is simply the next agent; only a catalog with no agent
* at all promotes the shell. Chosen from the catalog's order and kind, never by
* an id: the registry decides what comes first. Everything else keeps catalog
* order inside the chip row, so the DOM order across both is the catalog's.
*
* The CLI id travels only as DATA: `data-mode` for the click, and the
* `run-mode-dot <id>` logo slot every Run menu shares (styles.css draws the
* brand mark, or a plain dot for an id it has no logo for). No per-id class on
* the buttons themselves, so no rule anywhere can give one CLI its own look.
*/
renderWelcomeCliActions() {
const container = document.getElementById('welcomeCliActions');
if (!container) return;
const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
const offered = catalog.filter((cli) => cli.enabled && this.isCliAvailable(cli.id));
const primary = offered.find((cli) => cli.kind !== 'shell') || offered[0];
// "Run <label>", the strings i18n.js translates ("Run Claude Code", "Run Shell");
// a custom CLI's label simply has no dictionary entry, so it renders as typed.
const runLabel = (cli) => `Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`;
const logo = (cli) => {
const dot = document.createElement('span');
dot.className = `run-mode-dot ${cli.id}`;
dot.setAttribute('aria-hidden', 'true');
return dot;
};
const launch = (cli) => () => {
this.setRunMode(cli.id);
void this.run();
};
container.replaceChildren();
for (const cli of catalog) {
if (!cli.enabled || !this.isCliAvailable(cli.id)) continue;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`;
btn.dataset.mode = cli.id;
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('width', '20');
icon.setAttribute('height', '20');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '2');
icon.setAttribute('aria-hidden', 'true');
const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
play.setAttribute('points', '5 3 19 12 5 21 5 3');
icon.appendChild(play);
btn.appendChild(icon);
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
// label simply has no dictionary entry, so it renders as typed.
btn.append(`Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`);
btn.onclick = () => {
this.setRunMode(cli.id);
void this.run();
};
container.appendChild(btn);
if (!primary) return;
const main = document.createElement('button');
main.type = 'button';
main.className = 'welcome-primary';
main.dataset.mode = primary.id;
// The mark sits on a small light disc: brand marks (Claude's is orange) turn
// muddy straight on the accent fill, and the disc reads on every skin.
const disc = document.createElement('span');
disc.className = 'welcome-primary-logo';
disc.appendChild(logo(primary));
main.appendChild(disc);
// One raw string, kept whole: i18n.js matches the exact text node. The span
// only lets a long custom label ellipsize (styles.css .welcome-label).
const mainLabel = document.createElement('span');
mainLabel.className = 'welcome-label';
mainLabel.textContent = runLabel(primary);
main.appendChild(mainLabel);
main.onclick = launch(primary);
container.appendChild(main);
const rest = offered.filter((cli) => cli !== primary);
if (!rest.length) return;
const chips = document.createElement('div');
chips.className = 'welcome-chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'More tools');
for (const cli of rest) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'welcome-chip';
chip.dataset.mode = cli.id;
// The chip shows the bare name (the row under "Run …" already says what it
// does); the full "Run <label>" is its accessible name and tooltip, both of
// which i18n.js translates.
chip.title = runLabel(cli);
chip.setAttribute('aria-label', runLabel(cli));
chip.appendChild(logo(cli));
const chipLabel = document.createElement('span');
chipLabel.className = 'welcome-label';
chipLabel.textContent = cli.kind === 'shell' ? 'Shell' : cli.label;
chip.appendChild(chipLabel);
chip.onclick = launch(cli);
chips.appendChild(chip);
}
container.appendChild(chips);
},
/**
@@ -1709,17 +1766,16 @@ Object.assign(CodemanApp.prototype, {
}
},
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
const row = document.getElementById(rowId);
const display = document.getElementById(displayId);
_updateTunnelUrlDisplay(url) {
const row = document.getElementById('tunnelUrlRow');
const display = document.getElementById('tunnelUrlDisplay');
if (!row || !display) return;
if (url) {
const fullUrl = url + suffix;
row.style.display = '';
display.textContent = fullUrl;
display.textContent = url;
display.onclick = () => {
navigator.clipboard.writeText(fullUrl).then(() => {
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
navigator.clipboard.writeText(url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
} else {
@@ -1729,11 +1785,6 @@ Object.assign(CodemanApp.prototype, {
}
},
_updateTunnelUrlDisplay(url) {
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
},
showTunnelQR() {
// Close existing popup if open
this.closeTunnelQR();
@@ -2476,6 +2527,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
@@ -2504,6 +2556,9 @@ Object.assign(CodemanApp.prototype, {
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2526,6 +2581,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -2536,6 +2593,8 @@ Object.assign(CodemanApp.prototype, {
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Codex CLI settings
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
// Claude Permissions settings
@@ -2555,6 +2614,15 @@ Object.assign(CodemanApp.prototype, {
},
};
// SettingsUpdateSchema is .strict() and checks codexModel with this same
// pattern, so one bad character 400s the WHOLE settings PUT while the toast
// still says "Settings saved". Refuse it here, before anything is persisted.
if (!/^[A-Za-z0-9._\/-]*$/.test(settings.codexModel)) {
this.showToast('Default Codex model may only contain letters, digits, ".", "_", "-" and "/"', 'error');
document.getElementById('appSettingsCodexModel')?.focus();
return;
}
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
// UI, but their features still read settings.showTokenCount / settings.showCost
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
@@ -2762,6 +2830,8 @@ Object.assign(CodemanApp.prototype, {
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
showGitStatus: _sgs,
gitStatusTree: _gst,
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
@@ -2769,6 +2839,13 @@ Object.assign(CodemanApp.prototype, {
// .strict() schema — syncing it would push a desktop-shaped choice onto
// devices that cannot render it at all.
sessionLineageLines: _sll,
// Keyboard shortcut overrides are per-device (bindings follow the keyboard
// and the OS: Cmd on macOS, Ctrl elsewhere) and absent from the .strict()
// SettingsUpdateSchema. They used to ride along here, so the first
// Shortcuts change on a device (even a Reset, which leaves an empty {})
// made EVERY later App Settings save a 400, and every synced key stopped
// reaching the server while the toast still said "Settings saved".
shortcutOverrides: _sco,
...serverSettings
} = settings;
let webhookError = '';
@@ -3165,12 +3242,18 @@ Object.assign(CodemanApp.prototype, {
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
_syncCliLaunchCatalog() {
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
// /api/clis rows carry no capabilities, so keep the served catalog's `external`
// (isExternalCliSession() reads it). A new custom CLI has none and falls back to `kind`.
const previous = new Map(
(Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []).map((cli) => [cli.id, cli])
);
window.__codemanCliCatalog = this._cliList.map((cli) => ({
id: cli.id,
label: cli.label,
shortBadge: cli.shortBadge,
order: cli.order,
kind: cli.kind,
external: previous.get(cli.id)?.external,
enabled: cli.enabled,
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
}));
@@ -3454,6 +3537,8 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
tabArrangement: 'classic',
tabStateOrder: 'urgent-first',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -3464,7 +3549,14 @@ Object.assign(CodemanApp.prototype, {
}
// Desktop defaults - rely on ?? operators in apply functions
// This allows desktop to have different defaults without duplication
return {};
// A touch-primary tablet (iPad, an Android tablet: not a handheld, so it
// lands here) keeps the Tiles button opt-in, as Split is: a tile has none of
// the main terminal's touch, IME and soft-keyboard handling. The PRIMARY
// pointer decides, never MobileDetection.isTouchDevice(), which is true on a
// touchscreen laptop too (fine primary pointer: the desktop default stays).
const coarsePrimaryPointer =
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true;
return coarsePrimaryPointer ? { showTileGridButton: false } : {};
},
loadAppSettingsFromStorage() {
@@ -3481,8 +3573,15 @@ Object.assign(CodemanApp.prototype, {
} catch (err) {
console.error('Failed to load app settings:', err);
}
// Return device-specific defaults
this._cachedAppSettings = this.getDefaultSettings();
// Return device-specific defaults, without showTileGridButton: its default
// on a non-handheld follows the LIVE primary pointer (getDefaultSettings),
// and this object is what a fresh device caches and the server-settings
// merge then persists, which would freeze a 2-in-1's first-load posture
// into a stored value. Every reader resolves the absent key through a
// fresh getDefaultSettings() (?? defaults.showTileGridButton ?? true).
const defaults = { ...this.getDefaultSettings() };
delete defaults.showTileGridButton;
this._cachedAppSettings = defaults;
return this._cachedAppSettings;
},
@@ -3528,6 +3627,10 @@ Object.assign(CodemanApp.prototype, {
if (result && this.notificationManager) {
this.notificationManager.originalTitle = document.title;
}
// The connection tile's value word (Header Stats Style Tiles) is written
// already translated into a data-i18n-skip span, so the translator above
// cannot revert it; its own language compare makes this one call repaint it.
this._updateConnectionIndicator?.();
},
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
@@ -3555,6 +3658,81 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now;
},
/**
* The stored tab layout, or the default. Anything but the four known values
* (an absent key, a value from a newer build) reads as 'classic', the default:
* the single strip as before, the owner's pick on the 1.36.0 beta. 'state'
* (Discussion #426, option C), 'case' and 'ledger' are opt-in.
*/
resolveTabArrangement(settings) {
const value = settings?.tabArrangement ?? this.getDefaultSettings().tabArrangement;
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
resolveTabStateOrder(settings) {
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
return value === 'urgent-last' ? 'urgent-last' : 'urgent-first';
},
/**
* The stored header-stats style, or the default. Anything but the three
* known values (an absent key, a value from a newer build) reads as
* 'compact', the default (the two-pill ring variant of Discussion #426's
* option G, picked by the owner on the 1.36.0 beta over 'tiles').
*/
resolveHeaderStatsStyle(settings) {
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
return value === 'classic' || value === 'tiles' ? value : 'compact';
},
/**
* Apply a header-stats style: the `data-header-stats` attribute every rule in
* the "Header stats styles" block of styles.css keys on, plus the two DOM
* moves the clustered styles need.
*
* The template keeps the classic order, where the connection indicator sits
* before the font controls and the plan-usage chip near the end of the header.
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
* windows), so the indicator moves into #headerSystemStats as its first child
* and the chip moves right after it. Comment anchors left at the template
* positions are what 'classic' moves them back to, so switching back restores
* the header exactly.
*
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
* is hidden with `display: none`, and the WS readout must not disappear with
* it. Both elements keep their ids, so every writer (setConnectionStatus,
* updatePlanUsageChip) finds them wherever they sit.
*
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
*/
applyHeaderStatsStyle({ style, showSystemStats }) {
document.documentElement.dataset.headerStats = style;
const stats = document.getElementById('headerSystemStats');
const conn = document.getElementById('connectionIndicator');
const plan = document.getElementById('planUsageChip');
if (!stats || !conn || !plan) return;
if (!this._headerStatsAnchors) {
const connAnchor = document.createComment(' connection indicator (classic position) ');
const planAnchor = document.createComment(' plan usage chip (classic position) ');
conn.before(connAnchor);
plan.before(planAnchor);
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
}
const anchors = this._headerStatsAnchors;
const clustered = style !== 'classic';
if (clustered && showSystemStats) {
if (stats.firstElementChild !== conn) stats.prepend(conn);
} else if (anchors.conn.nextSibling !== conn) {
anchors.conn.after(conn);
}
if (clustered) {
if (stats.nextElementSibling !== plan) stats.after(plan);
} else if (anchors.plan.nextSibling !== plan) {
anchors.plan.after(plan);
}
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -3563,7 +3741,11 @@ Object.assign(CodemanApp.prototype, {
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
// .tab-detach without it (a tab that is already detached keeps its icon as
// the re-focus affordance for the popped-out window).
const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
// Under a host that opens windows (see hasHostWindows) popping out is the
// way to get two panes side by side, so the button defaults on there.
const showTabDetach =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
@@ -3585,6 +3767,12 @@ Object.assign(CodemanApp.prototype, {
if (tokenCountEl) {
tokenCountEl.style.display = showTokenCount ? '' : 'none';
}
// After the System Stats visibility above: whether WS joins the stats pill
// depends on the pill being shown.
this.applyHeaderStatsStyle({
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
showSystemStats,
});
// Hide lifecycle log button when setting is disabled
// Default OFF: the lifecycle-log document icon is opt-in; the default header
@@ -3639,7 +3827,7 @@ Object.assign(CodemanApp.prototype, {
this._applySplitButtonVisibility?.(showSplitButton);
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
@@ -3756,6 +3944,19 @@ Object.assign(CodemanApp.prototype, {
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
root.dataset.tabRailSort = sort;
// The tab layout rides on a fourth attribute, for the same reason: it is
// applied by the render paths (inline `order` plus headings, or cluster
// boxes), so a flip has to re-render, and the gates in app.js
// (`isTabTriage()`, `isTabClusters()`, `isTabLedger()`) read one attribute
// per pass instead of re-parsing localStorage.
const previousArrangement = root.dataset.tabArrangement || 'state';
const arrangement = this.resolveTabArrangement(settings);
root.dataset.tabArrangement = arrangement;
// Which end the state groups start from; read by _tabTriageLayout().
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
const stateOrder = this.resolveTabStateOrder(settings);
root.dataset.tabStateOrder = stateOrder;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -3779,7 +3980,7 @@ Object.assign(CodemanApp.prototype, {
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -4049,16 +4250,16 @@ Object.assign(CodemanApp.prototype, {
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
@@ -0,0 +1,290 @@
/**
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
*
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
* given a STORE-only archive rebuilt from the entries admission inflated, never
* the fetched bytes, so it can only parse what admission counted. The page never
* loads either vendor file. Cell values are sent back as plain strings; the
* renderer writes them with `textContent`. Formulas are never evaluated (the
* cached result is shown, else the formula text), and nothing here fetches:
* external links, images and drawings are reported as unsupported features.
*
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
* keeps a reverse-proxy `--base-url` mount working.
*/
'use strict';
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
// Per sheet: its populated rows in order, each with its populated cells in
// column order, built once at load from the keys that exist (`populatedRowIndex`).
let populatedRowsById = new Map();
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
// which is far too much to pay on every tile.
let mergesById = new Map();
let normalizedStyles = [];
let styleIds = new Map();
let themePalette = core.DEFAULT_THEME_PALETTE;
function postError(error) {
self.postMessage({
type: 'error',
code: error?.code || 'parse-failed',
message: error?.message || 'Spreadsheet preview failed',
});
}
// Maximum cells in one tile reply; the renderer draws at most this many too.
const MAX_TILE_CELLS = 2500;
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
// entry is the fallback and the default Office palette is the last resort.
function readThemeXml(loadedWorkbook, admittedEntries) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
const theme = admittedEntries?.['xl/theme/theme1.xml'];
return theme ? new TextDecoder().decode(theme) : '';
}
function normalizeStyle(cell) {
// Colours are resolved and contrast-checked as a PAIR. Emitting a
// font colour without its background lets workbook text land on the skin's
// `var(--bg-primary)` and disappear.
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
const style = {
font: {
bold: Boolean(cell.font?.bold),
italic: Boolean(cell.font?.italic),
color: colors.foreground,
},
fill: colors.background,
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
wrapText: Boolean(cell.alignment?.wrapText),
};
const key = JSON.stringify(style);
if (styleIds.has(key)) return styleIds.get(key);
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
}
const id = normalizedStyles.length;
normalizedStyles.push(style);
styleIds.set(key, id);
return id;
}
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
function presentIndices(sparse) {
const indices = [];
for (const key of Object.keys(sparse || [])) {
const index = Number(key);
if (Number.isInteger(index) && index >= 0) indices.push(index);
}
return indices.sort((a, b) => a - b);
}
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
// cell counts when it exists and its type is not `ValueType.Null`, and a row
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
function populatedRowIndex(sheet) {
const nullType = self.ExcelJS.ValueType.Null;
const rows = [];
for (const rowIndex of presentIndices(sheet._rows)) {
const row = sheet._rows[rowIndex];
if (!row) continue;
const cells = [];
for (const cellIndex of presentIndices(row._cells)) {
const cell = row._cells[cellIndex];
if (cell && cell.type !== nullType) cells.push(cell);
}
if (cells.length > 0) rows.push({ number: row.number, row, cells });
}
return rows;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const rowOverrides = [];
const populatedRows = populatedRowIndex(sheet);
for (const { number, row, cells } of populatedRows) {
for (const cell of cells) {
cellRefs.push(cell.address);
normalizeStyle(cell);
}
if (row.hidden) rowOverrides.push([number, 0]);
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
}
// `sheet.model` rebuilds every row and cell model, so merges come straight
// from ExcelJS's own merge map, in the order the model getter would list them.
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
const extent = core.deriveExtent(cellRefs, merges);
const columnOverrides = [];
for (let col = 1; col <= extent.cols; col += 1) {
const column = sheet.getColumn(col);
if (column.hidden) columnOverrides.push([col, 0]);
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
}
return {
populatedRows,
merges,
metadata: {
id: String(sheet.id),
name: sheet.name,
rows: extent.rows,
cols: extent.cols,
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
rowOverrides,
columnOverrides,
merges,
},
};
}
function cellDisplay(cell, date1904, warnings) {
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
if (formatted.warning) warnings.add(formatted.warning);
return formatted.text;
}
async function loadWorkbook(bytes) {
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
const admitted = core.buildAdmittedArchive(admission, self.fflate);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
// ExcelJS's DefinedNames model setter expands every range into one object per
// cell (a whole-sheet name exhausts the heap), and admission reads only the
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
// stored at all; print areas and titles are split off before this setter runs.
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
// backstop behind admission's row count, which also caps the workbook total.
await nextWorkbook.xlsx.load(admitted, {
ignoreNodes: ['dataValidations'],
maxRows: core.LIMITS.maxRowsPerSheet,
});
const nextSheets = new Map();
const nextRows = new Map();
const nextMerges = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
const sheets = [];
for (const sheet of nextWorkbook.worksheets) {
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
const sheetResult = worksheetMetadata(sheet);
const metadata = sheetResult.metadata;
nextSheets.set(metadata.id, sheet);
nextRows.set(metadata.id, sheetResult.populatedRows);
nextMerges.set(metadata.id, sheetResult.merges);
sheets.push(metadata);
}
workbook = nextWorkbook;
sheetsById = nextSheets;
populatedRowsById = nextRows;
mergesById = nextMerges;
self.postMessage({
type: 'metadata',
sheets,
styles: normalizedStyles,
date1904: Boolean(workbook.properties?.date1904),
empty: sheets.length === 0,
warnings: admission.features,
});
}
function sendTile(message) {
if (!workbook) throw new Error('Workbook is not loaded');
const sheet = sheetsById.get(String(message.sheetId));
if (!sheet) throw new Error('Worksheet is unavailable');
const range = message.range;
const warnings = new Set();
const cells = [];
const seenCells = new Set();
let truncated = false;
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
// (a filtered sheet); they are never drawn, so never sent.
const hiddenColumns = new Map();
const columnHidden = (col) => {
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
return hiddenColumns.get(col);
};
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
if (cells.length >= MAX_TILE_CELLS) {
truncated = true;
return;
}
seenCells.add(key);
cells.push({
row: cell.row,
col: cell.col,
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
styleId: normalizeStyle(cell),
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const populated of populatedRows) {
if (truncated) break;
if (populated.number < range.r1) continue;
if (populated.number > range.r2) break;
if (populated.row.hidden) continue;
for (const cell of populated.cells) {
if (cell.col < range.c1) continue;
if (cell.col > range.c2) break;
addCell(cell);
}
}
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
for (const merge of merges) {
const anchor = core.parseRange(merge);
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
}
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
self.postMessage({
type: 'tile',
requestId: message.requestId,
sheetId: String(message.sheetId),
cells,
merges,
// One counted entry for many unsupported number formats keeps the notice bar short.
warnings: core.foldWarnings(Array.from(warnings)),
});
}
self.onmessage = async (event) => {
try {
const message = event.data || {};
if (message.type === 'load') await loadWorkbook(message.bytes);
else if (message.type === 'tile') sendTile(message);
else if (message.type === 'dispose') {
workbook = null;
sheetsById = new Map();
populatedRowsById = new Map();
mergesById = new Map();
themePalette = core.DEFAULT_THEME_PALETTE;
}
} catch (error) {
postError(error);
}
};
self.postMessage({ type: 'ready' });
+555
View File
@@ -0,0 +1,555 @@
/**
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
*
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
* the visible tile of cells. Parsing happens entirely in the browser worker; the
* server just streams the file through its existing confined raw routes.
*
* Every workbook string (cell text, sheet names) is written with `textContent`,
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
* or closed.
*
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
*/
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
const ROW_HEADING_WIDTH = 36;
const COLUMN_HEADING_HEIGHT = 20;
const assets = Object.freeze({
version: SPREADSHEET_ASSET_VERSION,
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
});
// What a refusal from the worker says on screen. The core's own messages
// (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to
// one sentence with a zh-CN entry in i18n.js, and the raw message goes to the
// console. Any other code (parse-failed carries ExcelJS's own exception text)
// shows the generic failure.
const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.';
const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.';
const WORKER_ERROR_TEXT = Object.freeze({
encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.',
zip64: 'This workbook uses ZIP64, which the preview does not support.',
malformed: UNREADABLE,
'number-format': UNREADABLE,
'entry-limit': TOO_LARGE_OR_COMPLEX,
'entry-size': TOO_LARGE_OR_COMPLEX,
'inflated-size': TOO_LARGE_OR_COMPLEX,
'compression-ratio': TOO_LARGE_OR_COMPLEX,
'worksheet-limit': TOO_LARGE_OR_COMPLEX,
'element-limit': TOO_LARGE_OR_COMPLEX,
'row-limit': TOO_LARGE_OR_COMPLEX,
'cell-limit': TOO_LARGE_OR_COMPLEX,
'merge-limit': TOO_LARGE_OR_COMPLEX,
'style-limit': TOO_LARGE_OR_COMPLEX,
});
// The workbook features the preview leaves out, as the core names them
// (featureForName), in words for the notice bar.
const FEATURE_LABELS = Object.freeze({
charts: 'charts',
drawings: 'drawings',
pivotTables: 'pivot tables',
externalLinks: 'external links',
macros: 'macros',
});
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
function translate(text) {
return global.codemanT?.(text) || text;
}
function own(map, key) {
return Object.prototype.hasOwnProperty.call(map, key);
}
function workerErrorText(payload) {
const code = String(payload?.code || '');
if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`);
return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed';
}
// One notice bar item, translated on its own (the bar is one text node, which
// the i18n layer could only match whole; the bar itself carries
// data-i18n-skip). A number format's code is workbook text: it is appended
// as is, never passed through the translator, which would read a `{…}` in it
// as a placeholder. A feature word goes through its scoped
// 'Spreadsheet feature: <word>' key and reads as the plain word when that key
// has no translation: a bare 'charts' key would also rename a charts/ folder.
function warningText(warning) {
const text = String(warning);
if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) {
return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`;
}
if (own(FEATURE_LABELS, text)) {
const label = FEATURE_LABELS[text];
const key = `Spreadsheet feature: ${label}`;
const translated = translate(key);
return translated !== key ? translated : label;
}
return translate(text);
}
function message(container, text, kind) {
container.textContent = '';
const state = document.createElement('div');
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
state.textContent = text;
container.appendChild(state);
}
function safePreviewUrl(candidate) {
const url = new URL(candidate, global.location.href);
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
url.searchParams.set('preview', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
function open(options) {
const container = options.container;
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
let disposed = false;
let worker = null;
let controller = null;
let timer = null;
let metadata = null;
let activeSheetId = null;
let latestRequestId = 0;
let grid = null;
let spacer = null;
let cellsLayer = null;
let headingsLayer = null;
let emptySheetState = null;
let resizeObserver = null;
let latestRange = null;
let latestAxes = null;
let scrollFrame = null;
const current = () => !disposed && isCurrent();
const clearTimer = () => {
if (timer !== null) global.clearTimeout(timer);
timer = null;
};
const fail = (text) => {
if (!current()) return;
clearTimer();
message(container, text || 'Spreadsheet preview failed', 'error');
};
function sheetMetadata() {
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
// Prefix sums per override list, built once per sheet's axis: renderTile
// asks for several offsets per cell, so a linear walk over every override
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
const axisDeltas = new WeakMap();
function overrideDeltas(overrides, defaultSize) {
let entry = axisDeltas.get(overrides);
if (!entry || entry.defaultSize !== defaultSize) {
const deltas = [];
let delta = 0;
for (const [, size] of overrides) {
delta += size - defaultSize;
deltas.push(delta);
}
entry = { defaultSize, deltas };
axisDeltas.set(overrides, entry);
}
return entry.deltas;
}
function axisOffset(count, defaultSize, overrides, index) {
const bounded = Math.max(1, Math.min(count + 1, index));
const list = overrides || [];
let low = 0;
let high = list.length;
while (low < high) {
const mid = (low + high) >> 1;
if (list[mid][0] < bounded) low = mid + 1;
else high = mid;
}
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
}
function axisIndex(count, defaultSize, overrides, offset) {
let low = 1;
let high = Math.max(1, count);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
else high = mid - 1;
}
return low;
}
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
// range, so the last row stays reachable) and the tile is laid out at real
// sizes from there. `shift` is the logical offset minus the scroll offset,
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
function scrollAxis(logical, scroll, viewport, heading) {
const shown = Math.min(MAX_SCROLL_PX, logical);
const scrollRange = Math.max(0, heading + shown - viewport);
const logicalRange = Math.max(0, heading + logical - viewport);
const virtual =
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
return { virtual, shift: virtual - scroll, end: heading + shown };
}
function requestTile() {
if (!current() || !worker || !grid) return;
const sheet = sheetMetadata();
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
const viewHeight = grid.clientHeight || 500;
const viewWidth = grid.clientWidth || 800;
const y = scrollAxis(
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
grid.scrollTop,
viewHeight,
COLUMN_HEADING_HEIGHT
);
const x = scrollAxis(
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
grid.scrollLeft,
viewWidth,
ROW_HEADING_WIDTH
);
const r1 = Math.max(
1,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
) - 2
);
const c1 = Math.max(
1,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
) - 2
);
const r2 = Math.min(
sheet.rows,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
) + 2
);
const c2 = Math.min(
sheet.cols,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
) + 2
);
latestRequestId += 1;
latestRange = { r1, c1, r2, c2 };
latestAxes = { y, x };
worker.postMessage({
type: 'tile',
requestId: latestRequestId,
sheetId: String(activeSheetId),
range: { r1, c1, r2, c2 },
});
}
function renderWarnings(tileWarnings) {
const notice = container.querySelector('.spreadsheet-preview-notice');
if (!notice) return;
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
notice.hidden = warnings.length === 0;
const warningLabel = translate('Some workbook features are not shown');
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : '';
}
function pinHeadings() {
if (!grid || !headingsLayer) return;
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
heading.style.left = `${grid.scrollLeft}px`;
});
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
heading.style.top = `${grid.scrollTop}px`;
});
}
function renderTile(tile) {
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
const sheet = sheetMetadata();
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
const { y, x } = latestAxes;
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
// it never grows the scroll area.
const rowTop = (row) =>
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
const colLeft = (col) =>
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
cellsLayer.textContent = '';
headingsLayer.textContent = '';
const mergeByAnchor = new Map();
for (const merge of tile.merges || []) {
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
if (!match) continue;
const column = (letters) =>
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
r2: Number(match[4]),
c2: column(match[3]),
});
}
for (const cell of tile.cells.slice(0, 2500)) {
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
const height = rowSpan(cell.row, merge?.r2 || cell.row);
const width = colSpan(cell.col, merge?.c2 || cell.col);
// A cell clipped to nothing at the spacer's edge (or sized 0 px) is
// skipped like its heading: padding and border would still draw it as a
// small box below the spacer and grow the scroll area.
if (height <= 0 || width <= 0) continue;
const element = document.createElement('div');
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
element.dataset.row = String(cell.row);
element.dataset.col = String(cell.col);
element.textContent = String(cell.text ?? '');
element.style.top = `${rowTop(cell.row)}px`;
element.style.left = `${colLeft(cell.col)}px`;
element.style.height = `${height}px`;
element.style.width = `${width}px`;
cellsLayer.appendChild(element);
}
// Headings take their size from the same axis math as the cells, so custom
// widths/heights line up; hidden (0 px) rows and columns get no heading and
// do not count against the heading caps.
let rowHeadings = 0;
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
const height = rowSpan(row, row);
if (height <= 0) continue;
rowHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-row-heading';
heading.textContent = String(row);
heading.style.top = `${rowTop(row)}px`;
heading.style.height = `${height}px`;
heading.style.left = `${grid.scrollLeft}px`;
headingsLayer.appendChild(heading);
}
let columnHeadings = 0;
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
const width = colSpan(col, col);
if (width <= 0) continue;
columnHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-column-heading';
let label = '';
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
heading.textContent = label;
heading.style.left = `${colLeft(col)}px`;
heading.style.width = `${width}px`;
heading.style.top = `${grid.scrollTop}px`;
headingsLayer.appendChild(heading);
}
renderWarnings(tile.warnings);
}
function selectSheet(sheetId) {
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
activeSheetId = String(sheetId);
latestRequestId += 1;
latestRange = null;
latestAxes = null;
if (cellsLayer) cellsLayer.textContent = '';
if (headingsLayer) headingsLayer.textContent = '';
container.querySelectorAll('[role="tab"]').forEach((tab) => {
const selected = tab.dataset.sheetId === activeSheetId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
if (grid) {
grid.scrollTop = 0;
grid.scrollLeft = 0;
}
const sheet = sheetMetadata();
if (sheet && spacer) {
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
}
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
renderWarnings([]);
requestTile();
}
function renderMetadata(nextMetadata) {
if (!current()) return;
metadata = nextMetadata;
container.textContent = '';
if (!metadata.sheets?.length) {
message(container, 'This workbook has no visible worksheets.', 'empty');
return;
}
const shell = document.createElement('div');
shell.className = 'spreadsheet-preview-shell';
const styleSheet = document.createElement('style');
styleSheet.textContent = (metadata.styles || [])
.map((style, id) => {
const declarations = [];
if (style.font?.bold) declarations.push('font-weight:700');
if (style.font?.italic) declarations.push('font-style:italic');
// Colour and background are emitted together or not at all.
// The worker already contrast-checked them as a pair; contributing
// one half would drop the cell back onto the skin's own background.
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
}
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
if (style.wrapText) declarations.push('white-space:normal');
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
})
.join('');
const tabs = document.createElement('div');
tabs.className = 'spreadsheet-sheet-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('data-i18n-skip', '');
for (const sheet of metadata.sheets) {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'spreadsheet-sheet-tab';
tab.setAttribute('role', 'tab');
tab.dataset.sheetId = String(sheet.id);
tab.textContent = sheet.name;
tab.addEventListener('click', () => selectSheet(sheet.id));
tabs.appendChild(tab);
}
const notice = document.createElement('div');
notice.className = 'spreadsheet-preview-notice';
// Written already translated, item by item (renderWarnings), and it ends
// with workbook text (a number format's code): the observer's t() over
// the whole line would rewrite a `{name}` or a "Codeman" in that code.
notice.setAttribute('data-i18n-skip', '');
notice.hidden = true;
emptySheetState = document.createElement('div');
emptySheetState.className = 'spreadsheet-empty-sheet';
emptySheetState.textContent = 'This worksheet is empty.';
emptySheetState.hidden = true;
grid = document.createElement('div');
grid.className = 'spreadsheet-grid';
grid.setAttribute('data-i18n-skip', '');
spacer = document.createElement('div');
spacer.className = 'spreadsheet-grid-spacer';
cellsLayer = document.createElement('div');
cellsLayer.className = 'spreadsheet-cells';
headingsLayer = document.createElement('div');
headingsLayer.className = 'spreadsheet-headings';
grid.append(spacer, cellsLayer, headingsLayer);
grid.addEventListener(
'scroll',
() => {
pinHeadings();
if (scrollFrame !== null) return;
scrollFrame = global.requestAnimationFrame(() => {
scrollFrame = null;
requestTile();
});
},
{ passive: true }
);
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
container.appendChild(shell);
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
resizeObserver?.observe(grid);
selectSheet(metadata.sheets[0].id);
}
function dispose() {
if (disposed) return;
disposed = true;
clearTimer();
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
controller?.abort();
resizeObserver?.disconnect();
try {
worker?.postMessage({ type: 'dispose' });
worker?.terminate();
} catch {
// A worker that failed during startup may already be unavailable.
}
worker = null;
}
async function start() {
if (Number(options.size) > MAX_PREVIEW_BYTES) {
fail('This workbook is too large to preview (10 MB limit).');
return;
}
message(container, 'Loading spreadsheet…', 'loading');
let previewUrl;
try {
previewUrl = safePreviewUrl(options.url);
controller = new AbortController();
// Root-absolute paths ignore <base href>; route through the mount prefix.
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
const ready = new Promise((resolve, reject) => {
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
worker.onmessage = (event) => {
if (event.data?.type === 'ready') resolve();
};
});
const responsePromise = fetch(previewUrl, { signal: controller.signal });
const [response] = await Promise.all([responsePromise, ready]);
if (!current()) return;
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
const bytes = await response.arrayBuffer();
if (!current()) return;
worker.onmessage = (event) => {
if (!current()) return;
const payload = event.data || {};
if (payload.type === 'metadata') {
clearTimer();
renderMetadata(payload);
} else if (payload.type === 'tile') renderTile(payload);
else if (payload.type === 'error') fail(workerErrorText(payload));
};
worker.onerror = () => fail('Spreadsheet parser failed.');
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
timer = global.setTimeout(() => {
worker?.terminate();
fail('Spreadsheet preview timed out.');
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
worker.postMessage({ type: 'load', bytes }, [bytes]);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
}
}
void start();
return Object.freeze({ dispose, selectSheet, resize: requestTile });
}
global.CodemanSpreadsheetPreviewAssets = assets;
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
})(window);
File diff suppressed because it is too large Load Diff
+1330 -443
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -329,7 +329,8 @@ Object.assign(CodemanApp.prototype, {
{ 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)
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
+129 -3
View File
@@ -18,6 +18,15 @@
* the case this module exists for, and the case its browser test asserts by
* checking WHO delivered the byte rather than merely that one arrived.
*
* A SECOND failure lives in that same self-rescue: xterm diffs `newValue.replace(oldValue, '')`,
* which only works when the keyboard APPENDED. A soft keyboard that autocorrects on space
* (SwiftKey, Gboard) rewrites the tail instead: it deletes the word and inserts the corrected
* one, and xterm answers by sending the WHOLE new textarea value (the old value is not a
* substring of it), then sends the inserted text AGAIN from the second keydown's timer. One
* autocorrect turned `testing the peompt` + <space> into
* `testing the peompttesting the prompt rompt `. A multi-character delete is also sent as ONE
* DEL. `installEditSync` below replaces that diff with an edit-based one.
*
* The recovery never guesses the character: the `input` event already carries
* the real committed text in `ev.data`, which is exactly what xterm itself
* would have forwarded. We only decide WHETHER to forward it, by asking
@@ -33,12 +42,28 @@
* also be a CAPTURE listener; see the measured table at the addEventListener
* call below.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
* @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by
* terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm)
* @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers)
*/
(function (global) {
'use strict';
/**
* What turns `previous` into `next`, as a terminal sees it: how many characters to delete from
* the END of the line, then what to type. Everything after the common prefix is treated as
* replaced, because a terminal can only edit at its cursor. Counts are code points, so an
* emoji is one DEL, as it is one backspace.
*/
function editBetween(previous, next) {
const a = Array.from(previous);
const b = Array.from(next);
let prefix = 0;
const max = Math.min(a.length, b.length);
while (prefix < max && a[prefix] === b[prefix]) prefix += 1;
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
}
function create(options) {
const textarea = options?.textarea;
const emitRecovered = options?.emitRecovered;
@@ -65,6 +90,9 @@
let keydownSnapshot = 0;
let composing = false;
const pending = [];
// Applies any edit-sync diff still waiting on its timer. Assigned by installEditSync() below; a
// no-op when xterm's internals are not available.
let settleEdit = () => {};
/**
* Resolve every candidate still pending, right now, instead of waiting for
@@ -147,6 +175,13 @@
// reach the PTY — see flushPending(). This runs from xterm's custom key
// handler, i.e. before xterm processes the key, so a recovered character
// is always ordered ahead of the bytes this keydown produces.
// ORDER MATTERS. Settle the edit-sync diff first: it bumps `canonicalCount` for the
// keystroke it belongs to, so flushPending() then stands that keystroke's orphan candidate
// down. Swapped, the candidate would resolve first and the character would be sent twice.
// It also has to happen BEFORE xterm handles THIS key: for Enter, xterm clears the textarea
// in its own keydown, and a timer left pending would then diff the whole line against ''
// and send one DEL per character ahead of the submitted line.
settleEdit(event);
flushPending();
keydownSnapshot = canonicalCount;
}
@@ -176,6 +211,92 @@
}
}
/**
* Replace xterm's `_handleAnyTextareaChanges` (see the header) with an edit-based diff against
* the value the PTY side has already been told about. `synced` is that value; it is shared by
* every keydown's timer, so two timers that both see the final textarea value cannot both
* send it. Returns an uninstall function, or null when xterm's internals are not as expected
* (then xterm's own, flawed, behaviour is left in place).
*/
function installEditSync() {
let helper;
try {
helper = options.getCompositionHelper?.();
} catch {
return null;
}
const original = helper?._handleAnyTextareaChanges;
const coreService = helper?._coreService;
if (typeof original !== 'function' || typeof coreService?.triggerDataEvent !== 'function') return null;
let synced = textarea.value;
const waiting = new Set();
/** Send what changed since `synced`, once, and remember it. */
function applyEdit() {
if (destroyed || helper._isComposing) return; // xterm's composition path owns this one
const current = textarea.value;
if (current === synced) return;
const { deleted, inserted } = editBetween(synced, current);
synced = current;
try {
// One DEL per character, like repeated backspace presses: the local-echo composer and
// the PTY both treat each as a single edit.
for (let i = 0; i < deleted; i += 1) coreService.triggerDataEvent('\x7f', true);
if (inserted) {
helper._dataAlreadySent = inserted;
coreService.triggerDataEvent(inserted, true);
}
} catch {
// Delivery is best effort; never throw into the browser's timer queue.
}
}
helper._handleAnyTextareaChanges = function handleAnyTextareaChanges() {
if (destroyed) return original.call(this);
// No edit in flight and the value is not what we last sent: something outside the IME
// changed it (xterm clears it after Enter, a composition committed). Nothing to send;
// resynchronise.
if (waiting.size === 0 && synced !== textarea.value) synced = textarea.value;
const entry = { id: null };
waiting.add(entry);
entry.id = setTimer(() => {
waiting.delete(entry);
applyEdit();
}, 0);
};
// Apply the pending edit NOW instead of on its timer (see handleKeyEvent).
settleEdit = (event) => {
if (waiting.size === 0) return;
for (const entry of waiting) {
try {
clearTimer(entry.id);
} catch {
// A broken timer host must not break input handling.
}
}
// Cleared BEFORE the return below, on purpose: left pending, the timer would fire after
// Enter clears the textarea and send one DEL per character ahead of the submitted line.
waiting.clear();
// A composition just ended and this key makes xterm finalize it SYNCHRONOUSLY, through
// `_finalizeComposition(false)`, which ignores `_dataAlreadySent`: that text is xterm's, and
// sending the edit too would deliver it twice. On 229, CapsLock and the modifiers xterm keeps
// the composition on its async path, which honours `_dataAlreadySent`, so the edit still
// applies there. Only this settle path is guarded: on the timer path xterm always finalizes
// asynchronously, and skipping the edit there would drop a byte master delivers.
if (helper._isSendingComposition && ![229, 20, 16, 17, 18].includes(event?.keyCode)) return;
applyEdit();
};
return () => {
settleEdit = () => {};
if (helper._handleAnyTextareaChanges !== original) helper._handleAnyTextareaChanges = original;
};
}
const uninstallEditSync = installEditSync();
function onCompositionStart() {
if (destroyed) return;
composing = true;
@@ -191,6 +312,11 @@
if (destroyed) return;
destroyed = true;
cancelPending();
try {
uninstallEditSync?.();
} catch {
// Best effort.
}
try {
textarea.removeEventListener('input', onInput, true);
textarea.removeEventListener('compositionstart', onCompositionStart, true);
@@ -228,5 +354,5 @@
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
}
global.CodemanKeyCode229Recovery = Object.freeze({ create });
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
})(typeof window !== 'undefined' ? window : globalThis);
+641 -73
View File
@@ -10,18 +10,53 @@
* synchronous tmux call that blocks the server's event loop.
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
* handlers, no keyboard accessory bar. Desktop-only by nature; see
* docs/split-pane-sessions-plan.md.
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
* handlers (a swipe on a touch screen pages nothing), and no keyboard
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
* and docs/tile-grid-plan.md.
*
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
* (_maybePageCliTranscript) through the primary pane's gates, plus an
* overflow-row discount for this pane's capture-before-resize load
* (_localRows), and only while the viewport is on the live screen (a
* wheel-down from those overflow rows is xterm's, and brings it home).
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener), sent
* ephemeral, like every mouse report from this pane (_onTerminalData).
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
* pane, on this pane's own textarea and composition helper and sending to
* this pane's session (_createKeyCode229Recovery): it forwards an
* `insertText` xterm refused, settles a pending textarea edit at the next
* keydown ahead of that key (#441: the last character an Android keyboard
* commits in the same task as Enter), and replaces xterm's append-only
* keyCode-229 diff with an edit-based one (#541: autocorrect on space
* duplicated the line). Not a desktop-only concern: the grid and the split
* are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide
* Android tablet, or a large foldable unfolded in landscape, reaches.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
(function (global) {
// How long a scroll-to-top history pull may hold this pane's live output.
// How long a load may hold this pane's live output while it reads a bounded
// body: a scroll-to-top history pull, or a refresh of a bounded window.
const HISTORY_PULL_TIMEOUT_MS = 10000;
// How much of a replay is queued in xterm at once: a 1 MiB load goes in one
@@ -130,7 +165,19 @@
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._liveQueueBytes = 0;
this._markerOwed = false;
// Live-output flow control (_writeLive, TerminalTile.LIVE_BACKLOG_BUDGET):
// code units written into this xterm and not yet parsed (each write's
// callback counts its own back down, unless a reset bumped `_liveEpoch`
// since), whether output was dropped and not yet recovered, when the last
// frame was dropped, and the debounced, bounded recovery refresh.
this._liveInFlight = 0;
this._liveEpoch = 0;
this._liveDropped = false;
this._liveDropAt = 0;
this._dropRecoveryTimer = null;
this._dropRecoveryAttempt = 0;
this._onWheel = null;
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while
// this pane's socket is open, so the exactly-once input queue delivers this
@@ -157,6 +204,25 @@
// flag, app._linkHovered, belongs to its terminal alone).
this._linkHovered = false;
this._onFocusIn = null;
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
this._scrollBytes = '';
this._scrollFlushTimer = null;
// Rows above the screen that this pane pushed there itself rather than
// received as history: a capture taken at the PTY's previous, taller size
// and row-shrinking fits (_overflowAfterLoad, _noteResizeRows). Not
// history, so the paging gate leaves them out (_localRows).
this._overflowRows = 0;
// The desktop click reporter (_installClickListener).
this._onClick = null;
// The soft-keyboard controller (terminal-keycode229-recovery.js): created
// in connect() once the xterm is open, torn down in destroy().
this._keyCode229Recovery = null;
}
async connect() {
@@ -192,13 +258,33 @@
});
this._installWheelListener();
this._installClickListener();
// Focusing this terminal makes it the pane the keyboard is in, so the
// app-level shortcuts, voice and paste act on it (app._focusedPane).
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
this.terminal.onData((data) => this._onTerminalData(data));
this._createKeyCode229Recovery();
// The twin of terminal-ui.js's onData gate (initTerminal; keep the two in
// step). Canonical xterm data tells the controller this keystroke was
// delivered, but not a query reply or a focus/mouse report, which xterm
// emits on its own and which would otherwise stand a pending recovery
// down. The notify lives HERE and not in _onTerminalData(): the
// controller's own recovered bytes go through _onTerminalData() too, and
// must never count as xterm's, or a second pending character from the
// same keystroke window would stand down and be lost.
this.terminal.onData((data) => {
try {
const input = global.CodemanTerminalInput;
if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) {
this._keyCode229Recovery?.notifyCanonicalData?.();
}
} catch {
/* Bookkeeping must never block real input. */
}
this._onTerminalData(data);
});
// xterm has no gates of its own, so every app-level chord that the
// document capture-phase handler (app.js) only preventDefault()s (never
@@ -213,6 +299,19 @@
// here too. Ctrl+V goes through the primary pane's paste trap
// (image-input.js), aimed at this pane (below).
this.terminal.attachCustomKeyEventHandler((ev) => {
// FIRST, above the IME early return below, as in terminal-ui.js: every
// keydown settles this pane's pending textarea edit and drains a
// pending recovery BEFORE xterm handles the key, so a character an
// Android keyboard committed in the same task as Enter is sent ahead
// of the \r. Below that return a keyCode-229 keydown would skip the
// settle, the drain and the snapshot, and the panes would differ.
// Read at call time, never captured, so the controller can be swapped
// (the tests count xterm's emissions through it).
try {
this._keyCode229Recovery?.handleKeyEvent?.(ev);
} catch {
/* The controller must never interfere with xterm's own handling. */
}
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
@@ -429,7 +528,12 @@
this._lastSentDims = null;
this._registerInputSocket();
this._sendResize();
if (reconnected) this._refreshBuffer();
if (reconnected) {
// The gap already cost output, and the refresh below replaces the
// screen, so live-output accounting starts over with it.
this._resetLiveFlow();
this._refreshBuffer();
}
}
// Opens a replacement socket now instead of waiting out the backoff (for an
@@ -523,6 +627,44 @@
app?._sendInputAsync?.(this.sessionId, data);
}
// The soft-keyboard controller, the twin of the primary pane's wiring in
// terminal-ui.js initTerminal() (keep the two in step); the behaviour lives
// once, in terminal-keycode229-recovery.js. Everything it is handed is THIS
// pane's: its textarea, its xterm's CompositionHelper (whose
// `_handleAnyTextareaChanges` it patches, per instance) and its send path.
// Recovered text goes straight to _onTerminalData(), never through xterm's
// onData, so it is not counted as xterm's own (see connect()'s onData).
// Created after terminal.open(): xterm's capture `input` listener on the
// textarea is registered there, and must run before the controller's. No
// device or mode gate, as in the primary pane: with a hardware keyboard it
// costs one assignment per keydown. A failure leaves xterm's own handling.
_createKeyCode229Recovery() {
this._destroyKeyCode229Recovery();
if (!this.terminal) return;
try {
this._keyCode229Recovery =
global.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
emitRecovered: (data) => this._onTerminalData(data),
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
}) ?? null;
} catch {
this._keyCode229Recovery = null;
}
}
// Restores xterm's own textarea diff and removes the controller's capture
// listeners from the live textarea, so it runs before terminal.dispose().
_destroyKeyCode229Recovery() {
try {
this._keyCode229Recovery?.destroy?.();
} catch {
/* Optional; teardown must continue. */
}
this._keyCode229Recovery = null;
}
// Joins the app's input-socket map for this session and flushes anything
// already queued for it (typed while the socket was down, or left over from
// a reload) over the fresh socket. Called from onopen.
@@ -551,10 +693,11 @@
// 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.
// A trailing refresh is about to run, and it settles the marker itself:
// a replay's queued `\x1bc` would wipe one written here (it re-owes the
// marker on a closed socket and stamps it below the replay), and a refresh
// that writes nothing stamps the one still owed. Stamped here as well,
// there would be two marker writes for one close.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
@@ -568,11 +711,12 @@
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (above). The primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
// connect() (initial load) and by the refresh frames (`{t:'r'}`, `{t:'c'}`)
// and a reconnect (_refreshBuffer). The primary pane's own
// _onSessionNeedsRefresh (app.js) is scoped to `this.activeSessionId` and
// rewrites the primary terminal, neither of which applies to this
// independent pane, so this is a standalone equivalent rather than a call
// into it, in the primary's order (below).
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
@@ -584,6 +728,21 @@
// wrapper (constants.js), which already prefixes CodemanBase, unlike the
// raw WebSocket URL above, which does not.
//
// A refresh replaces what the pane shows, in the primary pane's order
// (_onSessionNeedsRefresh, _resetTerminalForReplay): fetch FIRST, so the
// pane keeps its last frame through the round trip (and through a grid
// tile's wait in the load queue); then the queued in-stream `\x1bc`, never
// xterm's clear(): clear() is synchronous while write() is parsed on a later
// tick, so live bytes still queued would land after it and fuse into the
// snapshot, and it keeps the cursor's row, column, SGR and margins, so the
// capture (raw rows, no home) started wherever the cursor sat. Live frames
// from the response onward are held (`_liveQueue`, the primary's
// _finishBufferLoad `since` rule, as _pullHistory() holds them) and only
// those that arrived after it are written behind the replay. A failed,
// aborted or empty fetch writes nothing and resets nothing: the pane keeps
// its last frame and every held frame. The initial load needs none of
// this: it runs before the pane has a socket, onto a fresh xterm.
//
// 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
@@ -594,52 +753,80 @@
this._bufferLoading = true;
await this._runLoad(refresh ? 'refresh' : 'initial', async () => {
this._loadRunning = true;
let replayed = false;
let capturedAt = 0;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever. Re-armed once a
// refresh's headers land (below), so one signal carries both budgets.
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 {
if (this._destroyed) return;
if (refresh) {
// Cleared at the load's turn, not when it was asked for: a grid tile
// waiting in the queue keeps its last frame instead of sitting blank.
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue
// a trailing refresh behind a pull that the socket's close then
// interrupts), so a refresh on a closed socket owes it back once its
// replay is written.
if (this._wsClosed) this._markerOwed = true;
}
const shell = this.sessionMode === 'shell';
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
// A deadline covering the body as well as the headers (the primary
// pane's budgets, CodemanFetchDeadline): a capture that never answers
// would otherwise hold this pane's single-flight flag, and in the grid
// the one load queue every tile waits behind, forever.
const controller = global.AbortController ? new global.AbortController() : null;
this._loadAbort = controller;
const budget = global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000;
const timer = controller ? setTimeout(() => controller.abort(), budget) : null;
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
let payload;
try {
const res = await fetch(
`/api/sessions/${this.sessionId}/terminal?${query}`,
controller ? { signal: controller.signal } : undefined
);
if (refresh) {
// The response's arrival stands in for the instant tmux took the
// capture (see _pullHistory()). Frames from here on are news the
// capture cannot hold, so they wait for the replay. From now on
// live output IS held, so a bounded window's body (at most
// TERMINAL_TAIL_SIZE) gets the pull's short budget; an unbounded
// capture (the split's Pane B, up to 32 MB) keeps the request's.
capturedAt = performance.now();
this._openLiveQueue();
if (shell || this.boundedLoad) armDeadline(HISTORY_PULL_TIMEOUT_MS);
}
payload = (await res.json())?.data ?? {};
} finally {
clearTimeout(timer);
clearTimeout(abortTimer);
this._loadAbort = null;
}
if (payload.terminalBuffer && this.terminal) {
if (payload.terminalBuffer && this.terminal && !this._destroyed) {
if (refresh) {
this.terminal.write('\x1bc');
this._overflowRows = 0; // the reset leaves nothing above the screen
replayed = true;
// The reset 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;
}
await writeChunked(
this.terminal,
payload.terminalBuffer,
() => this._destroyed,
(cancel) => (this._cancelReplay = cancel)
);
if (!this._destroyed && this.terminal) this._overflowRows = this._overflowAfterLoad(payload);
}
} catch {
/* Best-effort: live output still arrives once the socket connects. */
} finally {
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
// Before the flush: a refresh that recovered dropped output lets its
// held frames through.
if (refresh) this._settleDropRecovery({ replayed, capturedAt, timedOut: !!controller?.signal?.aborted });
// Held frames before the marker, so the marker stays the last thing on
// screen (see _pullHistory()).
this._flushLiveQueue(replayed ? capturedAt : 0);
this._stampMarkerIfOwed();
this._endBufferLoad();
}
@@ -683,34 +870,377 @@
}
}
// 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.
// Live terminal output. Written straight through, except while a refresh or
// 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 written behind the snapshot by that load's _flushLiveQueue() (the
// primary pane's _finishBufferLoad `since` rule), never underneath it.
// Held frames count against the same budget as unparsed ones: a pull or a
// refresh holds output for up to its body budget, and a flood meanwhile
// must not grow the queue without bound either.
_onLiveOutput(data) {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
else this.terminal?.write(data);
if (!data) return;
if (this._liveQueue) {
if (this._liveQueueBytes + data.length > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
this._liveQueueBytes += data.length;
this._liveQueue.push({ at: performance.now(), data });
return;
}
this._writeLive(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.
_openLiveQueue() {
this._liveQueue = [];
this._liveQueueBytes = 0;
}
// Releases the frames a load held (_liveQueue) and closes the queue. After a
// replay only those that arrived after the capture are news (`cutoff`, the
// response's arrival; earlier ones are already in it); with no replay
// (`cutoff` 0) every one is. Through _writeLive(), so they are counted (and
// a write that throws cannot skip the load's marker and trailing refresh).
_flushLiveQueue(cutoff) {
const queued = this._liveQueue ?? [];
this._liveQueue = null;
this._liveQueueBytes = 0;
for (const entry of queued) {
if (entry.at < cutoff) continue;
this._writeLive(entry.data);
}
}
// Writes one live frame into this xterm, under flow control. The server
// applies no backpressure (16 KB / 8 ms batches, never a bufferedAmount
// check), so a flood a tile cannot parse as fast as it arrives (a shell
// tile running `cat` on a huge log, `yes`) used to pile up in xterm's own
// write queue without bound, on a main thread six tiles share, until
// xterm's WriteBuffer throws past 50M code units and the frames were
// silently lost in onmessage's catch. The primary pane caps its queues
// and drops then recaptures (_onSessionTerminal, app.js); this is the
// tile's equivalent. Past TerminalTile.LIVE_BACKLOG_BUDGET unparsed, a
// frame is dropped and the tile stops writing until a refresh recaptures
// the screen (_scheduleDropRecovery): every byte after a hole is written
// onto a screen out of step with the PTY, which that refresh replaces
// anyway. A write that throws is the same drop, never a malformed frame.
_writeLive(data) {
const terminal = this.terminal;
if (!terminal || this._destroyed || !data) return;
if (this._liveDropped) {
this._noteLiveDrop();
return;
}
const n = data.length;
if (this._liveInFlight + n > TerminalTile.LIVE_BACKLOG_BUDGET) {
this._noteLiveDrop();
return;
}
const epoch = this._liveEpoch;
this._liveInFlight += n;
try {
terminal.write(data, () => {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
});
} catch {
if (epoch === this._liveEpoch) this._liveInFlight -= n;
this._noteLiveDrop();
}
}
// A live frame was dropped. Marks the tile out of step and arms ONE
// recovery; later drops only move the stamp the recovery has to beat.
_noteLiveDrop() {
this._liveDropAt = performance.now();
if (this._liveDropped) return;
this._liveDropped = true;
this._scheduleDropRecovery();
}
// The primary pane's dropped-output recovery (_scheduleDroppedOutputRecovery,
// app.js), aimed at this tile: debounced by DROP_RECOVERY_DELAY_MS so a
// sustained flood collapses into one attempt, and run as an ordinary
// refresh, which is single-flight, bounded (`lines=`/`tail=`) and waits its
// turn in the grid's load queue. _settleDropRecovery() decides what the
// refresh it starts achieved.
_scheduleDropRecovery() {
if (this._dropRecoveryTimer || this._destroyed) return;
const delay = global.CodemanDroppedOutput?.DROP_RECOVERY_DELAY_MS ?? 2000;
this._dropRecoveryTimer = setTimeout(() => {
this._dropRecoveryTimer = null;
if (this._destroyed || !this._liveDropped) return;
this._dropRecoveryAttempt++;
this._refreshBuffer();
}, delay);
}
// A refresh finished while output was marked dropped. Recovered when its
// replay's capture was taken after the last dropped frame (the response's
// arrival, the cutoff every load uses): output flows again. Otherwise one
// more attempt, bounded by the primary pane's rule
// (shouldRetryDroppedOutputRecovery: DROP_RECOVERY_MAX_ATTEMPTS, and never
// after a capture cut off at its deadline, a stalled link); past that the
// flag is released so the tile is never left frozen, and it writes on, out
// of step, as every tile did before this existed.
_settleDropRecovery({ replayed, capturedAt, timedOut }) {
if (!this._liveDropped || this._destroyed) return;
if (replayed && capturedAt >= this._liveDropAt) {
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
return;
}
// Another try is already on its way: the debounce, or a trailing refresh.
if (this._dropRecoveryTimer || this._bufferRefreshPending) return;
const retry =
global.CodemanDroppedOutput?.shouldRetryDroppedOutputRecovery?.({
repainted: false,
timedOut,
attempt: Math.max(0, this._dropRecoveryAttempt - 1),
stillActive: true,
}) === true;
if (retry) {
this._scheduleDropRecovery();
return;
}
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
}
// Starts live-output accounting over (a reconnect, destroy): write callbacks
// still pending from before carry the old epoch and count nothing.
_resetLiveFlow() {
this._liveEpoch++;
this._liveInFlight = 0;
this._liveDropped = false;
this._dropRecoveryAttempt = 0;
clearTimeout(this._dropRecoveryTimer);
this._dropRecoveryTimer = null;
}
// The server's `{t:'c'}` frame, which is a refresh, not a wipe. Its one
// emitter (Session.startInteractive, session.ts) sends it once a fresh Claude
// pane first shows its prompt: the server has just trimmed its own buffer and
// means "refresh after startup". The primary pane refetches the capture and
// replays it (_onSessionClearTerminal, app.js), and while the grid is open
// that handler stands aside for the tiles. A bare xterm clear() here kept
// only the cursor's row and dropped the banner and every row above it, and an
// idle Claude never repaints static rows, so a Claude session Run into the
// grid (or Attached in a tile) sat there as a near-empty tile. So it takes
// the `{t:'r'}` route: single-flight, coalesced into one trailing refresh
// behind a load already running (a pull's held frames included), and paced
// by the grid's load queue.
_onLiveClear() {
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
else this.terminal?.clear();
this._refreshBuffer();
}
// 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.
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
// stopPropagation in the capture phase, the primary pane's technique), so
// xterm's viewport, a descendant, never sees them. Every other wheel is left
// to xterm, which keeps doing the scrolling, and only observed for the
// shell history pull.
_installWheelListener() {
this._onWheel = (ev) => {
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
ev.preventDefault();
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
};
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
}
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
// pane's session through its own coalescer. Returns true when the wheel
// belongs to the CLI: a gesture with no whole line or no measurable cell is
// consumed too, as in the primary pane, so xterm never scrolls the stale
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
_maybeForwardWheelToCli(ev) {
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
// history" gesture, here as in the primary pane (whose capture-phase wheel
// handler scrolls with terminal.scrollLines() for the same reason). Left to
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
// wheel into a horizontal one, so the viewport never moved. Reads the
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
// for the next event (a trackpad's small deltas), and on the way up still
// asks a shell pane for more history. Returns true when the wheel was
// consumed here.
_maybeScrollLocalOnShift(ev) {
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
const input = global.CodemanTerminalInput;
if (!input?.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
const lines = Math.trunc(total);
this._shiftScrollPending = total - lines;
if (lines) {
this.terminal.scrollLines(lines);
if (lines < 0) this._maybeLoadMoreHistory();
}
return true;
}
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
// drag selects text), which leaves this xterm's own mouse encoder idle for
// them; without this a click in such a pane never reached the CLI. Only
// while this pane's session has tracking on (cliMouseTracking), through the
// same skips as the primary pane. Bubble phase, as there. The target is
// built per click, so the terminal and the link hover are read live.
_installClickListener() {
this._onClick = (ev) => {
if (this._destroyed || !this.terminal) return;
global.app?._handleDesktopTerminalClick?.(ev, {
terminal: this.terminal,
sessionId: this.sessionId,
linkHovered: this._linkHovered,
// Like every mouse report from this pane (_onTerminalData): once,
// never persisted, so a reload cannot replay it onto a later screen.
ephemeral: true,
});
};
this.mountEl.addEventListener('click', this._onClick);
}
// Hollow-buffer paging, the twin of the primary pane's
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
// the primary pane's own, asked for THIS pane (its terminal, its session,
// never the active one), so the CLI rules stay in terminal-ui.js and this
// file names no CLI. Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it (a shell running htop), as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
// primary hollow buffer has baseY 0, so its viewport is always at the
// bottom, while a tile's is hollow with its own overflow rows still above
// the screen, and Shift+PageUp, a scrollbar drag or a wheel during the
// first replay can leave the viewport up there. Paging from there would
// swallow every wheel (wheel-down included) and keep the stale rows on
// screen while the CLI pages out of view; left to xterm, a wheel-down
// brings the viewport home and paging resumes from there. The click
// report refuses an off-bottom viewport for the same reason
// (_terminalViewportAtBottom).
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
if (!lines) return false;
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
this._pageKeyPending = step.pending;
if (step.keys) this._queueScrollBytes(step.keys);
return true;
}
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
// session, over this pane's socket while it is open.
_queueScrollBytes(data) {
if (!data || this._destroyed) return;
if (this._scrollBytes.length > 512) return;
this._scrollBytes += data;
if (this._scrollFlushTimer) return;
this._scrollFlushTimer = setTimeout(() => {
this._scrollFlushTimer = null;
const bytes = this._scrollBytes;
this._scrollBytes = '';
if (bytes && !this._destroyed) global.app?._sendInputEphemeral?.(this.sessionId, bytes);
}, 40);
}
// History rows in this xterm, for the paging gate: baseY less the rows this
// pane pushed up itself. Clamped, because a clear (Ctrl+L) or an ED3/RIS in
// the stream drops rows behind this count's back.
_localRows() {
const baseY = this.terminal?.buffer?.active?.baseY || 0;
this._overflowRows = Math.min(this._overflowRows, baseY);
return baseY - this._overflowRows;
}
// After a local resize: rows a shrinking fit pushed above the screen count
// as overflow, and rows a growing one pulled back come off it. `before` is
// baseY just before the resize (xterm resizes synchronously).
_noteResizeRows(before) {
const after = this.terminal?.buffer?.active?.baseY || 0;
this._overflowRows = Math.max(0, Math.min(after, this._overflowRows + (after - before)));
}
// The overflow a finished load leaves: everything above the screen when the
// capture held a single screen (the server says how tall in `captureRows`;
// the full-history path keeps every pane row and trims one newline), none
// when it carried history. The counterpart of the primary pane resizing the
// PTY before it captures (app.js selectSession's sendResize), which this
// pane does not do: its first capture is taken at the PTY's previous size
// (usually the primary pane's, taller) and written into a shorter xterm,
// whose extra rows land above the screen with nothing after them to clear
// them. Without a `captureRows` the raw baseY stands, as in the primary.
_overflowAfterLoad(payload) {
const captureRows = payload?.captureRows;
if (!Number.isFinite(captureRows)) return 0;
const text = payload.terminalBuffer || '';
let lines = 1;
for (let i = text.indexOf('\n'); i !== -1 && lines <= captureRows; i = text.indexOf('\n', i + 1)) lines++;
if (lines > captureRows) return 0;
return this.terminal?.buffer?.active?.baseY || 0;
}
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
@@ -791,7 +1321,7 @@
// 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
// would buy nothing and freeze the pane for as long as the fetch took.
this._liveQueue = [];
this._openLiveQueue();
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
@@ -818,6 +1348,7 @@
}
this._historyPullUseless = false;
term.write('\x1bc');
this._overflowRows = 0; // the reset leaves nothing above the screen
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(
@@ -842,16 +1373,9 @@
clearTimeout(abortTimer);
this._loadAbort = null;
this._loadRunning = false;
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
// 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);
}
this._flushLiveQueue(replayed ? capturedAt : 0);
// 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
@@ -874,12 +1398,13 @@
return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`;
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row must not start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
// The refresh path (`{t:'r'}`, `{t:'c'}`, a reconnect): fetch, then reset
// in-stream and replay (_loadBuffer). Two refresh frames in a row must not
// start two concurrent replays, each resetting 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;
@@ -894,29 +1419,34 @@
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
const before = this.terminal?.buffer?.active?.baseY || 0;
this.fitAddon.fit();
this._noteResizeRows(before);
}
// Reflow to the container and tell the PTY, as one step: the xterm and the
// PTY must never disagree about size (#464), and a font change is a size
// change too, so the font setters call this rather than localFit().
// `force` resends an unchanged size.
// `force` resends an unchanged size and asks the server to apply it anyway
// (Redraw, restoreTerminalSize). Returns whether a resize went out.
fit({ force = false } = {}) {
this.localFit();
this._sendResize({ force });
return this._sendResize({ force });
}
// Returns true only once a `{t:'z'}` frame was sent, false at every early
// exit, so Redraw can say when nothing reached the PTY.
_sendResize({ force = false } = {}) {
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
if (!this._wsReady || !this.fitAddon || !this.terminal) return false;
// 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 pane was opened, so its own window now
// owns the PTY's size and this pane must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
if (this.detachedSessions?.has(this.sessionId)) return false;
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
// fit() then leaves the xterm alone: there is no size worth reporting.
const dims = this.fitAddon.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return false;
// Report what the xterm actually holds, so the PTY gets exactly the size
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
// a floor would misreport the split's Pane B at its divider's reachable
@@ -926,9 +1456,20 @@
const cols = this.terminal.cols;
const rows = this.terminal.rows;
const last = this._lastSentDims;
if (!force && last && last.cols === cols && last.rows === rows) return;
if (!force && last && last.cols === cols && last.rows === rows) return false;
// `f` is the primary pane's forced resize (sendResize, terminal-ui.js):
// Session.resize (session.ts) otherwise skips a size equal to the one it
// last applied, so without it a forced resend reached the server and did
// nothing there (no tmux resize-window, no PTY resize).
const msg = { t: 'z', c: cols, r: rows, v: 'desktop' };
if (force) msg.f = true;
try {
this.ws.send(JSON.stringify(msg));
} catch {
return false; // nothing went out, so nothing is recorded as sent
}
this._lastSentDims = { cols, rows };
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
return true;
}
// The session's PTY is new: a tile can connect before its session has a
@@ -955,7 +1496,9 @@
{ cols, rows }
);
if (!verdict?.adopt) return;
const before = terminal.buffer?.active?.baseY || 0;
terminal.resize(verdict.cols, terminal.rows);
this._noteResizeRows(before); // a column change reflows rows above the screen
this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
}
@@ -982,11 +1525,27 @@
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
this._onWheel = null;
}
if (this._onClick) {
this.mountEl?.removeEventListener('click', this._onClick);
this._onClick = null;
}
// A disposed xterm never runs its write callbacks, and a pending
// recovery would refresh a pane nobody can see.
this._resetLiveFlow();
// Page keys still waiting for their flush go nowhere: the pane is gone.
clearTimeout(this._scrollFlushTimer);
this._scrollFlushTimer = null;
this._scrollBytes = '';
this._pageKeyPending = 0;
this._detachSocket();
if (this._onFocusIn) {
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
this._onFocusIn = null;
}
// Before dispose(): puts xterm's own textarea diff back and takes the
// controller's listeners off the textarea; its pending timers are inert
// once it is destroyed.
this._destroyKeyCode229Recovery();
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
// primary terminal (_focusedPane also skips a destroyed tile on its own).
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
@@ -998,6 +1557,15 @@
}
}
// Code units of live output a pane lets sit unparsed in its xterm (or held
// behind a replay) before it drops a frame and recaptures (_writeLive). Not
// the primary pane's 128 KB: that caps its own rAF-paced queues, about two
// frames of them, while here xterm itself is the pacer, a burst normally
// parses within a frame or two, and a tight cap would trip on ordinary
// bursts and blank-and-reload the tile over and over. A few MB keeps a flood
// far below xterm's 50M code-unit throw and bounds each tile's memory.
TerminalTile.LIVE_BACKLOG_BUDGET = 4 * 1024 * 1024;
// The marker a pane writes when its socket drops: a transient drop says it is
// reconnecting; a permanent stop says why, keyed by close code. All start
// with `[disconnected` so a reader (and a test) can tell any of them apart
+243 -98
View File
@@ -92,6 +92,62 @@
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
// cap: a fling must not build a backlog that keeps paging after it stops.
const PAGE_KEY_MAX_PER_BATCH = 3;
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
// Shift-axis trap and the deltaMode units), pure so a TerminalTile pages with
// the same math against its own row count.
function wheelDeltaLines(ev, rows) {
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
if (!delta) return 0;
return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel)
? delta
: ev.deltaMode === 2 // DOM_DELTA_PAGE
? delta * (rows || 24)
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
// live-screen relative): button 64 per line up, 65 per line down, capped at
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
// byte-identical reports to its own session.
function sgrWheelReports(lines, pos) {
if (!lines || !pos) return '';
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
// its own pending travel) pages identically.
function pageKeysForTravel(pending, lines, rows) {
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
const total = (pending || 0) + lines;
const pages = Math.trunc(total / perPage);
const keys = pages
? (pages < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN).repeat(Math.min(Math.abs(pages), PAGE_KEY_MAX_PER_BATCH))
: '';
return { pending: total - pages * perPage, keys };
}
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
@@ -229,6 +285,11 @@
KEY_PAGE_DOWN,
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
@@ -1273,7 +1334,8 @@ Object.assign(CodemanApp.prototype, {
// A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output
// (isAltScreenStripMode, session.ts) so the wheel keeps scrolling
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
// so a drag selects text) so the wheel keeps scrolling
// scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions.
@@ -1805,10 +1867,14 @@ Object.assign(CodemanApp.prototype, {
// registers its own listener with `capture: true`; on bubble xterm's
// `cancel()` (stopPropagation) would swallow exactly the handled events —
// see the measured table in terminal-keycode229-recovery.js.
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
// connect() key handler and onData) wires its own controller the same way
// for every grid tile and the split's Pane B; keep the two in step.
try {
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
emitRecovered: (data) => handleTerminalData(data),
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
});
} catch {
@@ -4568,6 +4634,15 @@ Object.assign(CodemanApp.prototype, {
/** Insert editable text at the active prompt without pressing Enter. */
insertTerminalText(text) {
if (!this.activeSessionId || !text) return;
// A tile or the split's Pane B holds the keyboard: the text belongs to that
// pane's session. Those panes have no local-echo overlay, and the main
// overlay is parked behind the grid, so send it straight to the pane.
const pane = this._focusedPane?.();
if (pane && !pane.isPrimary) {
this._sendInputAsync(pane.sessionId, text);
pane.terminal?.focus();
return;
}
// Under predict the text goes out via sendInput (bypasses onData), so the
// hook never sees it: clear outstanding predictions here instead.
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
@@ -4591,6 +4666,16 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return;
if (typeof CjkInput !== 'undefined') CjkInput.clear();
// A tile or Pane B holds the keyboard: its TUI owns the editable buffer
// (no local-echo overlay there), so kill the line in that pane's session,
// never in the parked main pane's.
const pane = this._focusedPane?.();
if (pane && !pane.isPrimary) {
this._sendInputAsync(pane.sessionId, '\x15');
this.showToast?.('Input cleared', 'success');
pane.terminal?.focus();
return;
}
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
@@ -4627,11 +4712,24 @@ Object.assign(CodemanApp.prototype, {
*/
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.
// size it renders at (TerminalTile.fit sends the same forced `f` resize
// sendResize sends below), whatever another device set. fit() says whether
// the resize went out; when it did not, say why rather than report a size
// that was never sent, as the primary branch does below.
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');
const sent = pane.tile.fit({ force: true });
if (sent !== false) {
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
} else if (this.detachedSessions?.has(pane.sessionId)) {
// Its own window owns the PTY's size (TerminalTile._sendResize yields).
this.showToast('This session is sized by its own window', 'warning');
} else if (!pane.tile._wsReady) {
// The tile announces its size again as soon as its socket reopens.
this.showToast('Terminal not connected: its size is sent when it reconnects', 'warning');
} else {
this.showToast('Could not determine terminal size', 'error');
}
return;
}
if (!this.activeSessionId) {
@@ -4853,8 +4951,9 @@ Object.assign(CodemanApp.prototype, {
* settings save can forget to call, and the toggle takes effect on the next
* selection instead of the next reload. ⚠️ The test is `!== false`, not
* `=== true`: this one defaults ON, and the desktop branch of
* getDefaultSettings returns {} and leans on the read sites for defaults, so
* a device that has never opened App Settings has no stored value at all.
* getDefaultSettings sets no copyStripMargin and leans on the read sites for
* defaults, so a device that has never opened App Settings has no stored
* value at all.
*/
_copyStripMarginEnabled() {
try {
@@ -5303,9 +5402,10 @@ Object.assign(CodemanApp.prototype, {
// follows the same path as a desktop click.
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
} else if (shouldActivate && this._shouldReportMouseToCli()) {
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
// report directly to the PTY while retaining local touch scrollback. Only
// while the CLI actually has tracking on (see _shouldReportMouseToCli).
// This session's mouse DECSETs are stripped from the browser stream
// (strip-full or strip-mux-and-mouse), so report directly to the PTY
// while retaining local touch scrollback, and only while the CLI
// actually has tracking on (see _shouldReportMouseToCli).
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}
@@ -5369,69 +5469,81 @@ Object.assign(CodemanApp.prototype, {
}
},
// Mirror of the server's isAltScreenStripMode (session.ts): session modes whose
// output stream has mouse-tracking DECSET sequences stripped before reaching the
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
/**
* True when the browser has to hand-encode a click report for the CLI.
* True when the browser has to hand-encode a click report for the CLI: the
* server stripped this session's mouse-tracking DECSETs out of the stream (so
* xterm's own encoder is permanently idle here and something has to stand in
* for it) AND the CLI has a tracking mode on right now.
*
* Two conditions, and dropping either one is a bug that has already happened:
* One flag answers both. The server sets `cliMouseTracking` only as it strips a
* tracking DECSET (`_recordStrippedMouseMode` in session.ts, called from the
* mouse-strip branch of `_handleTerminalOutput` and nowhere else), so it can
* only ever be true for a mode whose DECSETs are stripped: whichever modes the
* registry decides to strip, the browser follows, with no mode list here to
* keep in step. For a `preserve` / `strip-mux-only` mode the flag stays false
* and xterm keeps encoding its own reports. That invariant is pinned server-side
* in test/claude-scrollback-strip.test.ts.
*
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
* xterm's own encoder is permanently idle here and something has to stand
* in for it.
* 2. The CLI actually has a mouse-tracking mode on right now. The server
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
* publishes it as `cliMouseTracking`. Without this half the browser
* reported EVERY click, so a CLI sitting at its composer with no dialog
* open, or a pane that has fallen back to a shell prompt, received mouse
* reports it never asked for. A shell prints those as literal text
* (`[<0;88;20M`) and they garble the next line typed.
* Without the flag the browser reported EVERY click, so a CLI sitting at its
* composer with no dialog open, or a pane that has fallen back to a shell
* prompt, received mouse reports it never asked for. A shell prints those as
* literal text (`[<0;88;20M`) and they garble the next line typed.
*
* Fails toward silence: an unknown or stale flag reports nothing rather than
* injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does.
*
* `sessionId` defaults to the primary pane's session; a TerminalTile passes
* its own (through _handleDesktopTerminalClick's target), never the active one.
*/
_shouldReportMouseToCli() {
const session = this.sessions?.get(this.activeSessionId);
const mode = session?.mode || 'claude';
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
return session?.cliMouseTracking === true;
_shouldReportMouseToCli(sessionId = this.activeSessionId) {
return this.sessions?.get(sessionId)?.cliMouseTracking === true;
},
// True when xterm's viewport shows the live PTY screen (not scrolled up into
// local scrollback). SGR coordinates are only meaningful then: the TUI's
// screen is the bottom `rows` of the buffer, so a report computed from a
// scrolled-up viewport would hit-test a completely different row.
_terminalViewportAtBottom() {
const buf = this.terminal?.buffer?.active;
// `terminal` defaults to the primary pane's (a TerminalTile passes its own).
_terminalViewportAtBottom(terminal = this.terminal) {
const buf = terminal?.buffer?.active;
return !buf || buf.viewportY >= buf.baseY;
},
// Map a viewport point to a 1-based terminal cell the same way xterm maps a
// click: offset inside .xterm-screen divided by the rendered cell size,
// clamped to the grid. Returns null when the terminal isn't measurable yet.
_clientPointToCell(clientX, clientY) {
if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
const screen = this.terminal.element?.querySelector('.xterm-screen');
const cell = this.terminal._core?._renderService?.dimensions?.css?.cell;
// `terminal` defaults to the primary pane's (a TerminalTile passes its own).
_clientPointToCell(clientX, clientY, terminal = this.terminal) {
if (!terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
const screen = terminal.element?.querySelector('.xterm-screen');
const cell = terminal._core?._renderService?.dimensions?.css?.cell;
if (!screen || !cell?.width || !cell?.height) return null;
const rect = screen.getBoundingClientRect();
const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1));
const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1));
const col = Math.max(1, Math.min(terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1));
const row = Math.max(1, Math.min(terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1));
return { col, row };
},
// Encode a tap as an SGR mouse report (press + release at button 0) and send it
// to the PTY directly, bypassing xterm's mouse encoder.
_sendSyntheticSgrTap(clientX, clientY) {
if (!this.activeSessionId) return;
if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing
const pos = this._clientPointToCell(clientX, clientY);
// to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal,
// sessionId, ephemeral }) aims it at a TerminalTile instead of the primary
// pane; either of the first two left out means the primary pane's.
// `ephemeral: true` sends it through _sendInputEphemeral instead of the
// persisted exactly-once queue: a TerminalTile's mouse reports never enter
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one
// onto a later screen. Left out, the report stays on _sendInputAsync, as the
// primary pane always sent it.
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
const sessionId = target.sessionId || this.activeSessionId;
const terminal = target.terminal || this.terminal;
if (!sessionId) return;
if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing
const pos = this._clientPointToCell(clientX, clientY, terminal);
if (!pos) return;
this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
const report = `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`;
if (target.ephemeral) this._sendInputEphemeral(sessionId, report);
else this._sendInputAsync(sessionId, report);
},
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
@@ -5480,25 +5592,23 @@ Object.assign(CodemanApp.prototype, {
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
const lines = this._wheelScrollLinesFloat(ev);
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
return Math.round(lines) || (lines > 0 ? 1 : -1);
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
* sub-line fractions across events instead of forcing every tiny trackpad
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. */
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
* math is the pure CodemanTerminalInput.wheelDeltaLines (top of this file),
* which a TerminalTile calls with its own row count. */
_wheelScrollLinesFloat(ev) {
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
if (!delta) return 0;
return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel)
? delta
: ev.deltaMode === 2 // DOM_DELTA_PAGE
? delta * (this.terminal?.rows || 24)
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows);
},
_shouldForwardWheelToApp(ev) {
// `target` ({ terminal, sessionId }) asks the question for a TerminalTile:
// its own terminal's tracking mode and its own session, never the active one.
// Either field left out means the primary pane's.
_shouldForwardWheelToApp(ev, target = {}) {
if (ev.shiftKey) return false;
// Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
@@ -5515,9 +5625,9 @@ Object.assign(CodemanApp.prototype, {
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript and the setting keeps meaning exactly what it says.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = this.terminal?.modes?.mouseTrackingMode;
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(this.activeSessionId);
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
const sessionMode = session?.mode || 'claude';
if (sessionMode !== 'claude') return false;
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
@@ -5557,13 +5667,13 @@ Object.assign(CodemanApp.prototype, {
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
},
/**
@@ -5572,6 +5682,10 @@ Object.assign(CodemanApp.prototype, {
* tmux send-keys server-side, so per-event writes would spawn a process storm
* on a single flick; the queue is bounded so a wild scroll can't build a
* backlog that keeps scrolling after the finger stops.
*
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
* terminal-tile.js: same 40ms window, same 512-byte bound) because this queue
* flushes to the active session only; keep the two in step.
*/
_queueScrollBytes(data) {
if (!data || !this.activeSessionId) return;
@@ -5583,18 +5697,39 @@ Object.assign(CodemanApp.prototype, {
},
/**
* True when this session's LOCAL scrollback is structurally empty: a Claude
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
* overwrites the last, so xterm's normal buffer never grows past one screen
* True when this session's LOCAL scrollback is structurally empty: a pane whose
* TUI repaints one full screen in place, so tmux keeps no history for it
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
* is routed — the "wheel does nothing at all" half of the #205 retest.
*
* Two shapes, measured separately:
* - `claude` in repaint mode (the original, #205 round 2), and
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
* terminal's scrollback at all. It pages its own transcript with the same
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
* byte-identical — so paging is the only gesture that reaches it. Without
* this the wheel was silently dead in every opencode tab.
*
* Every other mode is deliberately absent: shell/pi own real terminal
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
* is unverified (docs/scrollback-fix-plan.md).
*
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
* calls this with its own session and terminal, so the mode list above stays
* here alone. `localRows` replaces `baseY` as the history row count: a tile
* discounts the stale rows its own load order leaves above the screen
* (TerminalTile._localRows). Every field left out means the primary pane's.
*/
_localScrollbackIsHollow() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude') return false;
const buf = this.terminal?.buffer?.active;
_localScrollbackIsHollow(target = {}) {
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
if (mode !== 'claude' && mode !== 'opencode') return false;
const buf = (target.terminal || this.terminal)?.buffer?.active;
if (!buf || buf.type === 'alternate') return false;
return (buf.baseY || 0) === 0;
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
return (rows || 0) === 0;
},
/**
@@ -5602,21 +5737,28 @@ Object.assign(CodemanApp.prototype, {
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
*
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
* on a Claude session that has no local history to fall back on: the CLI
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
* tracking flag is unset (the inline renderer, or fullscreen right after a
* server restart), or the user turned on "Wheel scrolls local history" (which
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
* setting's footgun). Before this, all of those produced a completely dead
* gesture; the #205 reporter proved the keyboard route works by paging back
* through intact text with Fn+Up.
* on a session that has no local history to fall back on: the CLI version probe
* failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is
* unset (the inline renderer, or fullscreen right after a server restart), the
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
* opencode, which never fills the buffer and never accepts the wheel. Before
* this, all of those produced a completely dead gesture; the #205 reporter
* proved the keyboard route works by paging back through intact text with Fn+Up.
*
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
* real local scrollback is never touched. Shift is excluded on purpose: it is
* the explicit "give me local scrollback" gesture and must keep that meaning.
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
* session with real local scrollback is never touched. Shift is excluded on
* purpose: it is the explicit "give me local scrollback" gesture and must keep
* that meaning.
*
* @returns true when the gesture was consumed here (the caller must not also
* scroll locally).
*
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
* through the same gates and the same pageKeysForTravel arithmetic; keep the
* two in step. The tile adds one gate this pane cannot need, viewport at the
* bottom: hollow here means baseY 0, so this viewport is always there, while a
* tile is hollow with its own discounted rows still above the screen.
*/
_maybePageCliTranscript(ev, lines) {
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
@@ -5626,15 +5768,9 @@ Object.assign(CodemanApp.prototype, {
this._pageKeySession = this.activeSessionId;
this._pageKeyPending = 0;
}
const tuning = window.CodemanTerminalInput;
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
const pending = (this._pageKeyPending || 0) + lines;
const pages = Math.trunc(pending / perPage);
this._pageKeyPending = pending - pages * perPage;
if (pages) {
const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN;
this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH)));
}
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
this._pageKeyPending = step.pending;
if (step.keys) this._queueScrollBytes(step.keys);
this._logScrollRouting('page-keys');
return true;
},
@@ -5689,18 +5825,26 @@ Object.assign(CodemanApp.prototype, {
// synthetic SGR press could e.g. dismiss a claude permission dialog),
// clicks outside the cell grid, and sessions where xterm's own encoder is
// live (it reported the click itself — a second report would double-move).
_handleDesktopTerminalClick(ev) {
if (!this.terminal || !ev?.isTrusted) return;
//
// `target` ({ terminal, sessionId, linkHovered, ephemeral }) runs the same
// skips for a TerminalTile's click: its own terminal, its own session's
// tracking flag and its own link hover (the primary pane's _linkHovered
// belongs to its terminal alone). `ephemeral` reaches _sendSyntheticSgrTap,
// so a TerminalTile's mouse report never enters the persisted input queue.
// Every field left out means the primary pane's.
_handleDesktopTerminalClick(ev, target = {}) {
const terminal = target.terminal || this.terminal;
if (!terminal || !ev?.isTrusted) return;
if (ev.button !== 0 || ev.detail !== 1) return;
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const mode = this.terminal.modes?.mouseTrackingMode;
const mode = terminal.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return;
if (!this._shouldReportMouseToCli()) return;
if (this.terminal.hasSelection?.()) return;
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
if (!this._shouldReportMouseToCli(target.sessionId)) return;
if (terminal.hasSelection?.()) return;
if (target.linkHovered ?? this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
if (!ev.target?.closest?.('.xterm-screen')) return;
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
this._sendSyntheticSgrTap(ev.clientX, ev.clientY, target);
},
/**
@@ -5714,7 +5858,8 @@ Object.assign(CodemanApp.prototype, {
* The reason is that the habit and xterm's Shift mean different things once
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
* the app actually has mouse tracking on; the server strips those DECSETs for
* claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
* so xterm's mouseTrackingMode is
* permanently `none`, that branch is unreachable, and Shift instead falls into
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
* when `selectionStart` is null, so the drag never anchors and no selection is
+117 -29
View File
@@ -233,6 +233,8 @@ Object.assign(CodemanApp.prototype, {
onChange: (tile, state) => {
const entry = grid.tiles.get(tile.sessionId);
if (entry?.tile !== tile) return;
// Rewritten on the way in, so a language switched since is picked up.
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
entry.el.classList.toggle('tile--loading', state !== 'idle');
// The first capture has landed (or failed): the terminal fades in,
// whole, instead of showing its replay scroll by.
@@ -243,6 +245,17 @@ Object.assign(CodemanApp.prototype, {
return grid.queue;
},
/**
* The "Loading…" label of a tile body. It is CSS generated content (styles.css,
* `content: attr(data-loading-label)`), which the i18n layer never reaches, so
* the text is written here in the UI language.
*/
_setTileLoadingLabel(body) {
if (!body) return;
const t = window.codemanT;
body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…';
},
_tileGridSection() {
let section = document.getElementById('tileGrid');
if (!section) {
@@ -253,16 +266,65 @@ Object.assign(CodemanApp.prototype, {
const wrap = document.querySelector('.terminal-wrap');
wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
}
// Once per section (index.html ships it, so not only on create).
if (this._tileFileDropSection !== section) {
this._tileFileDropSection = section;
this._installTileFileDrop(section);
}
return section;
},
/**
* A file dragged over the grid. The single view's file drop (image-input.js)
* listens on #terminalContainer, hidden while tiles are open, so nothing
* cancelled a file drag here and the browser opened the file in place of
* Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the
* padding) the drag is cancelled, so the page never navigates; dropped on a
* tile, its images upload to THAT tile's session and their paths are typed
* there, as the single view does for the active one. Bubble phase, files
* only: a tab or tile drag carries none, and its target stops it in the
* capture phase anyway (_acceptTabDrops).
*/
_installTileFileDrop(section) {
const isFileDrag = (e) => {
const types = e.dataTransfer?.types;
return !!types && Array.from(types).includes('Files');
};
// The open grid's tile under `target`, or null (an empty cell, a divider, the padding).
const tileAt = (target) => {
const grid = this._tileGrid;
if (!grid?.open || !target) return null;
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id;
return null;
};
section.addEventListener('dragover', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy';
});
section.addEventListener('drop', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
const sessionId = tileAt(e.target);
const files = Array.from(e.dataTransfer?.files || []);
if (!sessionId || files.length === 0) return;
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
if (images.length === 0) {
this.showToast?.('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages?.(images, { sessionId });
});
},
/**
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
* at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
* adds what is missing and moves focus. `auto: false` makes the focus a human
* selection (it acknowledges that session's idle alert). An open split
* closes (the two are never open together); `mergeSplit` (default) makes its
* two sessions the first tiles, false opens exactly `ids` (a stored grid).
* two sessions the first tiles, false opens exactly `ids` (a stored grid, a
* group, a Ctrl/Cmd+click: callers that size their own set).
*
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
* its snapshot of the session it shows is still right, and closes its socket.
@@ -275,15 +337,13 @@ Object.assign(CodemanApp.prototype, {
const max = window.CodemanTileGrid.TILE_GRID_MAX;
// The grid and the split are never open together. An open split becomes the
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
// want more" is one step. No closing resize for Pane A: it is about to park.
// want more" is one step.
let requested = ids || [];
if (this._splitPane) {
const splitOpen = !!this._splitPane;
if (splitOpen && mergeSplit) {
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
this.closeSplitPane({ skipPrimaryResize: true });
if (mergeSplit) {
requested = [...seed, ...requested];
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
}
requested = [...seed, ...requested];
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
}
const wanted = [];
for (const id of requested) {
@@ -292,6 +352,12 @@ Object.assign(CodemanApp.prototype, {
wanted.push(id);
if (wanted.length === max) break;
}
// No closing resize for Pane A only when it becomes a tile (its tile sizes
// the PTY). A Pane A left out of the set (a group, a stored grid: mergeSplit
// false) gets its full width back now, while the main terminal still shows
// it, or its PTY stays at the split's half width for as long as the grid
// is open.
if (splitOpen) this.closeSplitPane({ skipPrimaryResize: wanted.includes(this.activeSessionId) });
if (wanted.length === 0) return grid.open;
const focus = wanted.includes(focusedId) ? focusedId : wanted[0];
@@ -1014,9 +1080,12 @@ Object.assign(CodemanApp.prototype, {
/**
* The tile chord `e` asks for, if it applies right now, else null. The
* toggle applies while the grid is open, or where one could open AND the
* per-device `showTileGridButton` setting is on: with it off (the default)
* the chord is inert and reaches the terminal like any unbound key (owner
* decision 6 in docs/tile-grid-plan.md). The focus, move,
* per-device `showTileGridButton` setting is on (the desktop default; OFF on
* handhelds and touch-primary tablets): with it off the chord is inert and
* reaches the terminal like any unbound key (owner decision 6 in
* docs/tile-grid-plan.md). An absent key
* resolves through the device defaults exactly as the header button does
* (settings-ui.js), so the chord and the button can never disagree. The focus, move,
* zoom and remove chords apply only while the grid is open, however it was
* opened (a move chord also while a tile is zoomed, as a no-op, so its keys
* never reach the CLI). The arrow chords never apply in a text field, whose
@@ -1036,7 +1105,8 @@ Object.assign(CodemanApp.prototype, {
if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue;
if ((spec.direction || spec.move) && isTextFieldTarget(e.target)) continue;
if (spec.needsOpen) return open ? shortcut.id : null;
const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true;
const stored = this.loadAppSettingsFromStorage?.()?.showTileGridButton;
const enabled = (stored ?? this.getDefaultSettings?.()?.showTileGridButton ?? true) === true;
return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null;
}
return null;
@@ -1137,10 +1207,14 @@ Object.assign(CodemanApp.prototype, {
// replayed fresh (forceReload drops the stale snapshot and nulls
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
// or, if that session is gone, the same fallback as closing the active tab.
// Returns the selection's promise (it settles once the replay is written),
// or undefined for the welcome screen.
// A popped-out session counts as gone in both: selectSession would only
// raise its window and return, leaving the parked terminal's pre-grid
// content on screen under its tab (and saved as its snapshot on the next
// switch). Returns the selection's promise (it settles once the replay is
// written), or undefined for the welcome screen.
_selectAfterTileGrid(sessionId) {
if (sessionId && this.sessions.has(sessionId)) {
const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id);
if (sessionId && usable(sessionId)) {
return this.selectSession(sessionId, { forceReload: true, auto: true });
}
this.activeSessionId = null;
@@ -1149,7 +1223,7 @@ Object.assign(CodemanApp.prototype, {
} catch {
/* Nothing stored. */
}
const next = this.sessionOrder.find((id) => this.sessions.has(id));
const next = this.sessionOrder.find(usable);
if (next) return this.selectSession(next, { auto: true });
this.terminal?.clear();
this.showWelcome();
@@ -1210,11 +1284,14 @@ Object.assign(CodemanApp.prototype, {
* Removes one tile; the session keeps running. Its cell becomes empty where
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
* moves focus to the neighbouring tile (next in grid order, else previous),
* as the app's choice (`auto`: no idle alert is spent). The last tile
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
* DOM focus where it is (an app-driven removal: a socket the server closed),
* so keystrokes never land in the neighbour's PTY unasked. The last tile
* leaving closes the grid: with `refocus` the single view then shows that
* session, without it the caller decides what comes next.
* session (or, popped out, the next one: _selectAfterTileGrid), without it
* the caller decides what comes next.
*/
removeTile(sessionId, { refocus = true } = {}) {
removeTile(sessionId, { refocus = true, focus = true } = {}) {
const grid = this._tileGrid;
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
if (!entry) return false;
@@ -1240,7 +1317,7 @@ Object.assign(CodemanApp.prototype, {
this._applyTileLayout();
this._scheduleTileGridRefit();
this.renderSessionTabs?.();
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true });
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
return true;
},
@@ -1276,6 +1353,7 @@ Object.assign(CodemanApp.prototype, {
const header = this._buildTileHeader(sessionId);
const body = document.createElement('div');
body.className = 'tile-body';
this._setTileLoadingLabel(body);
el.append(header.el, body);
// Pressing a tile is a human selection: it focuses the tile and
// acknowledges its idle alert (the already-focused tile hits
@@ -1328,7 +1406,8 @@ Object.assign(CodemanApp.prototype, {
* `accepts(id)` is the target's own rule (a tile takes any session but its
* own; an empty cell takes any). A session it does not accept
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
* drop follows). Any other drag (a file) is left alone.
* drop follows). Any other drag (a file) is left to the grid section's own
* guard (_installTileFileDrop).
*/
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
@@ -1522,7 +1601,9 @@ Object.assign(CodemanApp.prototype, {
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
const base = this._tileGridOpenSet()?.ids || [];
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
// it), and merging it again went past the count (N+1) and the window.
this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false });
return true;
},
@@ -1564,7 +1645,9 @@ Object.assign(CodemanApp.prototype, {
this.closeTileGrid({ keepStored: false, reselect: false });
this.activeSessionId = null;
}
return this.openTileGrid(ids, { focusedId: focus });
// Exactly the group: an open split closes without joining it (merged, its
// two pushed group members out and the grid past the window's capacity).
return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false });
},
/** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
@@ -2033,7 +2116,9 @@ Object.assign(CodemanApp.prototype, {
/**
* A tile's socket stopped for good. 4009 (the session exited) keeps the tile
* with its "session ended" marker; 4003 (refused), 4004 (session gone) and
* 4010 (another socket took over) remove it.
* 4010 (another socket took over) remove it. Nobody here asked for that, so
* the neighbour takes focus without the keyboard (`focus: false`): what the
* user is typing never lands in another session's PTY.
*/
_onTileExit(sessionId, tile, code) {
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
@@ -2042,7 +2127,7 @@ Object.assign(CodemanApp.prototype, {
this._renderTileOverlay(sessionId);
return;
}
this.removeTile(sessionId);
this.removeTile(sessionId, { focus: false });
},
/**
@@ -2557,8 +2642,9 @@ Object.assign(CodemanApp.prototype, {
if (!grid.open) return false;
// Only a focus that is gone moves: re-selecting the same tile would hide an
// active web tab on every SSE blip (_selectTiledSession hides the web layer),
// which the single view's reconnect never does.
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true });
// which the single view's reconnect never does. The app's choice, so DOM
// focus stays put (an open modal or text field keeps the keyboard).
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false });
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
return true;
},
@@ -2566,7 +2652,9 @@ Object.assign(CodemanApp.prototype, {
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
// spent). Done BEFORE the original handler, so activeSessionId no longer names
// spent; `focus: false`: the keyboard stays put, so what the user was typing
// never goes on into the neighbour's PTY, as the single view sends it
// nowhere). Done BEFORE the original handler, so activeSessionId no longer names
// the deleted id and its welcome-screen handoff stays out of it. The last tile
// closes the grid without a reselect, and the original handler then shows the
// welcome screen as in the single view. A close started from this tab
@@ -2579,7 +2667,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
this.removeTile(data.id, { refocus: false });
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
this._selectTiledSession(neighbor, { auto: true });
this._selectTiledSession(neighbor, { auto: true, focus: false });
}
}
return _tileGridOriginalOnSessionDeleted.call(this, data);
-155
View File
@@ -1,155 +0,0 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Upload Screenshot - Codeman</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0d1117; color: #c9d1d9;
min-height: 100vh; min-height: 100dvh;
display: flex; align-items: center; justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
-webkit-text-size-adjust: 100%;
}
.container { max-width: 480px; width: 100%; padding: 24px; }
h1 { font-size: 1.4em; margin-bottom: 16px; color: #58a6ff; }
.drop-zone {
border: 2px dashed #30363d; border-radius: 12px;
padding: 48px 20px; text-align: center;
-webkit-tap-highlight-color: transparent;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone.active { border-color: #58a6ff; background: #161b22; }
.drop-zone p { margin-bottom: 12px; font-size: 1.1em; }
.drop-zone small { color: #8b949e; }
input[type=file] { display: none; }
.preview { margin-top: 16px; text-align: center; display: none; }
.preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #30363d; }
.preview .name { margin-top: 6px; font-size: 0.85em; color: #8b949e; word-break: break-all; }
button {
width: 100%; padding: 14px; margin-top: 16px;
background: #238636; color: #fff; border: none;
border-radius: 8px; font-size: 1.05em;
font-weight: 600; -webkit-appearance: none;
opacity: 0.4; pointer-events: none;
}
button.ready { opacity: 1; pointer-events: auto; }
button:active { background: #2ea043; }
.status { margin-top: 12px; padding: 10px; border-radius: 6px; text-align: center; display: none; }
.status.ok { display: block; background: #1a3a2a; color: #3fb950; }
.status.err { display: block; background: #3a1a1a; color: #f85149; }
.files { margin-top: 24px; }
.files h2 { font-size: 0.95em; color: #8b949e; margin-bottom: 8px; }
.files a { display: block; color: #58a6ff; text-decoration: none; padding: 6px 0; font-size: 0.9em; word-break: break-all; }
.files a:active { color: #79c0ff; }
.back { display: inline-block; margin-bottom: 12px; color: #8b949e; text-decoration: none; font-size: 0.9em; }
.back:active { color: #c9d1d9; }
</style>
</head><body>
<div class="container">
<a class="back" href="/">&larr; Back to Codeman</a>
<h1>Upload Screenshot</h1>
<div class="drop-zone" id="drop">
<p>Tap to select image</p>
<small>PNG, JPG, WebP &mdash; up to 10 MB</small>
</div>
<input type="file" id="file" accept="image/*">
<div class="preview" id="preview">
<img id="previewImg" alt="Preview">
<div class="name" id="previewName"></div>
</div>
<button id="btn">Upload</button>
<div class="status" id="status"></div>
<div class="files" id="files"></div>
</div>
<script>
(function() {
var drop = document.getElementById('drop');
var fileInput = document.getElementById('file');
var preview = document.getElementById('preview');
var previewImg = document.getElementById('previewImg');
var previewName = document.getElementById('previewName');
var btn = document.getElementById('btn');
var status = document.getElementById('status');
var filesDiv = document.getElementById('files');
var selectedFile = null;
drop.addEventListener('click', function() { fileInput.click(); });
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) pick(fileInput.files[0]);
});
// Drag-and-drop (desktop fallback)
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('active'); });
drop.addEventListener('dragleave', function() { drop.classList.remove('active'); });
drop.addEventListener('drop', function(e) {
e.preventDefault(); drop.classList.remove('active');
if (e.dataTransfer && e.dataTransfer.files[0]) pick(e.dataTransfer.files[0]);
});
function pick(f) {
selectedFile = f;
previewImg.src = URL.createObjectURL(f);
previewName.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
preview.style.display = 'block';
btn.classList.add('ready');
status.className = 'status';
status.style.display = 'none';
}
btn.addEventListener('click', function() {
if (!selectedFile) return;
btn.classList.remove('ready');
btn.textContent = 'Uploading\u2026';
var form = new FormData();
form.append('file', selectedFile);
fetch('/api/screenshots', { method: 'POST', body: form })
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.success) {
status.className = 'status ok';
status.textContent = 'Saved: ' + j.filename;
status.style.display = 'block';
selectedFile = null;
preview.style.display = 'none';
btn.textContent = 'Upload';
loadFiles();
} else {
status.className = 'status err';
status.textContent = j.error || 'Upload failed';
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
}
})
.catch(function(e) {
status.className = 'status err';
status.textContent = e.message;
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
});
});
function loadFiles() {
fetch('/api/screenshots')
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.files && j.files.length) {
var html = '<h2>Recent uploads</h2>';
j.files.forEach(function(f) {
html += '<a href="/api/screenshots/' + encodeURIComponent(f.name) + '" target="_blank">' + f.name + '</a>';
});
filesDiv.innerHTML = html;
}
})
.catch(function() {});
}
loadFiles();
})();
</script>
</body></html>
+22 -6
View File
@@ -1007,15 +1007,30 @@ const VoiceInput = {
} else {
// 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.
// for any other session must not be typed into it. It also belongs to the
// MAIN terminal, which the tile grid parks (display: none): with tiles
// open the text would sit in an invisible overlay the focused tile never
// sees, so it goes straight to the session instead.
const isActive = target === app.activeSessionId;
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
const tilesOpen = !!app._tilesOwnTerminal?.();
if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) {
app._localEchoOverlay.appendText(trimmed);
} else {
this._sendToTarget(target, trimmed).catch(() => {});
}
this._showVoiceSendBtn();
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
setTimeout(() => {
if (!isActive) return;
// With the grid open the keyboard belongs to the focused tile; the
// parked main terminal cannot take focus. Split view keeps the main
// terminal, even if Pane B took focus meanwhile (it is not the target).
if (app._tilesOwnTerminal?.()) {
const pane = app._focusedPane?.();
if (pane?.sessionId === target) pane.terminal?.focus();
} else if (app.terminal) {
app.terminal.focus();
}
}, 150);
}
},
@@ -1045,9 +1060,10 @@ const VoiceInput = {
if (!target) return;
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
// 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) {
// predictions belong to the ACTIVE session's MAIN terminal, so a
// dictation for another session, or for a tile while the grid has the
// main terminal parked, just sends its Enter there.
if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) {
this._sendToTarget(target, '\r').catch(() => {});
} else if (app._localEchoEnabled && app._localEchoOverlay) {
const text = app._localEchoOverlay.pendingText || '';
+9 -1
View File
@@ -514,7 +514,15 @@ Object.assign(CodemanApp.prototype, {
openWebviewExternal(id) {
const webview = this.webviews.get(id || this.activeWebviewId);
if (webview) window.open(webview.url, '_blank', 'noopener');
if (!webview) return;
// A host that refuses must not fall through to window.open, which in a
// WebView can replace the dashboard page.
const hosted = this.openInHostWindow?.(webview.url) ?? null;
if (hosted !== null) {
if (!hosted) this.showToast('Could not open a new window for this dashboard', 'error');
return;
}
window.open(webview.url, '_blank', 'noopener');
},
closeWebviewTab(id) {
+3 -1
View File
@@ -312,7 +312,9 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void {
// admin/settings surface; every SPAWN-time caller elsewhere uses
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
// overlays/discovery — the same rule every other catalogue-export surface in
// this codebase follows.
// this codebase follows, with one deliberate exception: the page catalog
// (`window.__codemanCliCatalog`, server.ts) carries `capabilities.external`,
// which Session Options reads to keep Claude's Respawn and Ralph tabs.
//
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
// the risky part. The Settings UI section simply never fetches this while
+44 -4
View File
@@ -73,6 +73,35 @@ import {
isEditableFileName,
} from '../../config/file-editing.js';
/**
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
* what a single preview can hand the worker; the renderer refuses the same size
* before fetching. An explicit download is unaffected (global download cap).
*/
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
/** Whether a raw request is an XLSX browser preview over the preview cap. */
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
return (
extension === 'xlsx' &&
query.preview === 'true' &&
query.download !== 'true' &&
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
);
}
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
)
);
}
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
// Return metadata for media/binary files (no text body). XLSX is still a
// binary here (no text body); `spreadsheet` tells the overlay it can parse
// it client-side from `url`. xls/ods stay plain binary (download only).
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: mediaType ?? 'binary',
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
extension: ext,
url: fileRawUrl,
},
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
if (!filePath) {
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -2217,7 +2253,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const { download, preview } = req.query as { download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
@@ -2237,6 +2273,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,
+34 -5
View File
@@ -20,6 +20,7 @@ import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
resolveOverviewLimits,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
@@ -33,6 +34,20 @@ import type { SessionPort } from '../ports/index.js';
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
* can never cost more than the module's own ceiling.
*/
function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
// A repeated key arrives as an array: it is not a number, so it means "default" like '' and absent.
const timeout = typeof query.timeout === 'string' && query.timeout.trim() ? query.timeout : undefined;
return resolveOverviewLimits({
maxRepos: query.maxRepos,
timeoutMs: timeout !== undefined ? Number(timeout) * 1000 : undefined,
});
}
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
@@ -41,7 +56,9 @@ export function registerGitStatusRoutes(
): 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 query = req.query as { fresh?: string; maxRepos?: unknown; timeout?: unknown };
const { fresh } = query;
const limits = limitsFrom(query);
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' }) };
@@ -50,6 +67,7 @@ export function registerGitStatusRoutes(
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
...limits,
dockerWorkspaces: await dockerWorkspaces(),
}),
};
@@ -62,16 +80,24 @@ export function registerGitStatusRoutes(
// 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 query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: unknown; timeout?: unknown };
const { repo, path, kind } = query;
const limits = limitsFrom(query);
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() })
? await findWorkspaceRepo(session.workingDir, repo, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
})
: null;
const status = repoRoot
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
: 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))
@@ -81,7 +107,10 @@ export function registerGitStatusRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
return {
success: true,
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
};
} catch (err) {
reply.code(500);
return createErrorResponse(
+80 -43
View File
@@ -36,6 +36,7 @@ import {
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
isMuxMouseStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
@@ -115,6 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { applyLaunchDefaults } from '../launch-defaults.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -224,6 +226,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
/**
* The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts)
* removes from the live stream, removed again from a stored buffer before it is replayed,
* because a buffer recorded before the live-side strip existed (or by an older server)
* still carries the sequences, and one replayed enable is enough to re-park xterm.
*
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
* saved lines, so history disappeared on tab switch.
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
* `3J` stays: a TUI is not a `clear` consumer.
* - every other mode, tmux-backed: tmux's own client smcup only (#205).
*
* Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts).
*/
export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string {
if (isAltScreenStripMode(mode)) {
return buffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
}
if (isMuxMouseStripMode(mode, usesMux)) {
return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, '');
}
if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
return buffer;
}
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -1117,6 +1150,10 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
// effort today) fill the CLI's own config object where the caller left it unset.
// Local launches only: a remote attach runs whatever the remote pane already runs.
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
// Where a model override comes from is a capability, and the three answers are
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
@@ -1131,9 +1168,9 @@ export function registerSessionRoutes(
const modelSource = getCli(mode)?.capabilities.model;
const model =
modelSource?.source === 'flag'
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
| string
| undefined)
? (legacyConfigForMode(mode, launchBody as unknown as Record<string, unknown>)?.[
modelSource.param ?? 'model'
] as string | undefined)
: modelSource?.source === 'claude-settings-file'
? body.model || modelConfig?.defaultModel || undefined
: undefined;
@@ -1150,12 +1187,12 @@ export function registerSessionRoutes(
deepSeekConfig: gatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
body.grokConfig,
body.deepSeekConfig
launchBody.codexConfig,
launchBody.geminiConfig,
launchBody.antigravityConfig,
launchBody.piConfig,
launchBody.grokConfig,
launchBody.deepSeekConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -1168,14 +1205,14 @@ export function registerSessionRoutes(
model,
claudeMode: effectiveClaudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
@@ -3107,21 +3144,9 @@ export function registerSessionRoutes(
? rawBuffer
: stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
// buffer and wiping saved lines, so conversation history disappears on tab
// switch. Same gate as the live-stream strip in session.ts.
if (isAltScreenStripMode(session.mode)) {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
} else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) {
// tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only.
// A byte buffer recorded before the live-side strip existed can still carry
// it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205).
strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
}
// Same strip as the live stream (session.ts), so a buffer recorded before the
// live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer.
strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux);
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
@@ -3851,19 +3876,31 @@ export function registerSessionRoutes(
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
// Synced App Settings launch defaults, as on the create path: local launches only, so
// never a remote or Docker case, and never a custom model endpoint launch.
const qsRequestConfigs = {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
};
const qsLaunchConfigs =
remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel);
// ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start
// has never taken omp's session model from ompConfig (the create path does). Kept as
// found rather than changed in passing.
const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs;
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record<string, unknown>)?.[
qsModelSource.param ?? 'model'
] as string | undefined)
: qsModelSource?.source === 'claude-settings-file'
? qsModelConfig?.defaultModel || undefined
: undefined;
@@ -3879,16 +3916,16 @@ export function registerSessionRoutes(
deepSeekConfig: qsGatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig
qsLaunchConfigs.codexConfig,
qsLaunchConfigs.geminiConfig,
qsLaunchConfigs.antigravityConfig,
qsLaunchConfigs.piConfig,
qsLaunchConfigs.grokConfig,
qsLaunchConfigs.deepSeekConfig
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4045,7 +4082,7 @@ export function registerSessionRoutes(
claudeMode: qsEffectiveClaudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
owner,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
+14
View File
@@ -1295,8 +1295,20 @@ export function registerSystemRoutes(
// ═══════════════════════════════════════════════════════════════
// ========== Screenshots ==========
// Deprecated (the upload page is gone): removed in a later MAJOR per
// docs/versioning-policy.md. Warns once per process on first use.
let screenshotsDeprecationWarned = false;
const warnScreenshotsDeprecated = (): void => {
if (screenshotsDeprecationWarned) return;
screenshotsDeprecationWarned = true;
console.warn(
'[deprecated] /api/screenshots is deprecated and will be removed in a future major release. ' +
'Use POST /api/sessions/:id/paste-image to hand a file to a session.'
);
};
app.post('/api/screenshots', async (req, reply) => {
warnScreenshotsDeprecated();
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -1383,6 +1395,7 @@ export function registerSystemRoutes(
});
app.get('/api/screenshots', async () => {
warnScreenshotsDeprecated();
if (!existsSync(SCREENSHOTS_DIR)) {
return { files: [] };
}
@@ -1396,6 +1409,7 @@ export function registerSystemRoutes(
});
app.get('/api/screenshots/:name', async (req, reply) => {
warnScreenshotsDeprecated();
const { name } = req.params as { name: string };
// Prevent path traversal
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
+34
View File
@@ -1403,6 +1403,25 @@ export const SettingsUpdateSchema = z
* rail drag-reorderable.
*/
tabRailSort: z.enum(['activity', 'manual']).optional(),
/**
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
* (per-device).
* 'classic' = one flat list in tab order, as before. The default.
* 'state' = a row per state in the header strip (needs you, waiting,
* working, idle; option C), sections in the flat side rail and
* the sidebar. Opt-in.
* 'case' = one cluster per case (option A): a labelled box in the strip,
* a section in the side rail and the sidebar. Opt-in.
* 'ledger' = the flat list on an aligned column grid with a status bar
* per cell (option B). Header strip on desktop only. Opt-in.
*/
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
/**
* Which end the state groups start from when `tabArrangement` is 'state'.
* Display key (per-device). 'urgent-first' = needs you on top (the
* default); 'urgent-last' = the other way up, needs you in the bottom row.
*/
tabStateOrder: z.enum(['urgent-first', 'urgent-last']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
@@ -1434,6 +1453,15 @@ export const SettingsUpdateSchema = z
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
/**
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
* (per-device), desktop only (the cluster is hidden below 768px).
* 'classic' = the bars and the 5H · 7D chip, as before
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
* The default.
* 'tiles' = label over value with a bar underneath, no icons
*/
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
@@ -1457,6 +1485,12 @@ export const SettingsUpdateSchema = z
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// Codex CLI settings
codexModel: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]*$/)
.optional(),
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
codexDangerouslyBypassApprovals: z.boolean().optional(),
codexAnimationsEnabled: z.boolean().optional(),
// Terminal history and retention
+28 -10
View File
@@ -323,6 +323,14 @@ export class WebServer extends EventEmitter {
private store = getStore();
private tabLayouts!: TabLayoutService;
private port: number;
/**
* The port the server is actually listening on once `start()` has resolved — the
* OS-assigned one for `new WebServer(0, …)` — and the constructor's port before that.
*/
get boundPort(): number {
return this.port;
}
private host: string;
private https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
@@ -746,7 +754,11 @@ export class WebServer extends EventEmitter {
saveRespawnConfig: this.saveRespawnConfig.bind(this),
// ConfigPort
store: this.store,
port: this.port,
// A getter, not a snapshot: this context is built in setupRoutes(), BEFORE
// listen() resolves an ephemeral `port: 0`, and the tunnel start reads it later.
get port() {
return self.port;
},
https: this.https,
testMode: this.testMode,
serverStartTime: this.serverStartTime,
@@ -920,7 +932,9 @@ export class WebServer extends EventEmitter {
});
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys, and every
// HTML page has its own route that says so (/, /index.html, /session/:id). ⚠️ A new static .html
// needs such a route too: this plugin would hand it a year of `immutable`.
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
await this.app.register(fastifyStatic, {
@@ -932,7 +946,7 @@ export class WebServer extends EventEmitter {
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
// request, which is every page load. See the v10.0.0 release notes.
setHeaders: (reply, path) => {
setHeaders: (reply) => {
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
// callback wrote to the raw response and Fastify's staged reply headers then
// overwrote it, so a route that set its own Cache-Control before .sendFile()
@@ -941,12 +955,7 @@ export class WebServer extends EventEmitter {
// no-store` its route asks for — a service worker that can never update.
// So: a route that already decided keeps its answer.
if (reply.getHeader('Cache-Control') !== undefined) return;
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
if (path.includes('.html')) {
reply.header('Cache-Control', 'no-cache');
} else {
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
},
});
@@ -1154,7 +1163,9 @@ export class WebServer extends EventEmitter {
// due times for any persisted jobs, then expose it to its routes.
this.cronService = new CronService(ctx);
this.cronService.init();
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
// Only what CronPort declares: a spread of ctx would copy `port` by value (the
// pre-listen 0 of an ephemeral bind) into an object nothing keeps in sync.
registerCronRoutes(this.app, { cron: this.cronService });
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy());
@@ -1713,6 +1724,7 @@ export class WebServer extends EventEmitter {
shortBadge: entry.shortBadge,
order: entry.order,
kind: entry.kind,
external: entry.capabilities.external,
enabled,
available: enabled && installed,
};
@@ -2909,6 +2921,12 @@ export class WebServer extends EventEmitter {
}
await this.app.listen({ port: this.port, host: this.host });
// A `port: 0` bind gets its number from the OS. Everything below and every later
// reader (the banner, CODEMAN_API_URL, the docker bridge listener, the
// unauthenticated-bind warnings, the route context's getter) must see that number,
// not the 0 that was asked for.
const address = this.app.server.address();
if (address !== null && typeof address === 'object') this.port = address.port;
const protocol = this.https ? 'https' : 'http';
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
// The only startup banner: `codeman web` used to print its own copy of this
+13 -18
View File
@@ -8,7 +8,7 @@
* 6. Logout endpoint invalidates session
* 7. Settings schema rejects unknown fields
*
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
@@ -16,11 +16,6 @@ import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
const NETWORK_OVERRIDE_PORT = 3162;
const AUTH_RATE_LIMIT_PORT = 3220;
const NOAUTH_NETWORK_PORT = 3221;
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -30,12 +25,12 @@ function basicAuthHeader(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
const server = new WebServer(port, false, true);
const server = new WebServer(0, false, true);
await server.start();
return { server, baseUrl: `http://localhost:${port}` };
return { server, baseUrl: `http://localhost:${server.boundPort}` };
}
async function getSessionCookie(baseUrl: string): Promise<string> {
@@ -66,9 +61,9 @@ describe('Auth Security', () => {
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
server = new WebServer(AUTH_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${AUTH_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -194,7 +189,7 @@ describe('Auth Security', () => {
let rateBaseUrl: string;
beforeEach(async () => {
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer());
});
afterEach(async () => {
@@ -347,7 +342,7 @@ describe('No-Auth Server Startup Policy', () => {
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
server = new WebServer(0, false, true, '127.0.0.1');
await server.start();
});
@@ -359,7 +354,7 @@ describe('No-Auth Server Startup Policy', () => {
});
it('allows loopback requests without auth when no password is configured', async () => {
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
const res = await fetch(`http://localhost:${server.boundPort}/api/status`);
expect(res.status).toBe(200);
});
@@ -368,10 +363,10 @@ describe('No-Auth Server Startup Policy', () => {
// bind without a password no longer refuses to start — it starts and warns,
// pointing at how to secure it. See docs/security-architecture.md.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await expect(networkServer.start()).resolves.toBeUndefined();
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);
const warned = warnSpy.mock.calls.flat().join('\n');
@@ -393,10 +388,10 @@ describe('No-Auth Server Startup Policy', () => {
});
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true);
await networkServer.start();
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);
await networkServer.stop();
});
+138 -10
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { stripReplayBuffer } from '../src/web/routes/session-routes.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
@@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n
describe('isMuxAltScreenOnlyStripMode', () => {
it('covers exactly the modes the full strip does not, and only under tmux', () => {
for (const mode of ['shell', 'opencode', 'antigravity'] as const) {
for (const mode of ['shell', 'antigravity'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip already owns these; never double-gate them here.
for (const mode of ['claude', 'codex', 'gemini'] as const) {
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
});
/**
* opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs,
* tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then
* reports DRAGS to the TUI instead of selecting locally. That killed "mark text,
* copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's
* `app_exit`, so the failure also ended sessions.
*
* It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a
* TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground
* between the full strip and the narrow one.
*/
describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('opencode', true)).toBe(true);
// Direct-PTY fallback: the pane's own alt screen really does reach xterm.
expect(isMuxMouseStripMode('opencode', false)).toBe(false);
for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) {
expect(isMuxMouseStripMode(mode, true)).toBe(false);
}
});
it('drops mouse tracking so xterm keeps local text selection', () => {
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l');
expect(emitted[0]).toBe('TUI');
expect(session.terminalBuffer).toBe('TUI');
});
it('still drops tmux’s attach-time smcup', () => {
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
});
it('KEEPS 3J, unlike the full strip', () => {
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
handleOutput(session, '\x1b[3Jtext');
expect(session.terminalBuffer).toBe('\x1b[3Jtext');
});
it('publishes cliMouseTracking so the browser can hand-encode clicks', () => {
// xterm can never see the DECSETs once they are stripped, so the click path
// (_sendSyntheticSgrTap) is the only way a click still reaches opencode.
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h');
expect(session.toState().cliMouseTracking).toBe(true);
});
it('reassembles a mouse DECSET split across PTY chunks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
handleOutput(session, 'before\x1b[?100');
handleOutput(session, '3h after');
expect(session.terminalBuffer).toBe('before after');
expect(session.toState().cliMouseTracking).toBe(true);
});
it('leaves a direct-PTY opencode pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false });
const out = '\x1b[?1049h\x1b[?1003h';
handleOutput(session, out);
expect(session.terminalBuffer).toBe(out);
});
});
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
@@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
});
it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => {
// The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode
// list: a flag set for a non-stripping mode would make it hand-encode a second
// report on top of xterm's own. Only the mouse-strip branch may set it.
for (const mode of ['shell', 'antigravity'] as const) {
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app');
expect(session.toState().cliMouseTracking, mode).toBeFalsy();
}
});
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
const emitted: string[] = [];
@@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
expect(emitted).toEqual(['before', ' after']);
});
it('applies to opencode and antigravity too', () => {
for (const mode of ['opencode', 'antigravity'] as const) {
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
}
it('applies to antigravity too (opencode has its own strip — see below)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true });
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
});
});
@@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => {
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
});
});
/**
* The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI
* with and without tmux, on both halves of the parity triangle that can drift apart: the
* live stream (`_handleTerminalOutput`) and the replay of a stored buffer
* (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published
* `cliMouseTracking`, so the last column is what keeps it right.
*/
describe('strip decision table: live stream = replay, for every stock CLI', () => {
const ALT = '\x1b[?1049h';
const ERASE = '\x1b[3J';
const MOUSE = '\x1b[?1002h\x1b[?1006h';
const input = `A${ALT}B${ERASE}C${MOUSE}D`;
/** Read straight off the table, not off the predicates under test. */
function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } {
const strip = { alt: false, erase: false, mouse: false };
if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true });
else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true });
else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row
return {
out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`,
tracking: strip.mouse,
};
}
for (const entry of STOCK_CLIS) {
for (const useMux of [true, false]) {
it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => {
const want = expected(entry.capabilities.altScreen, useMux);
const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux });
handleOutput(session, input);
expect(session.terminalBuffer).toBe(want.out);
expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out);
expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking);
});
}
}
});
+174 -51
View File
@@ -33,6 +33,13 @@
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile.
*
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
* approved every later copy of the same text in the same file: the codex launch defaults
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
* because the legacy-plumbing entry already covered that string. A new copy now fails as
* an unapproved branch would, and a removed one fails as stale until the count drops.
*
* Port: none (pure static analysis).
*/
@@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set(
].map((p) => p.split('/').join(sep))
);
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
interface Allowance {
count: number;
reason: string;
}
const allow = (count: number, reason: string): Allowance => ({ count, reason });
/**
* Specific surviving branches, each with the reason it is not a capability.
* Specific surviving branches, each with the reason it is not a capability and the exact
* number of times the expression occurs in that file (see the header).
* Keyed `<relative path>::<the matched expression>`.
*/
const ALLOWED_BRANCHES: Record<string, string> = {
const ALLOWED_BRANCHES: Record<string, Allowance> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
"web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'":
"tmux-manager.ts::mode === 'claude'": allow(
2,
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
"tmux-manager.ts::mode === 'omp'":
'`--session-id … || resume`; neither fits a static overlays.command string'
),
"tmux-manager.ts::mode === 'omp'": allow(
1,
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
),
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
'an error message explaining why THIS mode in particular will never deliver a stop signal',
"web/routes/approval-routes.ts::mode === 'deepseek'":
'the DeepSeek status bridge is the only non-claude source of approval items',
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
"web/session-wait-registry.ts::mode === 'deepseek'": allow(
1,
'an error message explaining why THIS mode in particular will never deliver a stop signal'
),
"web/routes/approval-routes.ts::mode === 'deepseek'": allow(
1,
'the DeepSeek status bridge is the only non-claude source of approval items'
),
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
"web/routes/session-routes.ts::mode === 'claude'": allow(
2,
'docker case bookkeeping keyed on the claude conversation id'
),
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
//
@@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record<string, string> = {
// there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'":
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
"web/server.ts::mode !== 'claude'":
"web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
1,
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
),
"web/server.ts::mode !== 'claude'": allow(
2,
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
),
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
"tui/tui-app.ts::mode !== 'claude'": allow(
1,
'TUI resume builds a claude --resume; claude-transcript-only by construction'
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
'push buttons withheld where the answer route refuses keystrokes',
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
1,
'push buttons withheld where the answer route refuses keystrokes'
),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
"web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'":
"web/routes/session-routes.ts::mode !== 'opencode'": allow(
2,
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion',
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
'so any capability form would change behaviour — see PR discussion'
),
"web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
@@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
const { findings, filesScanned } = scan();
interface CountMismatch {
key: string;
allowed: number;
found: number;
lines: string[];
}
/**
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
* ones with fewer (a stale approval that would let the next copy back in unseen).
* Unallowlisted keys are not this function's business; the offenders check covers them.
*/
function countMismatches(
found: Finding[],
allowed: Record<string, Allowance>
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
const byKey = new Map<string, Finding[]>();
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
const grown: CountMismatch[] = [];
const shrunk: CountMismatch[] = [];
for (const [key, allowance] of Object.entries(allowed)) {
const hits = byKey.get(key) ?? [];
const mismatch = {
key,
allowed: allowance.count,
found: hits.length,
lines: hits.map((f) => `${f.file}:${f.line}`),
};
if (hits.length > allowance.count) grown.push(mismatch);
else if (hits.length < allowance.count) shrunk.push(mismatch);
}
return { grown, shrunk };
}
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every
@@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
).toEqual([]);
});
it('has no new copy of an allowlisted branch', () => {
// An approval covers the copies that were reviewed, not every later line that happens
// to spell the same expression in the same file.
const detail = grown
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
.join('\n');
expect(
grown,
grown.length === 0
? ''
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
).toEqual([]);
});
it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through.
const present = new Set(findings.map((f) => f.key));
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
// person to reintroduce that exact branch would sail straight through. The same holds
// for an approved count above what the file still has.
const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
expect(
stale,
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
).toEqual([]);
});
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
const at = (line: number): Finding => ({
file: 'web/example.ts',
expression: "mode === 'codex'",
line,
key: "web/example.ts::mode === 'codex'",
});
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
const extra = countMismatches([at(10), at(42)], approved);
expect(extra.grown).toEqual([
{
key: "web/example.ts::mode === 'codex'",
allowed: 1,
found: 2,
lines: ['web/example.ts:10', 'web/example.ts:42'],
},
]);
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
// Every live entry carries a positive whole count, or the comparison means nothing.
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
expect(Number.isInteger(count) && count > 0, key).toBe(true);
}
});
});
+51
View File
@@ -291,6 +291,57 @@ describe('cross-field integrity', () => {
});
});
describe('capabilities.launchDefaults', () => {
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
const entry = baseEntry('codex');
mutate(entry);
return CliEntrySchema.safeParse(entry).success;
}
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
(e.capabilities as Record<string, unknown>).launchDefaults = value;
};
it('ships on codex alone, keyed by launch param', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
expect(declaring).toEqual(['codex']);
expect(codexWith(() => {})).toBe(true);
});
it('rejects a param the entry never declared', () => {
// Filled into the config object and then read by nothing: a silent no-op, like a
// privilegedParams clamp naming the wrong param.
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
});
it('rejects a settings key outside the closed list', () => {
// An override must not be able to pour an arbitrary setting onto a command line.
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
});
it('rejects an empty map', () => {
expect(codexWith(setDefaults({}))).toBe(false);
});
it('refuses an entry with no legacyConfigField to fill', () => {
// Without one the params are read off the request body itself, where `model` is
// claude's per-session field, not this CLI's.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
})
).toBe(false);
// The same entry without launch defaults is fine: the refusal is about the pair.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
delete (e.capabilities as Record<string, unknown>).launchDefaults;
})
).toBe(true);
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
+7 -10
View File
@@ -19,7 +19,7 @@
* - tunnel NOT running + good secret → not 401 (allowed)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
@@ -28,9 +28,6 @@ import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';
@@ -58,9 +55,9 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -97,9 +94,9 @@ describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () =
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -130,9 +127,9 @@ describe('COD-54 hook-event auth — rate limiting', () => {
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
+17 -2
View File
@@ -46,10 +46,17 @@ function lockedVersions(lock: PackageLock, packageName: string): string[] {
return [...versions].sort();
}
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void {
function expectEveryLockedVersionAtLeast(
lock: PackageLock,
packageName: string,
minimum: string,
/** Exact versions deliberately outside this policy; each call site says why. */
exempt: string[] = []
): void {
const versions = lockedVersions(lock, packageName);
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
for (const version of versions) {
if (exempt.includes(version)) continue;
expect(
compareVersions(version, minimum),
`${packageName}@${version} should be >= ${minimum}`
@@ -136,7 +143,11 @@ describe('dependency security policy', () => {
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
// Our own uuid stays >= 14. exceljs@4.4.0 (devDependency, vendored into the
// XLSX preview worker at build time) pins uuid@8.3.2 and only calls v4();
// GHSA-w5hq-g745-h8pq is MODERATE and covers v3/v5/v6 with a caller buffer,
// so it is outside this CRITICAL/HIGH policy and unreachable from exceljs.
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0', ['8.3.2']);
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
@@ -149,6 +160,10 @@ describe('dependency security policy', () => {
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
// GHSA-px8p-9vwx-vf98 (unbounded loop on a ZIP64 marker in a local header)
// covers <=0.8.2. The XLSX preview worker streams untrusted files through
// fflate's Unzip before any admission callback runs.
expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3');
expectNoVulnerableBraceExpansion(lock);
expectNoVulnerableVite(lock);
expectNoVulnerablePicomatch(lock);
+3 -2
View File
@@ -166,8 +166,9 @@ describe('entrance animation styles', () => {
expect(blur).not.toBeNull();
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
// The 100% frame deliberately omits opacity so the endpoint comes from the
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
// line, 0.95 on a working one. Pinning a number here snaps three of them.
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
// number here snaps them.
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
+24
View File
@@ -128,6 +128,30 @@ describe('file viewer detach button', () => {
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error');
});
it('hands the URL to a host window opener instead of window.open', () => {
const { app, overlay, windowStub } = loadApp();
app.openInHostWindow = vi.fn().mockReturnValue(true);
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
app.detachFilePreview();
expect(app.openInHostWindow).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf');
expect(windowStub.open).not.toHaveBeenCalled();
expect(overlay.classList.contains('visible')).toBe(false);
});
it('keeps the overlay and toasts when the host could not open a window', () => {
const { app, overlay, windowStub } = loadApp();
app.openInHostWindow = vi.fn().mockReturnValue(false);
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
app.detachFilePreview();
expect(windowStub.open).not.toHaveBeenCalled();
expect(overlay.classList.contains('visible')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
});
it('does nothing when no preview is armed', () => {
const { app, windowStub } = loadApp();
app.filePreviewDetachUrl = '';
+30
View File
@@ -127,6 +127,36 @@ describe('terminal shortcuts follow the focused pane', () => {
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
});
// TerminalTile.fit() returns whether its resize went out. When it did not,
// Redraw used to report a size that was never sent.
it.each([
[
'popped out to its own window',
{ detached: true, wsReady: true },
'This session is sized by its own window',
'warning',
],
[
'whose socket is down',
{ detached: false, wsReady: false },
'Terminal not connected: its size is sent when it reconnects',
'warning',
],
['that could not measure itself', { detached: false, wsReady: true }, 'Could not determine terminal size', 'error'],
])('Ctrl+Shift+R on a second pane %s reports no success', async (_label, state, message, level) => {
const app = loadApp();
app.detachedSessions = new Set(state.detached ? ['session-b'] : []);
const tile = paneB({ fit: vi.fn(() => false), _wsReady: state.wsReady });
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(tile.fit).toHaveBeenCalledWith({ force: true });
expect(app.showToast).toHaveBeenCalledTimes(1);
expect(app.showToast).toHaveBeenCalledWith(message, level);
expect(app.sendResize).not.toHaveBeenCalled();
});
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
const app = loadApp();
+26 -3
View File
@@ -3,7 +3,9 @@
* (`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
* and `terminal-tile.js`, whose wheel paging and click reports reach the
* primary pane's CLI rules through terminal-ui.js and must not grow a copy of
* them, 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/`.
@@ -24,7 +26,14 @@ 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', 'constants.js', 'terminal-split.js', 'tile-grid.js'];
const SCANNED_FILES = [
'session-ui.js',
'mobile-overview.js',
'constants.js',
'terminal-split.js',
'tile-grid.js',
'terminal-tile.js',
];
/**
* Every currently-surviving branch, each with the COUNT of physical call
@@ -100,6 +109,20 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
reason: 'attach route: a shell session attaches through /shell, an agent through /interactive',
},
// terminal-tile.js: the pane's two shell-only mechanisms, both mirrors of the
// primary pane's own shell checks (terminal-ui.js / app.js). Its wheel
// paging and click reports name no CLI: they ask terminal-ui.js's gates.
"terminal-tile.js::mode !== 'shell'": {
count: 2,
reason:
'Ctrl+Z reaches the PTY only in a shell (job control), and the scroll-to-top history pull is shell-only, ' +
'both as in the primary pane',
},
"terminal-tile.js::mode === 'shell'": {
count: 1,
reason: 'the load query: a shell loads the bounded tail= window instead of a full capture, as in the primary pane',
},
// 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'": {
@@ -188,7 +211,7 @@ function actualCounts(): Map<string, number> {
}
describe('no NEW CLI-id branching in the scanned frontend files', () => {
it('scans both files (sanity)', () => {
it('scans every listed file (sanity)', () => {
// If this drops to zero the scanner or the file list drifted and every
// assertion below would pass vacuously.
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
+117
View File
@@ -398,6 +398,123 @@ describe('Git status indicator in a real browser', () => {
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('sends the max-repositories and git-timeout settings, and lists an unreadable repository with its reason', async () => {
const setLimits = async (maxRepos: string, timeout: string) => {
await page.evaluate(() => (window as any).app.openAppSettings());
await page.fill('#appSettingsGitStatusMaxRepos', maxRepos);
await page.fill('#appSettingsGitStatusTimeout', timeout);
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
await setLimits('7', '45');
// Per-device keys must never reach the strict PUT /api/settings.
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 7, timeout: 45 });
await refresh();
expect(gitStatusRequests.at(-1)).toMatch(/maxRepos=7/);
expect(gitStatusRequests.at(-1)).toMatch(/timeout=45/);
// Out-of-range values are clamped when saved, not sent as typed.
await setLimits('9999', '1');
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 50, timeout: 5 });
// A folder with more repositories than the limit, one of which git could not read.
const emptyCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
const status = (over: Record<string, unknown>) => ({
state: 'ok',
branch: 'main',
detached: false,
upstream: 'origin/main',
upstreamGone: false,
ahead: 0,
behind: 0,
hasRemote: true,
counts: emptyCounts,
files: [],
filesTruncated: false,
unpushedCount: 0,
unpushed: [],
checkedAt: Date.now(),
...over,
});
await page.route('**/api/sessions/*/git-status*', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
state: 'ok',
reposTruncated: true,
repoLimit: 2,
checkedAt: Date.now(),
repos: [
{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) },
{ name: 'slow', path: 'slow', status: status({ state: 'error', error: 'git timed out' }) },
],
},
}),
})
);
await refresh();
await page.waitForFunction(() =>
/could not read/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
);
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('slow');
expect(body).toContain('could not read: git timed out');
expect(body).toContain('Showing the first 2 of more than 2 repositories');
expect(body).toContain('Raise “Git status: max repositories”');
// The unreadable repository must keep the indicator from claiming everything is fine.
expect(await label()).toContain('? 1');
expect(await label()).not.toContain('✓');
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/);
const mockOverview = async (data: Record<string, unknown>) => {
await page.unroute('**/api/sessions/*/git-status*');
await page.route('**/api/sessions/*/git-status*', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ success: true, data: { state: 'ok', checkedAt: Date.now(), ...data } }),
})
);
await refresh();
};
// The ONLY repository git could not read: still the error row, never a clean, empty repository.
await mockOverview({
reposTruncated: false,
repoLimit: 12,
repos: [
{
name: 'slow',
path: 'slow',
status: status({ state: 'error', error: 'git timed out', branch: null, hasRemote: false, upstream: null }),
},
],
});
await page.waitForFunction(() => document.getElementById('gitStatusBranch')?.textContent === '1 repository');
const lone = (await page.textContent('#gitStatusBody')) ?? '';
expect(lone).toContain('could not read: git timed out');
expect(lone).not.toContain('Nothing uncommitted');
expect(await label()).toContain('? 1');
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(slow\)/);
// A limit of 1 in a folder of several: the one row shown keeps the "Showing the first" notice.
await mockOverview({
reposTruncated: true,
repoLimit: 1,
repos: [{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) }],
});
await page.waitForFunction(() =>
/Showing the first 1 /.test(document.getElementById('gitStatusBody')?.textContent ?? '')
);
expect(await page.textContent('#gitStatusBranch')).toBe('1 repository');
await page.unroute('**/api/sessions/*/git-status*');
await setLimits('12', '30');
await refresh();
}, 40000);
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);
+96
View File
@@ -896,3 +896,99 @@ describe('Docker case workspaces are never inspected', () => {
expect(o.state).toBe('ok');
});
});
import {
clampInt,
DEFAULT_GIT_TIMEOUT_MS,
MAX_GIT_TIMEOUT_MS,
MAX_REPOS_LIMIT,
MIN_GIT_TIMEOUT_MS,
resolveOverviewLimits,
} from '../src/git-workspace-status.js';
describe('configurable repository limit and git timeout', () => {
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;
};
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-limits-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('clampInt keeps untrusted values inside the range, and falls back for anything that is not a number', () => {
expect(clampInt('7', 1, 50, 12)).toBe(7);
expect(clampInt(7.9, 1, 50, 12)).toBe(7);
expect(clampInt(0, 1, 50, 12)).toBe(1);
expect(clampInt(9999, 1, 50, 12)).toBe(50);
for (const bad of [undefined, null, '', 'abc', NaN, Infinity, {}]) expect(clampInt(bad, 1, 50, 12)).toBe(12);
});
it('resolveOverviewLimits defaults to 12 repositories and a 30 s timeout, and clamps both ends', () => {
expect(resolveOverviewLimits({})).toEqual({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
expect(DEFAULT_GIT_TIMEOUT_MS).toBe(30_000);
expect(resolveOverviewLimits({ maxRepos: 500, timeoutMs: 10 ** 9 })).toEqual({
maxRepos: MAX_REPOS_LIMIT,
timeoutMs: MAX_GIT_TIMEOUT_MS,
});
expect(resolveOverviewLimits({ maxRepos: -3, timeoutMs: 1 })).toEqual({
maxRepos: 1,
timeoutMs: MIN_GIT_TIMEOUT_MS,
});
});
it('lists up to maxRepos, says what the limit was, and a different limit is not answered from the old cache', async () => {
const ws = join(home, 'case');
for (const n of ['a', 'b', 'c', 'd', 'e']) repoAt(join(ws, n));
const three = await getGitWorkspaceOverview(ws, { home, maxRepos: 3 });
expect(three.repos.map((r) => r.name)).toEqual(['a', 'b', 'c']);
expect(three).toMatchObject({ reposTruncated: true, repoLimit: 3 });
// No `fresh`: the 30 s discovery cache must be keyed by the limit.
const ten = await getGitWorkspaceOverview(ws, { home, maxRepos: 10 });
expect(ten.repos).toHaveLength(5);
expect(ten).toMatchObject({ reposTruncated: false, repoLimit: 10 });
});
it('keeps a repository git could not read in the list, with the reason, instead of dropping it', async () => {
const ws = join(home, 'case');
for (const n of ['fast', 'slow']) repoAt(join(ws, n));
const flaky: GitRunner = (cwd, args, opts) => {
if (cwd.endsWith('slow')) return Promise.reject(Object.assign(new Error('timed out'), { killed: true }));
return runGit(cwd, args, opts);
};
const o = await getGitWorkspaceOverview(ws, { home, git: flaky });
expect(o.repos.map((r) => [r.name, r.status.state])).toEqual([
['fast', 'ok'],
['slow', 'error'],
]);
expect(o.repos[1].status.error).toBe('git timed out');
});
it('passes the timeout to every git command, clamped', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
const seen: Array<number | undefined> = [];
const spy: GitRunner = (cwd, args, opts) => {
seen.push(opts?.timeoutMs);
return runGit(cwd, args, opts);
};
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 45_000, fresh: true });
expect(seen.length).toBeGreaterThan(0);
expect(new Set(seen)).toEqual(new Set([45_000]));
clearGitStatusCache();
seen.length = 0;
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 10 ** 9, fresh: true });
expect(new Set(seen)).toEqual(new Set([MAX_GIT_TIMEOUT_MS]));
});
});
+51
View File
@@ -14,6 +14,12 @@
* 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.
* 4. That holds for EVERY rule that reaches a header glyph, not only the ones
* naming `.btn-icon-header`: the header stats styles (#538) restyle the
* buttons through `.header-right > .btn-settings > svg`, and a rotate or a
* transition there out-specified the guarded rules (a reduced-motion user
* still saw the gear turn, a tap left it turned, and #561's spring was
* replaced by a plain ease).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
@@ -49,6 +55,15 @@ function rules(): FoundRule[] {
const all = rules();
const iconRules = all.filter((r) => r.selector.includes('btn-icon-header'));
/**
* Every rule that reaches a header button's glyph, whatever names the button:
* the class list, a per-button class, or the header's right side.
*/
const glyphRules = all.filter(
(r) =>
/btn-icon-header|\.header-right|btn-settings|btn-tile-grid|btn-file-viewer/.test(r.selector) &&
/\bsvg\b/.test(r.selector)
);
function buttonTag(cls: string): string {
const i = html.indexOf(`class="btn-icon-header ${cls}`);
@@ -123,3 +138,39 @@ describe('header icon hover', () => {
}
});
});
describe('header glyph motion, whatever selector reaches it (header stats styles included)', () => {
it('finds the header stats styles glyph rules (the scan is not vacuous)', () => {
// Their 18px / 15px glyph sizes are reached through .header-right.
const sized = glyphRules.filter((r) => r.selector.startsWith('html[data-header-stats=') && r.decls.width);
expect(sized.map((r) => r.decls.width).sort()).toEqual(['15px', '18px']);
});
it('turns a glyph on hover only inside the pointer guard', () => {
const offenders = glyphRules.filter(
(r) =>
r.selector.includes(':hover') &&
r.decls.transform &&
r.decls.transform !== 'none' &&
!r.media.includes('(hover: hover)')
);
expect(offenders.map((r) => `${r.selector} { transform: ${r.decls.transform} }`)).toEqual([]);
const rotations = glyphRules.filter((r) => /rotate\(/.test(r.decls.transform || ''));
expect(rotations.map((r) => r.selector)).toEqual(['.btn-icon-header.btn-settings:hover svg']);
});
it('animates the glyphs with the one spring transition, which reduced motion turns off', () => {
// A second transition on the same glyphs (a header-wide restyle, say)
// would out-specify the spring and the reduced-motion `none` alike.
const animated = glyphRules.filter((r) => 'transition' in r.decls || 'animation' in r.decls);
const glyphs = [
'.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(animated.map((r) => `${r.media.join(' ')} ${r.selector}`.trim()).sort()).toEqual(
[...glyphs, ...glyphs.map((g) => `(prefers-reduced-motion: reduce) ${g}`)].sort()
);
});
});

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