Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2453fe97b | ||
|
|
89177651a6 | ||
|
|
9644892a5a | ||
|
|
ed6f5f6856 | ||
|
|
586aafa8de | ||
|
|
b3d3c647cf | ||
|
|
1b3f40bba7 | ||
|
|
fc7ffe1ad8 | ||
|
|
b59145effd | ||
|
|
3a0cee6b90 | ||
|
|
5e2e9bb833 | ||
|
|
28708cfa14 | ||
|
|
c638c88739 | ||
|
|
a4511a0e48 | ||
|
|
be3436f5b3 | ||
|
|
330203c08b | ||
|
|
9d38cbf51a | ||
|
|
ecd577157b | ||
|
|
c614241c48 | ||
|
|
1def7de146 | ||
|
|
82c87f56d1 | ||
|
|
cdb3c34ae0 | ||
|
|
eb5d982c38 | ||
|
|
e39a750749 | ||
|
|
daf7330d6e | ||
|
|
41643f2b38 | ||
|
|
37ddcbe2f0 | ||
|
|
18c8b5c280 | ||
|
|
7409ad2655 | ||
|
|
a96a94fb7e | ||
|
|
24a73ecd81 | ||
|
|
312a8faa06 | ||
|
|
4fe843a94e | ||
|
|
155372f7a8 | ||
|
|
f855b5d274 | ||
|
|
ccd52583e2 | ||
|
|
2c267276c3 | ||
|
|
efb3fa112d | ||
|
|
fe1acd625e | ||
|
|
432bd5fc0a | ||
|
|
f79f530f93 | ||
|
|
48f54ec090 | ||
|
|
34f211538a | ||
|
|
0868286661 | ||
|
|
d7140c32b4 | ||
|
|
af4e3e6ed7 | ||
|
|
e86c3d1ed3 | ||
|
|
1ee566e7a1 | ||
|
|
1105d646f3 | ||
|
|
ff2f81541a | ||
|
|
02c65e988a | ||
|
|
4502bfe8a9 | ||
|
|
ce6e4cc6e6 | ||
|
|
e5417310f8 | ||
|
|
28df21f4bb | ||
|
|
5c08e29a08 | ||
|
|
7f26b4ba46 | ||
|
|
5c357d699f | ||
|
|
c2340ae88a | ||
|
|
0f5613ba46 | ||
|
|
a049c69cbb | ||
|
|
2c066eb2f0 | ||
|
|
3bfb3ddfc5 | ||
|
|
7e2b9ed8f6 | ||
|
|
7d8c188f83 | ||
|
|
f3b2080e69 | ||
|
|
a58991e6d8 | ||
|
|
3fe5d1b278 | ||
|
|
112c533ac7 | ||
|
|
e7d661158b | ||
|
|
db168cdbe5 | ||
|
|
b539780f33 | ||
|
|
40560aced1 | ||
|
|
09d3b9a3bf | ||
|
|
34cbd5015b | ||
|
|
a1d07a56e5 | ||
|
|
2accd804f9 | ||
|
|
6644962d70 | ||
|
|
1568e489eb | ||
|
|
4ee382832a | ||
|
|
1296223403 | ||
|
|
deb11bade2 | ||
|
|
bd3c368512 | ||
|
|
42147d30c0 | ||
|
|
e308e99f05 | ||
|
|
382d7dd406 | ||
|
|
f5acf19a11 | ||
|
|
3904428a4f | ||
|
|
aa8e06c162 | ||
|
|
9b28c277f0 | ||
|
|
f7a41b3fae | ||
|
|
d630e62114 | ||
|
|
b1def488c4 | ||
|
|
b8dbef6241 | ||
|
|
f01f54e614 | ||
|
|
6fa807c2c3 | ||
|
|
421482e121 | ||
|
|
a898253089 | ||
|
|
d2a1d14eac | ||
|
|
17bc2f02e9 | ||
|
|
354c4641a9 | ||
|
|
bb4e7943c5 | ||
|
|
03629c966e | ||
|
|
5ba729fcbb | ||
|
|
5a0018fc86 | ||
|
|
9230b53ccd | ||
|
|
66c8fef97f | ||
|
|
45492a3013 | ||
|
|
4bb333e9bc | ||
|
|
fcec77131c | ||
|
|
4ad283c647 | ||
|
|
dbaf328c0a | ||
|
|
78bfe53dc7 | ||
|
|
7debebb7b3 | ||
|
|
64298101b0 | ||
|
|
45ca9c347b | ||
|
|
e702f3c151 | ||
|
|
aecada8c56 | ||
|
|
adeb22d7b2 | ||
|
|
20c2561a45 | ||
|
|
2b20288ca0 | ||
|
|
9209922ea4 | ||
|
|
2e25bfa9e0 | ||
|
|
3104e9945b | ||
|
|
21ae48a5c8 | ||
|
|
661fe3dc13 | ||
|
|
284f86b740 | ||
|
|
c481bf4f47 | ||
|
|
c6e13e4fcf | ||
|
|
969f273fec | ||
|
|
8392854619 | ||
|
|
d044406f8f | ||
|
|
c30128d3f0 | ||
|
|
fc6e911888 | ||
|
|
16e44aa1d1 | ||
|
|
218b03ceb7 | ||
|
|
8ce2e5acbb | ||
|
|
4bfe239083 | ||
|
|
9f9bdbb671 | ||
|
|
a07c663ca9 | ||
|
|
4b2e6a7c83 | ||
|
|
17eea3c230 | ||
|
|
c101b70678 | ||
|
|
b9ce850a2c | ||
|
|
fbab0ffbf8 | ||
|
|
e6eb3dd849 | ||
|
|
c54867d9f0 | ||
|
|
0746cffe6d | ||
|
|
6cbaf3f7b6 | ||
|
|
be7328c8eb | ||
|
|
1202e9baa2 | ||
|
|
a24b548389 | ||
|
|
6af38abc76 | ||
|
|
55b526bc1e | ||
|
|
4558536676 | ||
|
|
b965c3d346 | ||
|
|
ec5e4aa3e1 | ||
|
|
7990249e2d | ||
|
|
6d72b38db4 | ||
|
|
50e0d22def | ||
|
|
d7f6047529 | ||
|
|
02387b5b16 | ||
|
|
409fd658f2 | ||
|
|
5650be5200 | ||
|
|
00440c02e1 | ||
|
|
caf5248e22 | ||
|
|
5da25f782b | ||
|
|
66da91f78b | ||
|
|
107e87e457 | ||
|
|
dfb9f32e23 | ||
|
|
c848e7cf27 | ||
|
|
6c5b4a7a25 | ||
|
|
be04d3e5e0 | ||
|
|
09daf0fe49 | ||
|
|
dd01ea9927 | ||
|
|
39d87e363f | ||
|
|
b7fafb1c16 | ||
|
|
d2e72143e8 | ||
|
|
21beacf700 | ||
|
|
0d1b91188d | ||
|
|
fa9d5879b3 | ||
|
|
f1e5b82ecc | ||
|
|
c206d10e3a | ||
|
|
5a58d272ea | ||
|
|
6bb16fdad6 | ||
|
|
d331db1141 | ||
|
|
dbff114dda | ||
|
|
65e8271fbe | ||
|
|
b4618bb853 | ||
|
|
de1b48a63f | ||
|
|
6fedbd1b09 | ||
|
|
83d0caa209 | ||
|
|
13b2989886 | ||
|
|
88447e6c2b | ||
|
|
6adf750c50 | ||
|
|
1a04a75c3d | ||
|
|
4341d3dca8 | ||
|
|
fca7acd05f | ||
|
|
59eb509d47 | ||
|
|
526d396492 | ||
|
|
bcdccd14c4 | ||
|
|
3ae22f64a4 | ||
|
|
90649fc363 | ||
|
|
f21ab39a89 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
0c71b753ef | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
310f20b288 | ||
|
|
f1537a7887 | ||
|
|
55cafc282f | ||
|
|
bb5fd5ee97 | ||
|
|
bebf0db792 | ||
|
|
e3dfbf6591 | ||
|
|
9b0d305223 | ||
|
|
aad9c248dc | ||
|
|
4c2fdd5f5a | ||
|
|
ab7e89873f | ||
|
|
c36be7bb94 | ||
|
|
51b6be3e7a | ||
|
|
0ae5ce017a | ||
|
|
ab96e74e69 | ||
|
|
d00229ee29 | ||
|
|
7d3e27fb6d | ||
|
|
c1811fd716 | ||
|
|
2d0ffb71aa | ||
|
|
d65ee4f89d | ||
|
|
e85b4f34dd | ||
|
|
bfab172608 | ||
|
|
4edb7b8f80 | ||
|
|
0b122e2c76 | ||
|
|
0720526b64 | ||
|
|
cbb1435a46 | ||
|
|
8e4606c57b |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/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.",
|
"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": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -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.
|
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.
|
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -219,6 +219,8 @@ jobs:
|
|||||||
run: npx vitest run
|
run: npx vitest run
|
||||||
working-directory: packages/xterm-zerolag-input
|
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:
|
# 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
|
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
||||||
# codex binary for codex-predictive-echo)
|
# codex binary for codex-predictive-echo)
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ src/web/public/settings-ui.js
|
|||||||
src/web/public/sw.js
|
src/web/public/sw.js
|
||||||
src/web/public/terminal-ui.js
|
src/web/public/terminal-ui.js
|
||||||
src/web/public/voice-input.js
|
src/web/public/voice-input.js
|
||||||
src/web/public/upload.html
|
|
||||||
scripts/remotion/
|
scripts/remotion/
|
||||||
|
|
||||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ Quick pointers:
|
|||||||
|
|
||||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
- 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
|
- 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): ...`)
|
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||||
- Never commit secrets or local state from `~/.codeman/`
|
- Never commit secrets or local state from `~/.codeman/`
|
||||||
@@ -1,5 +1,56 @@
|
|||||||
# aicodeman
|
# 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
|
## 1.35.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.35.0 (must match `package.json`)
|
**Version**: 1.40.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -123,11 +123,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||||
|
|
||||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and the Playwright tests in `BROWSER_TEST_GLOBS`; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). `browser-suite.yml` runs `npm run test:browser` (minus codex-predictive-echo) nightly and on demand via `workflow_dispatch`; it is informational and never gates a push or PR.
|
||||||
|
|
||||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||||
|
|
||||||
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||||
|
|
||||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||||
|
|
||||||
@@ -248,9 +248,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||||
|
|
||||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||||
|
|
||||||
@@ -274,9 +274,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||||
|
|
||||||
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTermLine truncated
|
||||||
|
|
||||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||||
|
|
||||||
@@ -284,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||||
|
|
||||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||||
|
|
||||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||||
@@ -293,7 +295,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||||
|
|
||||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--responseLine truncated
|
||||||
|
|
||||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||||
|
|
||||||
@@ -325,14 +327,18 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||||
|
|
||||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||||
|
|
||||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: `test/mobile-tab-tap-zones.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||||
|
|
||||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||||
|
|
||||||
|
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||||
|
|
||||||
|
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||||
|
|
||||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||||
|
|
||||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||||
@@ -347,7 +353,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||||
|
|
||||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||||
|
|
||||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||||
|
|
||||||
@@ -385,11 +391,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||||
|
|
||||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
|
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -430,7 +436,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
|||||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
||||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`; enforced by `test/test-ports-guard.test.ts`), and a raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` until the follow-up sweep, because that helper caches servers by port. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
|
||||||
|
|
||||||
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
||||||
|
|
||||||
@@ -459,7 +465,7 @@ npm run test:perf # wall-clock benchmarks — need an otherwise idle machin
|
|||||||
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above).
|
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before.
|
||||||
|
|
||||||
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
||||||
|
|
||||||
@@ -469,7 +475,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
|
|||||||
|
|
||||||
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
|
||||||
|
|
||||||
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
|
**Ports**: a test that constructs `WebServer` binds port 0 and reads `server.boundPort`; `test/test-ports-guard.test.ts` fails any other port outside its shrink-only legacy list. A raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. The mobile suite keeps fixed ports (3150+, per `test/mobile/README.md`) until the follow-up sweep, since `createTestServer()` caches servers by port. Never 3000 (the live instance).
|
||||||
|
|
||||||
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
|
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200.
|
||||||
|
|
||||||
@@ -487,7 +493,7 @@ curl -sk https://localhost:3000/api/subagents | jq # Background agents
|
|||||||
cat ~/.codeman/state.json | jq # Persisted state
|
cat ~/.codeman/state.json | jq # Persisted state
|
||||||
```
|
```
|
||||||
|
|
||||||
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
Legacy screenshots (deprecated): `GET/POST /api/screenshots` still read and write `~/.codeman/screenshots/`, but the upload page that fed them is gone, they log a one-time deprecation warning, and they are removed in a later MAJOR, after at least one MINOR release that carries the warning (`docs/versioning-policy.md`). To hand a file to an agent use `POST /api/sessions/:id/paste-image`.
|
||||||
|
|
||||||
## Performance & Limits
|
## Performance & Limits
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,12 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||||
@@ -54,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
|
|||||||
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -385,6 +381,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.
|
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
|
### 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.
|
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.
|
||||||
@@ -418,7 +422,7 @@ The title is templated into the served HTML on first byte, so it's correct from
|
|||||||
### Tab Alerts
|
### Tab Alerts
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||||
@@ -731,6 +735,10 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
|||||||
|
|
||||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
#### Step 1: install it
|
#### Step 1: install it
|
||||||
|
|
||||||
| How | Command | Scope |
|
| How | Command | Scope |
|
||||||
|
|||||||
@@ -24,11 +24,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
@@ -386,6 +382,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
|||||||
|
|
||||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
运行 **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 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/capture-geometry-retry.browser.test.ts',
|
'test/capture-geometry-retry.browser.test.ts',
|
||||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||||
'test/split-pane-terminal.browser.test.ts',
|
'test/split-pane-terminal.browser.test.ts',
|
||||||
|
'test/terminal-tile-scroll.browser.test.ts',
|
||||||
'test/shift-enter-keypress.browser.test.ts',
|
'test/shift-enter-keypress.browser.test.ts',
|
||||||
'test/key-tester.browser.test.ts',
|
'test/key-tester.browser.test.ts',
|
||||||
'test/webhook-settings.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/git-status.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.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/mobile-ime-preview.browser.test.ts',
|
||||||
|
'test/run-mode-menu-scroll.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ payload return `{ "success": true, "data": {} }`.
|
|||||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
> `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
|
> 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
|
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||||||
@@ -445,6 +452,25 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
|||||||
slot, because the routes release the waiter when the client disconnects, but a
|
slot, because the routes release the waiter when the client disconnects, but a
|
||||||
client that opens many concurrent waits against one session will still hit the cap.
|
client that opens many concurrent waits against one session will still hit the cap.
|
||||||
|
|
||||||
|
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||||
|
|
||||||
|
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||||
|
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||||
|
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||||
|
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
|
||||||
|
is the number of scrollback rows tmux holds above the visible frame, which is the
|
||||||
|
most a `full=1` request can add. `truncated` describes the byte stream instead: for a
|
||||||
|
pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a
|
||||||
|
`tail` cut dropped are earlier repaints that no request returns. The capture runs
|
||||||
|
synchronous tmux calls on the server; the `Server-Timing` header reports `capture`,
|
||||||
|
`prepare` and `total`.
|
||||||
|
|
||||||
|
| Query | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||||
|
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||||
|
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||||
|
|
||||||
## Session lineage (`parentSessionId`)
|
## Session lineage (`parentSessionId`)
|
||||||
|
|
||||||
A create request may name the session that spawned it, which the web UI draws as a
|
A create request may name the session that spawned it, which the web UI draws as a
|
||||||
@@ -470,6 +496,32 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
|||||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||||
unresolved) and survives a server restart.
|
unresolved) and survives a server restart.
|
||||||
|
|
||||||
|
## Session model (`displayModel`)
|
||||||
|
|
||||||
|
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||||
|
session runs as far as the server knows it, for the web UI's session headers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`source` is where it came from, strongest first:
|
||||||
|
|
||||||
|
| `source` | Meaning |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
|
||||||
|
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
|
||||||
|
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
|
||||||
|
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
|
||||||
|
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
|
||||||
|
|
||||||
|
Between `statusline` and `screen` the newest report wins. The field is absent when no
|
||||||
|
model is known (a shell, a CLI that reports none and was launched without one). `model`
|
||||||
|
is display text from a pane or a CLI report: control characters are stripped and it is at
|
||||||
|
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
|
||||||
|
persisted and restored after a server restart until the next report replaces it; a
|
||||||
|
`config` value is read again at every pane start, attach and relaunch instead.
|
||||||
|
|
||||||
## Approvals Inbox
|
## Approvals Inbox
|
||||||
|
|
||||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||||
@@ -724,11 +776,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:
|
**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.
|
- 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 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.
|
- 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:
|
- `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`.
|
`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`.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
|||||||
|
|
||||||
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binaryLine truncated
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessionsLine truncated
|
||||||
|
|
||||||
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
|||||||
|
|
||||||
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
||||||
|
|
||||||
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
||||||
|
|
||||||
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
||||||
|
|
||||||
@@ -175,15 +175,19 @@ Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessio
|
|||||||
|
|
||||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||||
|
|
||||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache.
|
||||||
|
|
||||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
**Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get `<g class="lineage-family lineage-family--focus">`: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap).
|
||||||
|
|
||||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
**Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family `<g class="lineage-family">`, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working".
|
||||||
|
|
||||||
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
|
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines paint OVER it, which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer. The sidebar layout draws no lineage.
|
||||||
|
|
||||||
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since clamping would point at a tab that is not there, and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||||
|
|
||||||
|
History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely.
|
||||||
|
|
||||||
|
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2"; per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the lines below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
|
||||||
|
|
||||||
### Auto-named sessions (first prompt → tab title)
|
### Auto-named sessions (first prompt → tab title)
|
||||||
|
|
||||||
@@ -207,21 +211,23 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", true of an unbounded capture and false of a window cut at the tail size, so a bounded window that reached it marked the session `exhausted` and removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the Line truncated
|
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", true of an unbounded capture and false of a window cut at the tail size, so a bounded window that reached it marked the session `exhausted` and removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the Line truncated
|
||||||
|
|
||||||
|
**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`.
|
||||||
|
|
||||||
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
|
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
|
||||||
|
|
||||||
⚠️ **A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery must verify itself** (`_scheduleDroppedOutputRecovery`, app.js): a hole in a TUI byte stream is a desynced cursor, which is muffled text (#464). It was a fire-and-forget 2s timer that nulled its own handle and then called `_onSessionNeedsRefresh()` — whose early returns (a buffer load in flight, a refresh already owning the session) are MOST likely to be true during exactly the burst that caused the drop, so the recovery was lost silently and the bytes were never replayed. `_onSessionNeedsRefresh` now returns whether it actually repainted, and the scheduler re-arms while it has not, bounded by `DROP_RECOVERY_MAX_ATTEMPTS` because the early returns it retries past are transient contention. A refresh that died at the capture fetch DEADLINE (it returns `'deadline'`) is a stalled link, not contention, and is NOT retried: each retry would be another `?full=1` capture waiting out a deadline of up to two minutes. The same debounce still collapses a burst into one attempt, and the `TERMINAL DROP` crash-trail line sits behind it, since one line per dropped frame evicted the whole 50-entry trail in under a second. Tests: `test/dropped-output-recovery.test.ts`, whose retry case and no-retry case only pin the fix as a pair.
|
⚠️ **A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery must verify itself** (`_scheduleDroppedOutputRecovery`, app.js): a hole in a TUI byte stream is a desynced cursor, which is muffled text (#464). It was a fire-and-forget 2s timer that nulled its own handle and then called `_onSessionNeedsRefresh()` — whose early returns (a buffer load in flight, a refresh already owning the session) are MOST likely to be true during exactly the burst that caused the drop, so the recovery was lost silently and the bytes were never replayed. `_onSessionNeedsRefresh` now returns whether it actually repainted, and the scheduler re-arms while it has not, bounded by `DROP_RECOVERY_MAX_ATTEMPTS` because the early returns it retries past are transient contention. A refresh that died at the capture fetch DEADLINE (it returns `'deadline'`) is a stalled link, not contention, and is NOT retried: each retry would be another `?full=1` capture waiting out a deadline of up to two minutes. The same debounce still collapses a burst into one attempt, and the `TERMINAL DROP` crash-trail line sits behind it, since one line per dropped frame evicted the whole 50-entry trail in under a second. Tests: `test/dropped-output-recovery.test.ts`, whose retry case and no-retry case only pin the fix as a pair.
|
||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||||
|
|
||||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||||
|
|
||||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||||
|
|
||||||
@@ -231,7 +237,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
|
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
|
||||||
|
|
||||||
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`.
|
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. ⚠️ **The close is optimistic**: `closeSession()` runs `_onSessionDeleted({ id })` itself (the split, tile, detached-window, WebSocket and per-session teardown, idempotent so the real broadcast finds nothing left), does the follow-up selection and renders the strip with `renderSessionTabs({ immediate: true })` BEFORE sending the DELETE; the server's kill takes a few hundred ms and a tab that sat there that long read as a dead button. `_onSessionCreated`/`_onSessionUpdated` ignore ids in `_closingSessions`, so no upsert brings the row back mid-request. `_apiDelete` resolves a Response (or `null`), never throws: OK or 404 is closed; anything else is checked with `GET /api/sessions/:id` (a delete can land and lose its reply, after its broadcast was already spent) and only a session that still exists gets its row back at its old index, with the error toast. Tests: `test/session-close-fallback.test.ts`.
|
||||||
|
|
||||||
### Circuit breakers: Ralph and PTY-exit
|
### Circuit breakers: Ralph and PTY-exit
|
||||||
|
|
||||||
@@ -241,6 +247,8 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne
|
|||||||
|
|
||||||
**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-<fragment>` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manLine truncated
|
**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-<fragment>` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manLine truncated
|
||||||
|
|
||||||
|
⚠️ **The tab's badge is translated (zh-CN), so its updates compare with English, never the DOM.** `applyPaneExitBadge()` remembers the last English badge text (`data-label`) and the tab's accessible name (`data-aria-source`), both also seeded by the full render, and writes only when those change: the translator rewrites both in zh-CN, and a DOM compare (or an unconditional set) would write the English back on every incremental pass for it to translate again. i18n.js has `exited` and patterns for `exited (N)`, `exited (signal N)` and `<name> session, agent exited ...` (the name passed through); `test/i18n-exit-run-help.test.ts` covers every `paneExitLabel()` form.
|
||||||
|
|
||||||
**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close:
|
**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close:
|
||||||
|
|
||||||
- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep.
|
- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep.
|
||||||
@@ -260,7 +268,7 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit
|
|||||||
|
|
||||||
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
|
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
|
||||||
|
|
||||||
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
||||||
|
|
||||||
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
||||||
|
|
||||||
@@ -373,6 +381,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
|||||||
|
|
||||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||||
|
|
||||||
|
**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk,Line truncated
|
||||||
|
|
||||||
### Filesystem path picker
|
### Filesystem path picker
|
||||||
|
|
||||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||||
@@ -674,7 +684,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
|||||||
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
||||||
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
||||||
|
|
||||||
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
||||||
|
|
||||||
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
|
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
|
||||||
|
|
||||||
@@ -771,7 +781,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
|||||||
|
|
||||||
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
**The session list can render as the horizontal header strip (default) or as a collapsible left sidebar** — App Settings → Layout → Tabs → **Session List Layout** (`sessionListLayout: 'header' | 'sidebar'`, in the per-device `displayKeys` set, so it never syncs across devices; also in `SettingsUpdateSchema`, which is `.strict()` — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ **There is exactly ONE `#sessionTabs` element and `applySessionListLayout()` RE-PARENTS it** between `#sessionTabsHost` (in `<header>`) and `#sessionSidebarList` (in the `<aside>`, a flex sibling of `.terminal-wrap` so the terminal shrinks and `terminal-ui.js`'s `ResizeObserver` refits xterm on its own). It must never be cloned or rebuilt: `app.$(id)` caches elements by id and NEVER invalidates, and `settings-ui.js` / `webview-tabs.js` resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off `html[data-session-list]` / `html[data-sidebar]`, both written by a pre-paint script in `<head>` so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (`data-webview-id` rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (`TAB_SELECTOR`/`DOCK_SELECTOR` match on class names that are unchanged) all need **zero** edits.
|
||||||
|
|
||||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the harness mark (an agent CLI's `run-mode-dot` logo, a shell's `sh` chip) do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||||
|
|
||||||
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
|
||||||
|
|
||||||
@@ -793,7 +803,31 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
|||||||
|
|
||||||
### Split-pane sessions
|
### Split-pane sessions
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown withLine truncated
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattenLine truncated
|
||||||
|
|
||||||
|
### Tile grid
|
||||||
|
|
||||||
|
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
||||||
|
|
||||||
|
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
||||||
|
|
||||||
|
⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame.
|
||||||
|
|
||||||
|
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||||
|
|
||||||
|
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||||
|
|
||||||
|
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy.
|
||||||
|
|
||||||
|
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
||||||
|
|
||||||
|
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), each tile and each empty slot placed explicitly in its cell; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own (its own is held but refused, `dropEffect: 'none'`, no highlight), an empty slot anything. ⚠️ **The grid is cells** (owner: an empty cell can be any cell): `grid.cells` (a session id or `null` per cell, cols x rows of them) is the ONE source of truth and `grid.ids` a getter deriving the tiles in reading order, so never write `ids` (a leftover write in the non-strict `Object.assign` methods would be silently ignored). The shape comes from the tile count; within it a tile moved onto an empty cell goes there and leaves its own cell empty (`_moveTileToCell`), a removed tile leaves a hole, an added one takes the first hole or the cell a tab was dropped on (`_placeTile`), and a shape change goes through the pure `fitTileCells` (keep row and column when all fit, else pack). Each slot carries its cell (`data-cell`). The focus chords run `tileInDirection` over the CELLS (never onto a hole; the nearest tile in the nearest row for up/down), the Move Tile chords `tileCellInDirection` (the adjacent cell: into it when empty, a swap when not). Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
|
||||||
|
|
||||||
|
⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it.
|
||||||
|
|
||||||
|
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
|
||||||
|
|
||||||
|
⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants; a restore puts the holes back only when the cell count matches the current shape, else the tiles stay packed, and `_closeStoredTileGrid` writes the cells back, never the packed list. The old packed format reads as cells with no hole; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
||||||
|
|
||||||
### Gesture control: the setting
|
### Gesture control: the setting
|
||||||
|
|
||||||
@@ -861,12 +895,38 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
|||||||
|
|
||||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
|
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
|
||||||
|
|
||||||
|
⚠️ **Grouped by state, the active tab can move without being switched to.** The strip orders its tabs in one `order` band per state (`CodemanTabTriage`), so the active session changing state (a prompt sent: idle to working; a permission prompt: needs you) slides its chip into another band while `scrollLeft` stays, and the tab in use left the screen (measured at 390px: x 165 to -870). `_noteActiveTabBand()` records the active tab's band (read off the element, also from `_updateActiveTabImmediate()`, so the pass right after a switch counts: viewing a waiting tab spends its alert and drops it to idle) and both render paths reveal it when that band moved, in the single scrolling row only (`_isScrollingTabRow()`). Keyed on the BAND, never the raw order value: another tab entering or leaving the active tab's band shifts that value by one, and another tab's move must never yank a strip the user is browsing. 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: `test/tab-triage.test.ts`.
|
||||||
|
|
||||||
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
|
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
|
||||||
|
|
||||||
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
|
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
|
||||||
|
|
||||||
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||||
|
|
||||||
|
### Tab layouts (by state, by case, ledger)
|
||||||
|
|
||||||
|
**Tab layouts** (`tabArrangement`, per-device, default `classic`, the others opt-in; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before.
|
||||||
|
|
||||||
|
**By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, when the strip starts wrapping, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). ⚠️ Only the wrapping strip reads the label column, so `_sizeTabTriageGutter()` measures nothing (and forgets its key) while the strip does not wrap, skips parts that are not laid out (a hidden heading measures 0 per part, and the 5px gap between label and count used to turn that into a 15px column cached as if measured), and `updateTabOverflowMode()` calls it right after deciding to wrap, because a phone turned to landscape or a foldable opened crosses 768px with no tab render behind it (the resize handler only calls `updateTabOverflowMode()`). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`).
|
||||||
|
|
||||||
|
⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width.
|
||||||
|
|
||||||
|
**By case.** Each case's tabs sit in one `.tab-cluster` box (`_renderTabClusters()`), in the order the case first appears in the tab order; membership is `_mobileOverviewCaseFor()` (longest case path that prefixes the working directory), else the directory, else the session alone. A box with two or more tabs is labelled (swatch, name, count) and its generated `w<n>-<case>` names drop the `-<case>`, which stays in the DOM in a `.tab-name-case` span only `.tabs-clusters` hides; a one-tab case gets only the swatch in the header strip; a web tab gets a box of its own. The colour is `--session-<colour>` by a stable hash of the case key (`CodemanTabClusters.colorFor()`), so nothing is stored. The rail and the sidebar draw every case as a labelled section; phones dissolve the boxes (`display: contents`) into the one chip row.
|
||||||
|
|
||||||
|
⚠️ **The boxes ARE a DOM grouping**, unlike by state, which is safe only because a tab changes case far less often than it changes state: `_tabClusterLayout().key` is the whole structure, and the incremental path rebuilds whenever it differs from the last full render's. The keyboard walk keeps boxes together (box index first, then computed order, since a sorted rail orders rows inside each box). Drag only reorders inside one box, as it only reorders inside one state group (`_isTabDropAcrossGroups()`).
|
||||||
|
|
||||||
|
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
|
||||||
|
|
||||||
|
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
|
||||||
|
|
||||||
|
### Header stats styles
|
||||||
|
|
||||||
|
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header.
|
||||||
|
|
||||||
|
⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it.
|
||||||
|
|
||||||
|
⚠️ The extra parts (`.stat-ring`, `.connection-tile`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. ⚠️ A tile is a three-row grid (9px label, 14px value, 2px bar) with pixel line-heights, 36px tall like the header, in the bundled JetBrains Mono. The first version laid the bar over the bottom of a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it; the bar is now a real row, so the height comes from the layout, not the font. The WS tile keeps its grid on the inner `.connection-tile` span, because `_updateConnectionIndicator()` writes the indicator's own `display` inline. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`.
|
||||||
|
|
||||||
### Phone overview home screen
|
### Phone overview home screen
|
||||||
|
|
||||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
|
||||||
@@ -979,7 +1039,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
### Z-index layers
|
### Z-index layers
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
||||||
|
|
||||||
## Security layers
|
## Security layers
|
||||||
|
|
||||||
@@ -1013,6 +1073,8 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
|||||||
|
|
||||||
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
|
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
|
||||||
|
|
||||||
|
⚠️ **The kill path's waits are deadlines, not sleeps** (`waitForProcessesExit()`, `utils/process-exit-wait.ts`): `Session.stop()` (PTY client, 100 ms), and `killSession()`'s children (200 ms), process group (100 ms) and final verify (2 s) each stop waiting as soon as the processes are gone. They used to be fixed sleeps plus a 100 ms verify poll, ~0.45 s per close even for a session whose processes died in a few ms. `isProcessRunning()` counts a zombie as exited (`/proc/<pid>/stat` state `Z`/`X`), since `kill(pid, 0)` cannot tell one apart and a zombie only awaits its reaper. Claude itself takes ~0.8 s to exit on SIGTERM or SIGHUP (measured 2026-10-10), so the process-group deadline still ends in SIGKILL for a claude pane, as it always did. `tmux kill-session` and the pane-pid lookup on this path are async, never `execSync` (tens of ms of a frozen server per close). `killSubagentsForSession()` makes ONE `getClaudePids()` scan for all of the session's active/idle subagents, never one `pgrep` per agent (~85 ms each with ~100 matching processes).
|
||||||
|
|
||||||
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
||||||
|
|
||||||
## Local packages and build artifacts
|
## Local packages and build artifacts
|
||||||
|
|||||||
@@ -71,17 +71,21 @@ We tested three browser automation frameworks against the Codeman web UI:
|
|||||||
|
|
||||||
## Test File Structure
|
## Test File Structure
|
||||||
|
|
||||||
### Port Allocation
|
### Ports
|
||||||
|
|
||||||
| Port Range | Test File |
|
A test that starts a server binds an ephemeral port, never a fixed one:
|
||||||
|------------|-----------|
|
|
||||||
| 3150-3153 | browser-e2e.test.ts (existing) |
|
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
|
||||||
| 3154 | file-link-click.test.ts |
|
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
|
||||||
| 3155 | browser-playwright.test.ts |
|
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
|
||||||
| 3156 | browser-puppeteer.test.ts |
|
excepted).
|
||||||
| 3157 | browser-agent.test.ts |
|
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
|
||||||
| 3158-3160 | browser-comparison.test.ts |
|
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
|
||||||
| 3180-3182 | scripts/browser-comparison.mjs |
|
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
|
### File Purposes
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@ const browser = await chromium.launch({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('http://localhost:3000');
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
// Auto-waiting selectors
|
// Auto-waiting selectors
|
||||||
await page.click('.btn-claude');
|
await page.click('.btn-claude');
|
||||||
@@ -140,7 +144,7 @@ const browser = await puppeteer.launch({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.goto('http://localhost:3000');
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
// Manual waiting often needed
|
// Manual waiting often needed
|
||||||
await page.click('.btn-claude');
|
await page.click('.btn-claude');
|
||||||
@@ -186,7 +190,7 @@ function agentBrowserJson<T>(cmd: string): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
agentBrowser('open http://localhost:3000');
|
agentBrowser(`open ${BASE_URL}`);
|
||||||
agentBrowser('click ".btn-claude"');
|
agentBrowser('click ".btn-claude"');
|
||||||
const title = agentBrowserJson<{title: string}>('get title');
|
const title = agentBrowserJson<{title: string}>('get title');
|
||||||
|
|
||||||
@@ -246,11 +250,14 @@ npx playwright install chromium
|
|||||||
### 4. Wait for Server Startup
|
### 4. Wait for Server Startup
|
||||||
|
|
||||||
```typescript
|
```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 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
|
### 5. Clean Up Sessions
|
||||||
|
|
||||||
Track created sessions for cleanup:
|
Track created sessions for cleanup:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
|||||||
interface CliEntry {
|
interface CliEntry {
|
||||||
id: CliId; // 'codex'
|
id: CliId; // 'codex'
|
||||||
label: string; // 'Codex' — shown in menus
|
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
|
accent: string; // single hex colour
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||||
@@ -49,6 +49,11 @@ interface CliEntry {
|
|||||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||||
// that ended waiting for workers it will resume from
|
// 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
|
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -57,10 +62,14 @@ interface CliEntry {
|
|||||||
|
|
||||||
### Regexes that come from config
|
### Regexes that come from config
|
||||||
|
|
||||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||||
|
|
||||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
`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 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.
|
||||||
|
|
||||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||||
@@ -75,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
|
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
|
the `·` its footer joins items with. Codex pins
|
||||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
`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
|
puts the row third from the bottom once the status line and the composer are counted, and
|
||||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
|
||||||
against live panes rather than read out of a binary, which is the standard for adding a
|
the status line (it disappears while a prompt is typed). So its entry declares
|
||||||
third.
|
`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
|
`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
|
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
|||||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||||
profile. That is also how you point dsh at a local or third-party provider.
|
profile. That is also how you point dsh at a local or third-party provider.
|
||||||
|
|
||||||
|
Codeman does READ the route, for display only: a session header names the model
|
||||||
|
the TUI's status line draws, and while it draws none (the status bar's model
|
||||||
|
field switched off, or not painted yet) the model the session's route config
|
||||||
|
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||||
|
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||||
|
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||||
|
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||||
|
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||||
|
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||||
|
model id alone. The TUI can still reject a pinned route against its provider's
|
||||||
|
model catalog at startup; the status line, when on, then shows what it chose.
|
||||||
|
|
||||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -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/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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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) |
|
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
||||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
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`
|
- **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
|
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
|
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||||
|
|||||||
@@ -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.
|
> **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
|
### Goal
|
||||||
Detect OpenCode's state from terminal output (idle, working, ready).
|
Detect OpenCode's state from terminal output (idle, working, ready).
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,13 @@ command override instead.
|
|||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
|
- **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.
|
idle detection reads the screen: the registry entry's `workDetect` names pi's
|
||||||
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
|
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
|
||||||
signal; a Codeman pi extension using it is the highest-value follow-up.
|
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
|
- **No response viewer.** Pi writes JSONL v3 session files under
|
||||||
`~/.pi/agent/sessions/`; nothing reads them yet.
|
`~/.pi/agent/sessions/`; nothing reads them yet.
|
||||||
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
||||||
|
|||||||
@@ -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 `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.
|
- 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.
|
- 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.
|
- 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 any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
|
- 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.
|
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
||||||
|
|
||||||
## Testing (per repo rules)
|
## Testing (per repo rules)
|
||||||
|
|||||||
@@ -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` /
|
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||||
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||||
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||||
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
|
||||||
realpath containment.
|
`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**:
|
Two registration paths, with **different trust**:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||||
|
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||||
|
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||||
|
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||||
|
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||||
|
> closes anything (Close Session has no default key).
|
||||||
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||||
@@ -84,7 +92,15 @@ view needs a wide viewport). So:
|
|||||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||||
instance with no overlay is exactly how Codeman behaved before the local-
|
instance with no overlay is exactly how Codeman behaved before the local-
|
||||||
echo overlay existed for touch devices — normal, not degraded, for a
|
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
|
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
|
parity is a scoped v2 (extract the shared logic already once you have two
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- `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.
|
- 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
|
### 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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.
|
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.
|
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.
|
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.
|
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).
|
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
programmatically is not supported (there is no stable library entry point).
|
||||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||||
Gesture Control (beta), Agent Teams
|
Gesture Control (beta), Agent Teams
|
||||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||||
in the UI or docs. These may change or be removed at any time.
|
(`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
|
## Deprecation policy
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent.
|
|||||||
| Respawn cycling and unattended runs | Yes | Yes |
|
| Respawn cycling and unattended runs | Yes | Yes |
|
||||||
| Cron jobs | Yes | Yes |
|
| Cron jobs | Yes | Yes |
|
||||||
| Docker cases, remote SSH cases | 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 |
|
| Auto-resume when a usage limit resets | Yes | No |
|
||||||
| Plan usage chip | Yes | No |
|
| Plan usage chip | Yes | No |
|
||||||
| Approvals Inbox | Yes | DeepSeek yes; others 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
|
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.
|
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).
|
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
||||||
|
|
||||||
### Codex
|
### 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:
|
Two behaviours that are deliberate and worth knowing:
|
||||||
|
|
||||||
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
- **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
|
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
|
||||||
only the CLI you spawned yourself.
|
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
|
### Antigravity
|
||||||
|
|
||||||
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
|
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
|
`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
|
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
|
||||||
once. They stay out.
|
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).
|
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.
|
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
|
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).
|
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ npm run lint
|
|||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax
|
npm run check:frontend-syntax
|
||||||
npm run check:browser-excludes
|
npm run check:browser-excludes
|
||||||
npm test -- test/<file>.test.ts # one file, the normal way
|
npm test # the gate, exactly what CI runs
|
||||||
npm run test:ci # the full CI sweep
|
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,
|
`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
|
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||||
`pre-push` hook of your own is never overwritten.
|
`pre-push` hook of your own is never overwritten.
|
||||||
|
|
||||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
|
||||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
`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 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
|
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
|
||||||
3150 or above, and never 3000.
|
(`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
|
## Finding your way around
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ Codeman-side configuration:
|
|||||||
- Per-case toggles (Agent Teams, 1M Opus context).
|
- Per-case toggles (Agent Teams, 1M Opus context).
|
||||||
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
- 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 |
|
| How | Result |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
|||||||
## Lineage
|
## Lineage
|
||||||
|
|
||||||
A create request can name the session that spawned it, through a body field or a header, and
|
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
|
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.
|
failing the spawn, because a cosmetic field must never break a worker.
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ codeman web # then open http://localhost:3000
|
|||||||
| Page | What it answers |
|
| Page | What it answers |
|
||||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||||
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
| [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? |
|
| [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? |
|
| [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? |
|
| [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? |
|
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| ------------------------------- | --------------------------------------------------------------- |
|
| ------------------------------- | --------------------------------------------------------------- |
|
||||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||||
| `Ctrl+W` | Kill the active session. |
|
|
||||||
| `Ctrl+Tab` | Next session. |
|
| `Ctrl+Tab` | Next session. |
|
||||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||||
|
|
||||||
|
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||||
|
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||||
|
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||||
|
|
||||||
## Terminal
|
## Terminal
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
@@ -36,6 +39,22 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
|||||||
|
|
||||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||||
|
|
||||||
|
## Tile grid
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||||
|
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||||
|
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||||
|
|
||||||
|
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||||
|
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||||
|
default. See [Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
## Everything else
|
## Everything else
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
|||||||
enforces it.
|
enforces it.
|
||||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||||
- The desktop home tab rail, which needs a wide window.
|
- 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
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
|||||||
| To do this | Do that |
|
| To do this | Do that |
|
||||||
| ------------------------- | ------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------------------------------------------- |
|
||||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||||
| Stop everything | `tmux -L codeman kill-server`. |
|
| Stop everything | `tmux -L codeman kill-server`. |
|
||||||
|
|
||||||
|
|||||||
@@ -57,21 +57,30 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
Chips for every optional header control, with a live preview of the resulting header:
|
Chips for every optional header control, with a live preview of the resulting header:
|
||||||
|
|
||||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||||
Ultracode Windows, Cron.
|
Ultracode Windows, Cron.
|
||||||
|
|
||||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
**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
|
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
|
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
|
||||||
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
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
|
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
|
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
|
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||||
resizable panes plus their divider have nowhere to go.
|
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
|
||||||
|
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
|
||||||
|
[Tile Grid](Tile-Grid).
|
||||||
|
|
||||||
This section also holds background-agent tracking, including whether to track agents for
|
This section also holds background-agent tracking, including whether to track agents for
|
||||||
every session or only the active tab.
|
every session or only the active tab.
|
||||||
@@ -86,10 +95,12 @@ every session or only the active tab.
|
|||||||
| Interface Language | English or Simplified Chinese. Per device. |
|
| 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). |
|
| 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. |
|
| 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. |
|
| Tall Tabs | Taller tab strip. |
|
||||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
| 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). |
|
| 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. |
|
| Overview Home Screen | The phone home screen. On by default. |
|
||||||
|
|
||||||
@@ -127,13 +138,19 @@ 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). |
|
| 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. |
|
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
Master toggle, browser notifications, push subscription, audio alerts, how long a
|
||||||
|
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
|
||||||
|
how long a desktop notification stays up before Codeman closes it (**Browser
|
||||||
|
notification display time**, default 8; both per device, and your OS may close a
|
||||||
|
desktop notification sooner), the idle
|
||||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||||
[Notifications And Approvals](Notifications-And-Approvals).
|
[Notifications And Approvals](Notifications-And-Approvals).
|
||||||
@@ -152,7 +169,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
`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
|
`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
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
mode, the **Users** administration entry is injected here.
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Most of Codeman's UI is **opt-in**. A stock install shows a deliberately small h
|
|||||||
feature you read about here may simply not be on screen yet. Where that is the case, this
|
feature you read about here may simply not be on screen yet. Where that is the case, this
|
||||||
page says so and names the setting.
|
page says so and names the setting.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -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, 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. |
|
| **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. |
|
| **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. |
|
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
||||||
|
|
||||||
## Session list layout
|
## Session list layout
|
||||||
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
|||||||
|
|
||||||
| Layout | Behaviour |
|
| 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. |
|
| **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. |
|
| **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
|
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.
|
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
|
## Session tabs
|
||||||
|
|
||||||
One tab per session, in your order, and that order syncs across your devices.
|
One tab per session, in your order, and that order syncs across your devices.
|
||||||
@@ -64,7 +98,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||||
| Close | `Ctrl+W` |
|
| Close | The tab's close control (no key by default) |
|
||||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
Tabs can also be dragged to reorder.
|
||||||
@@ -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
|
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.
|
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
|
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
|
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||||
how a fan-out of eight workers stays readable.
|
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.
|
skipped for tabs scrolled out of the strip.
|
||||||
|
|
||||||
## Header controls
|
## 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. |
|
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
| 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. |
|
| File Viewer | On | Toggles the file browser panel. |
|
||||||
| Settings gear | Always on | App Settings. |
|
| 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. |
|
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||||
@@ -118,9 +157,23 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Cron ⏰ | Off | Scheduled jobs. |
|
| Cron ⏰ | Off | Scheduled jobs. |
|
||||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
| 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. |
|
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||||
|
| 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. |
|
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||||
| Admin panel | Multi-user only | User administration. |
|
| 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
|
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||||
covered in [Mobile Guide](Mobile-Guide).
|
covered in [Mobile Guide](Mobile-Guide).
|
||||||
|
|
||||||
@@ -159,13 +212,18 @@ Worth knowing:
|
|||||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||||
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
||||||
history; press **Load full history** to pull the rest explicitly. Automatic output
|
history; press **Load full history** to pull the rest explicitly. That notice only
|
||||||
|
appears once you scroll to the top, leaves when you scroll back down, and stays away
|
||||||
|
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
|
||||||
|
Claude) never show it, since there is nothing more to load. Automatic output
|
||||||
recovery stays within the bounded browser buffer.
|
recovery stays within the bounded browser buffer.
|
||||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
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/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
|
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.
|
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||||
`Ctrl+Shift+C` always copies.
|
`Ctrl+Shift+C` always copies.
|
||||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Tile Grid
|
||||||
|
|
||||||
|
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||||
|
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||||
|
its agent is working, idle, or waiting on you.
|
||||||
|
|
||||||
|
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
|
||||||
|
never offered in a popped-out session window.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
**App Settings → Header & Panels → Tiles.** This is a per-device setting, 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
|
||||||
|
|
||||||
|
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
|
||||||
|
(six until you choose; fewer if the window is too small or you have fewer sessions open).
|
||||||
|
You get the grid you last had, its tiles where they were, topped up with your open
|
||||||
|
sessions in tab order; if there is none, an open split's two first; otherwise your open
|
||||||
|
sessions in tab order, with the session you are on focused. With the grid open, the same
|
||||||
|
button closes it.
|
||||||
|
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
|
||||||
|
count it opens and what a click and a right-click do.
|
||||||
|
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
|
||||||
|
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
|
||||||
|
and is what the next click opens. With the grid open, picking a count re-forms it: the
|
||||||
|
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
|
||||||
|
order. A count the window is too small for is greyed out, with the reason.
|
||||||
|
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
|
||||||
|
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
|
||||||
|
the grid closed it opens what the Tiles button would show, with that session among them
|
||||||
|
(still the count you chose in total). On macOS use
|
||||||
|
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
|
||||||
|
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
|
||||||
|
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
|
||||||
|
another tile swaps the two.
|
||||||
|
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
|
||||||
|
- **Run**: a session you start from this browser tab's Run button while the grid is open
|
||||||
|
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||||
|
|
||||||
|
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||||
|
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||||
|
window is too small for six; the count menu says which limit applies.
|
||||||
|
|
||||||
|
Opening, the tiles fade in one after another and each terminal appears once its history
|
||||||
|
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
|
||||||
|
on screen, dimmed, until the single session behind them has loaded, then fade away. With
|
||||||
|
reduced motion turned on in your system settings, the grid opens and closes at once.
|
||||||
|
|
||||||
|
## A tile
|
||||||
|
|
||||||
|
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||||
|
|
||||||
|
| Part | What it does |
|
||||||
|
| ------ | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||||
|
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||||
|
| name | Double-click to rename the session. |
|
||||||
|
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. 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. |
|
||||||
|
|
||||||
|
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
|
||||||
|
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
|
||||||
|
voice and image paste. Tabs of tiled sessions carry a small underline.
|
||||||
|
|
||||||
|
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
|
||||||
|
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
|
||||||
|
one on its own until the window is big enough again.
|
||||||
|
|
||||||
|
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
|
||||||
|
whose agent exited inside its pane says so instead; close that session from `⋯`.
|
||||||
|
|
||||||
|
## Moving tiles
|
||||||
|
|
||||||
|
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||||
|
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
|
||||||
|
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
|
||||||
|
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
|
||||||
|
header focuses its tile when you click it, not when you press it.
|
||||||
|
|
||||||
|
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
|
||||||
|
down: into the empty slot if that is the place, else trading places with the tile there. It
|
||||||
|
keeps the focus.
|
||||||
|
|
||||||
|
A moved tile takes the size of the place it lands in: column widths and row heights stay
|
||||||
|
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
|
||||||
|
the empty slot included, is saved with the grid and comes back on reload.
|
||||||
|
|
||||||
|
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
|
||||||
|
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
|
||||||
|
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
|
||||||
|
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `Ctrl+Shift+G` | Open or close the grid. |
|
||||||
|
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||||
|
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
|
||||||
|
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||||
|
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
|
||||||
|
| `Ctrl+L` | Clear the focused tile. |
|
||||||
|
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
|
||||||
|
|
||||||
|
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||||
|
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||||
|
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||||
|
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||||
|
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||||
|
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||||
|
|
||||||
|
## Leaving the grid
|
||||||
|
|
||||||
|
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
|
||||||
|
session finder) shows that session on its own, the normal single view. The grid is
|
||||||
|
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
|
||||||
|
same. Narrowing the window below the desktop width also returns to the single view.
|
||||||
|
|
||||||
|
The grid is saved on this device and comes back when you reload the page, with its focus,
|
||||||
|
zoom and column widths. A session that was closed in the meantime is simply left out.
|
||||||
|
|
||||||
|
Split shows the same logo, name and model above both of its panes.
|
||||||
|
|
||||||
|
The grid and Split are never open together: opening the grid turns an open split into two
|
||||||
|
tiles, and Split is unavailable while the grid is open.
|
||||||
|
|
||||||
|
## Read next
|
||||||
|
|
||||||
|
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
|
||||||
|
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
|
||||||
|
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
|
||||||
@@ -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
|
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.
|
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
|
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||||
whole sessions started by an agent through the API.
|
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.
|
for tabs scrolled out of view.
|
||||||
|
|
||||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
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
|
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.
|
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
|
Teammates are in-process threads rather than separate CLI processes, so they show up as
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| 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. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
| Anything else | Download. |
|
| 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 →
|
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
|
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
|
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:
|
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
|
**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.
|
note instead, and a diff over 400 KB is cut short.
|
||||||
- A session folder that holds several projects gets one collapsible section per repository
|
- 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
|
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.
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
**Using it**
|
**Using it**
|
||||||
|
|
||||||
- [The Dashboard](The-Dashboard)
|
- [The Dashboard](The-Dashboard)
|
||||||
|
- [Tile Grid](Tile-Grid)
|
||||||
- [Agent CLIs](Agent-CLIs)
|
- [Agent CLIs](Agent-CLIs)
|
||||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||||
- [Working With Files](Working-With-Files)
|
- [Working With Files](Working-With-Files)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -129,6 +129,8 @@
|
|||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"exceljs": "4.4.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.58.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Extracted from the package.json one-liner for readability and debuggability.
|
* Extracted from the package.json one-liner for readability and debuggability.
|
||||||
*
|
*
|
||||||
* Steps:
|
* Steps:
|
||||||
|
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
|
||||||
* 1. TypeScript compilation
|
* 1. TypeScript compilation
|
||||||
* 2. Copy static assets (web/public, templates)
|
* 2. Copy static assets (web/public, templates)
|
||||||
* 3. Build vendor xterm bundles
|
* 3. Build vendor xterm bundles
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
import { createRequire } from 'module';
|
||||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -25,6 +27,31 @@ function run(label, cmd) {
|
|||||||
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
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
|
// 1. TypeScript compilation
|
||||||
run('tsc', 'tsc');
|
run('tsc', 'tsc');
|
||||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
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-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-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');
|
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)`
|
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||||
appendFileSync(
|
appendFileSync(
|
||||||
@@ -129,6 +159,7 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'api-client.js',
|
'api-client.js',
|
||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
'image-input.js',
|
'image-input.js',
|
||||||
|
'spreadsheet-preview.js',
|
||||||
'vendor/xterm-zerolag-input.js',
|
'vendor/xterm-zerolag-input.js',
|
||||||
'vendor/xterm-predictive-echo.js',
|
'vendor/xterm-predictive-echo.js',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
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 { dirname, extname, join, relative, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 publicRoot = resolve(repoRoot, 'src/web/public');
|
||||||
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||||
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
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) {
|
function collectTextAssets(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
|
|||||||
const files = collectTextAssets(publicRoot);
|
const files = collectTextAssets(publicRoot);
|
||||||
const failures = [];
|
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) {
|
for (const file of files) {
|
||||||
const rel = relative(repoRoot, file);
|
const rel = relative(repoRoot, file);
|
||||||
const data = readFileSync(file);
|
const data = readFileSync(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
|
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|||||||
*/
|
*/
|
||||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
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([
|
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
'jpeg',
|
'jpeg',
|
||||||
'gif',
|
'gif',
|
||||||
'webp',
|
'webp',
|
||||||
'pdf',
|
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||||
'docx',
|
|
||||||
'pptx',
|
|
||||||
'md',
|
'md',
|
||||||
'txt',
|
'txt',
|
||||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||||
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
|
|||||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||||
if (normalized === 'pdf') return 'pdf';
|
if (normalized === 'pdf') return 'pdf';
|
||||||
if (normalized === 'pptx') return 'presentation';
|
if (normalized === 'pptx') return 'presentation';
|
||||||
|
if (normalized === 'xlsx') return 'spreadsheet';
|
||||||
if (normalized === 'md') return 'markdown';
|
if (normalized === 'md') return 'markdown';
|
||||||
// Everything else in the text family reads as text, including code and
|
// 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.
|
// config: the card and the preview both treat it as a plain-text file.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
|
|||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.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 { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||||
@@ -111,7 +111,11 @@ program
|
|||||||
.action(async (filePath, options) => {
|
.action(async (filePath, options) => {
|
||||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many capture groups a regex source declares (named ones included), or -1 when it
|
||||||
|
* does not compile. Matching the empty string against `source|` always succeeds through
|
||||||
|
* the empty alternative, and the match array then has one slot per group.
|
||||||
|
*/
|
||||||
|
export function countCaptureGroups(source: string): number {
|
||||||
|
try {
|
||||||
|
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
|
||||||
|
} catch {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||||
import type { McpConfigFormat } 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. */
|
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||||
const cliId = z
|
const cliId = z
|
||||||
@@ -289,7 +289,7 @@ const capabilitiesSchema = z
|
|||||||
requiresMux: z.boolean(),
|
requiresMux: z.boolean(),
|
||||||
hooks: z.enum(['none', 'always', 'supervised']),
|
hooks: z.enum(['none', 'always', 'supervised']),
|
||||||
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
|
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,
|
echo: echoSchema,
|
||||||
wheelForward: z
|
wheelForward: z
|
||||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||||
@@ -370,6 +370,56 @@ const capabilitiesSchema = z
|
|||||||
model: z
|
model: z
|
||||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||||
.strict(),
|
.strict(),
|
||||||
|
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
|
||||||
|
// over the foot of a pane capture every time a session settles. Exactly one capture
|
||||||
|
// group (the model), checked here so a pattern without one fails at LOAD time instead
|
||||||
|
// of silently never naming a model.
|
||||||
|
modelDetect: z
|
||||||
|
.object({
|
||||||
|
screenLine: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
|
||||||
|
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||||
|
// itself may be able to write. 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.
|
||||||
|
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
// Typos rather than configurations, refused at LOAD time like watchingLines.
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
|
||||||
|
'modelDetect declares nothing to read'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.screenLines === undefined || v.screenLine !== undefined,
|
||||||
|
'screenLines has nothing to bound without a screenLine'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(v) => v.rejectWords === undefined || v.screenLine !== undefined,
|
||||||
|
'rejectWords has nothing to filter without a screenLine'
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// 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
|
privilegedParams: z
|
||||||
.array(
|
.array(
|
||||||
z
|
z
|
||||||
@@ -588,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;
|
const { setenvProfile } = entry.env;
|
||||||
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|||||||
@@ -491,8 +491,45 @@ const OPENCODE: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-and-mouse',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
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`.
|
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||||
mcpConfig: {
|
mcpConfig: {
|
||||||
path: '.config/opencode/opencode.json',
|
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
|
// 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
|
// the terminal starts, follows the composer down as the conversation grows, and is
|
||||||
// gone after `/stop`.
|
// 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
|
// ⚠️ 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,
|
// 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
|
// 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.
|
// 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
|
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
|
||||||
@@ -620,8 +662,37 @@ const CODEX: CliEntry = {
|
|||||||
promptGlyph: '›',
|
promptGlyph: '›',
|
||||||
workingLine: '[Ee]sc to interrupt',
|
workingLine: '[Ee]sc to interrupt',
|
||||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
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 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`(?:^|\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 `•`/`›`/`⚠`
|
// 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
|
// 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,
|
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||||
@@ -744,6 +815,28 @@ const GEMINI: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-full',
|
altScreen: 'strip-full',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
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
|
// 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
|
// 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.
|
// sends no geminiConfig at all would still get yolo for free.
|
||||||
@@ -925,6 +1018,37 @@ const PI: CliEntry = {
|
|||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
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
|
// 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
|
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
|
||||||
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
|
||||||
@@ -1042,8 +1166,9 @@ const GROK: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
// Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity:
|
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
|
||||||
// only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// (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',
|
altScreen: 'strip-mux-only',
|
||||||
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
||||||
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
||||||
@@ -1222,6 +1347,35 @@ const DEEPSEEK: CliEntry = {
|
|||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
// Model is NOT a session field for dsh — it is a profile composition entry.
|
// Model is NOT a session field for dsh — it is a profile composition entry.
|
||||||
model: { source: 'none' },
|
model: { source: 'none' },
|
||||||
|
// So the screen is where the model is known: dsh-TUI resolves the route itself
|
||||||
|
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
|
||||||
|
// lib/types/modelRoute.js) and its status line draws "the route requests actually
|
||||||
|
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
|
||||||
|
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
|
||||||
|
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
|
||||||
|
// The border anchors it: nothing the agent writes can sit below the composer, and a
|
||||||
|
// suggestion popup there starts with `/` or `+`, never a model id.
|
||||||
|
// ⚠ The first field is the model only while the status bar's model field is on (the
|
||||||
|
// default). Switched off, the first field is the next one (StatusLine.js): tokens per
|
||||||
|
// second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match,
|
||||||
|
// then the reasoning effort (` medium · th-config`, measured live), then the session
|
||||||
|
// mode, then the cwd's basename. So `rejectWords` lists what those can be, from the
|
||||||
|
// dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's
|
||||||
|
// THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode
|
||||||
|
// ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token,
|
||||||
|
// and a field equal to the session's folder name is refused by the shared reader.
|
||||||
|
// Known gaps, all off by default: a custom mode id drawn raw, a git branch or a
|
||||||
|
// one-word session title as the first field; and the non-compact layout, whose
|
||||||
|
// left/right justification never ends a field with ` · `, so nothing is read there
|
||||||
|
// and the session shows its route config.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
|
||||||
|
screenLines: 3,
|
||||||
|
rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'],
|
||||||
|
// With the status bar's model field off (or before it paints), the route the
|
||||||
|
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
|
||||||
|
configResolver: 'deepseek-route',
|
||||||
|
},
|
||||||
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
|
||||||
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
// launcher's own default, `workspace-write`, which already asks. Clamping to
|
||||||
// `read-only` instead would break the workspace rather than protect it.
|
// `read-only` instead would break the workspace rather than protect it.
|
||||||
@@ -1347,13 +1501,27 @@ const OMP: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...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.
|
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
||||||
// makes an omp conversation survive a full session kill.
|
// makes an omp conversation survive a full session kill.
|
||||||
transcript: 'omp-jsonl',
|
transcript: 'omp-jsonl',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
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
|
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
|
||||||
// whole privileged surface here is env-shaped.
|
// whole privileged surface here is env-shaped.
|
||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ export interface CliVariant {
|
|||||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
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. */
|
/** 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';
|
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||||
|
|
||||||
@@ -362,8 +373,8 @@ export interface CliCapabilities {
|
|||||||
/**
|
/**
|
||||||
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
* 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
|
* 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
|
* its own above the composer, which puts it third or fourth from the bottom (its hint
|
||||||
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
* 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
|
* another row an agent might be able to write, and the label is what silences an
|
||||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||||
*/
|
*/
|
||||||
@@ -436,11 +447,40 @@ export interface CliCapabilities {
|
|||||||
*/
|
*/
|
||||||
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
||||||
/**
|
/**
|
||||||
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
|
* What the server strips from this CLI's output stream before the browser sees it.
|
||||||
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
|
* The value encodes three independent choices (predicates in session.ts):
|
||||||
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
|
*
|
||||||
|
* | 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: {
|
echo: {
|
||||||
policy: 'buffer' | 'predict' | 'off';
|
policy: 'buffer' | 'predict' | 'off';
|
||||||
/** How the local-echo overlay locates the composer row. */
|
/** How the local-echo overlay locates the composer row. */
|
||||||
@@ -462,6 +502,55 @@ export interface CliCapabilities {
|
|||||||
statusLineTelemetry: boolean;
|
statusLineTelemetry: boolean;
|
||||||
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
|
||||||
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
|
||||||
|
/**
|
||||||
|
* Where this CLI draws the model it is running, so a session header can name it
|
||||||
|
* (`SessionState.displayModel`, src/session-display-model.ts).
|
||||||
|
*
|
||||||
|
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
|
||||||
|
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
|
||||||
|
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
|
||||||
|
* extra tmux call and re-reads the footer at every turn transition, so an in-session
|
||||||
|
* `/model` switch is followed.
|
||||||
|
*
|
||||||
|
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
|
||||||
|
* on chrome only this CLI draws (the row under its own composer, an effort word in its
|
||||||
|
* own footer format), never on a shape the agent could print in its transcript. Measured
|
||||||
|
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
|
||||||
|
* the session shows its launch model, if any.
|
||||||
|
*
|
||||||
|
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
|
||||||
|
* model the CLI's own config pins, the way that CLI resolves it for the session, for
|
||||||
|
* while the screen names none (its status line switched off, or not drawn yet). Read
|
||||||
|
* once per pane start, attach or relaunch, bounded and read-only; the screen still
|
||||||
|
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
|
||||||
|
* per-CLI behaviour stays data here and code in one module.
|
||||||
|
*/
|
||||||
|
modelDetect?: {
|
||||||
|
screenLine?: string;
|
||||||
|
screenLines?: number;
|
||||||
|
/**
|
||||||
|
* Words the `screenLine` field can show when it is NOT the model (a footer whose model
|
||||||
|
* field is switched off shows the next field there), compared lower-cased. A field
|
||||||
|
* equal to the session's own working-directory basename is never the model either,
|
||||||
|
* for every CLI; that rule is the shared reader's, not data.
|
||||||
|
*/
|
||||||
|
rejectWords?: string[];
|
||||||
|
configResolver?: ModelConfigResolverName;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 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.
|
* 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.
|
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The model a DeepSeek Harness (`dsh`) session's TUI is configured to
|
||||||
|
* use, read from its route config, for a session header whose screen names no model
|
||||||
|
* yet (the status bar's model field switched off, or not drawn yet). See
|
||||||
|
* `SessionState.displayModel` (src/session-display-model.ts): the screen still wins
|
||||||
|
* whenever it names a model, since it is what the running TUI actually uses.
|
||||||
|
*
|
||||||
|
* ## How dsh-TUI resolves its route (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1)
|
||||||
|
*
|
||||||
|
* A profile is a stack of loader patch layers over an empty root, in this order
|
||||||
|
* (`@deepseek-ai/dsh` profile-boot): every bundle's patch layer, the profile's own
|
||||||
|
* `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the home-level
|
||||||
|
* `$DSH_HOME/cordis.patch.yml` (it outranks the profile layer), then `--patch`
|
||||||
|
* overlays (Codeman passes none). A patch targets a row by `id`; one whose `name`
|
||||||
|
* does not match the row's is skipped; every other key REPLACES the row's field
|
||||||
|
* whole (`applyEntryPatches`), so the last layer carrying `config` for the `dsh-tui`
|
||||||
|
* row defines all of it.
|
||||||
|
*
|
||||||
|
* dsh-TUI then takes its model route from that config only when it names BOTH
|
||||||
|
* `provider` and `model` (`lib/types/modelRoute.js`, issue #67). Anything less is
|
||||||
|
* dropped whole and the TUI falls back to the persisted `/model` choice, then to its
|
||||||
|
* own default: neither is in the config, so neither is answered here. The bundle's
|
||||||
|
* own row pins `provider: deepseek-official` alone, by design, so only the two user
|
||||||
|
* layers can pin a route; the bundle layers are not read (they resolve through the dsh
|
||||||
|
* installation, outside the dsh home). `settings.yaml`'s `agent-default-model` is the
|
||||||
|
* HEADLESS default, not the TUI's, and is never read.
|
||||||
|
*
|
||||||
|
* ## Answer nothing rather than a guess
|
||||||
|
*
|
||||||
|
* Every doubt answers null: a profile that does not compose dsh-TUI, a half-pinned
|
||||||
|
* route, a layer that cannot be read (unreadable, a symlink out of the dsh home, too
|
||||||
|
* big, a mount that does not answer), and a file this reader does not fully
|
||||||
|
* understand. The YAML reader below is deliberately narrow (the repo carries no YAML
|
||||||
|
* dependency): a top-level block sequence of patch items, plain keys, single-line
|
||||||
|
* plain or quoted string scalars for the values it needs, and null for anything else
|
||||||
|
* that could change the answer (an anchor, alias or tag such as `!!js` on such a value,
|
||||||
|
* a merge key, a multi-line scalar, flow or block-scalar config, duplicate keys, a
|
||||||
|
* scalar YAML would type as a number, boolean or null, a second document, a nested
|
||||||
|
* row redefining dsh-TUI).
|
||||||
|
*
|
||||||
|
* ## Never block, never write, never leak
|
||||||
|
*
|
||||||
|
* Every path is probed with the bounded `probePathKind()` before it is touched, read
|
||||||
|
* asynchronously with a size cap, and must resolve (realpath) inside the dsh home.
|
||||||
|
* Nothing is written. Only the model id leaves this module: never the provider, a
|
||||||
|
* base URL, a key or any other config value.
|
||||||
|
*
|
||||||
|
* Tests: `test/deepseek-route-config.test.ts`.
|
||||||
|
*
|
||||||
|
* @module deepseek-route-config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||||
|
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||||
|
import {
|
||||||
|
deepSeekProfileFromManifest,
|
||||||
|
isProfileDirName,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
type DeepSeekProfile,
|
||||||
|
} from './utils/deepseek-cli-resolver.js';
|
||||||
|
|
||||||
|
/** The dsh-TUI bundle a profile must compose for its route to be read here. */
|
||||||
|
export const DSH_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||||
|
/** The loader row dsh-TUI's own config lives on. */
|
||||||
|
export const DSH_TUI_ROW_ID = 'dsh-tui';
|
||||||
|
/** Largest file read: a patch layer is a few dozen lines. */
|
||||||
|
export const MAX_ROUTE_FILE_BYTES = 64 * 1024;
|
||||||
|
/** A profile name as the launch accepts it (the `path-segment` token pattern). */
|
||||||
|
const PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||||
|
/** How many profile directories the default-profile inventory looks at. */
|
||||||
|
const MAX_PROFILES = 64;
|
||||||
|
|
||||||
|
/** What one patch item does to the dsh-TUI row. */
|
||||||
|
export interface DshTuiRowPatch {
|
||||||
|
/** `name` on the patch; a mismatch makes dsh skip it. */
|
||||||
|
name?: string;
|
||||||
|
/** `disabled` on the patch, when present. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* `config` on the patch, when present: the provider and model it names (absent when
|
||||||
|
* it does not name one), or `{}` for a config that is empty or not a mapping.
|
||||||
|
*/
|
||||||
|
config?: { provider?: string; model?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown inside the parser for anything it does not fully understand. */
|
||||||
|
class Ambiguous extends Error {}
|
||||||
|
|
||||||
|
/** A nested line naming the dsh-TUI row: a group's config can re-define the row through it. */
|
||||||
|
const ROW_ID_LINE = /^(?:-\s+)?id:\s*(['"]?)dsh-tui\1\s*$/;
|
||||||
|
|
||||||
|
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:\s+(.*))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a line's comment (`#` at line start or after whitespace, outside quotes) and
|
||||||
|
* trailing blanks. Throws on an unterminated quote: a multi-line flow scalar is
|
||||||
|
* beyond this reader.
|
||||||
|
*/
|
||||||
|
function stripComment(line: string): string {
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (quote === "'") {
|
||||||
|
if (c === "'") {
|
||||||
|
if (line[i + 1] === "'") i++;
|
||||||
|
else quote = null;
|
||||||
|
}
|
||||||
|
} else if (quote === '"') {
|
||||||
|
if (c === '\\') i++;
|
||||||
|
else if (c === '"') quote = null;
|
||||||
|
} else if (c === "'" || c === '"') {
|
||||||
|
// A quote opens a scalar only at its start; inside a plain scalar it is a character.
|
||||||
|
const prev = line.slice(0, i).trimEnd();
|
||||||
|
if (prev === '' || /[:\-[{,]$/.test(prev)) quote = c;
|
||||||
|
} else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||||
|
return line.slice(0, i).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quote) throw new Ambiguous('unterminated quote');
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indentation of a line; a tab in it is refused (YAML forbids tabs there). */
|
||||||
|
function indentOf(line: string): number {
|
||||||
|
const m = /^[ \t]*/.exec(line)![0];
|
||||||
|
if (m.includes('\t')) throw new Ambiguous('tab indentation');
|
||||||
|
return m.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-line scalar as a string: plain, or single/double-quoted. Throws on anything
|
||||||
|
* that is not plainly a string (a tag, an anchor, an alias, a flow collection, a
|
||||||
|
* block scalar, or a plain scalar YAML would type as null, a boolean or a number).
|
||||||
|
*/
|
||||||
|
function stringScalar(raw: string): string {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (v.startsWith("'")) {
|
||||||
|
const m = /^'((?:[^']|'')*)'$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/''/g, "'");
|
||||||
|
}
|
||||||
|
if (v.startsWith('"')) {
|
||||||
|
const m = /^"((?:[^"\\]|\\["\\/])*)"$/.exec(v);
|
||||||
|
if (!m) throw new Ambiguous('quoted scalar');
|
||||||
|
return m[1].replace(/\\(["\\/])/g, '$1');
|
||||||
|
}
|
||||||
|
if (v === '' || /^[!&*[\]{}|>%@`,?:-]/.test(v)) throw new Ambiguous('not a plain string');
|
||||||
|
if (/^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/.test(v)) throw new Ambiguous('typed scalar');
|
||||||
|
if (
|
||||||
|
/^[-+]?(?:\.\d+|\d[\d_]*(?:\.\d*)?)(?:[eE][-+]?\d+)?$|^0[xob][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/.test(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Ambiguous('numeric scalar');
|
||||||
|
}
|
||||||
|
if (/\s#|:\s/.test(v)) throw new Ambiguous('plain scalar with an indicator');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `true`/`false` as YAML spells them, or a throw. */
|
||||||
|
function boolScalar(raw: string): boolean {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (/^(?:true|True|TRUE)$/.test(v)) return true;
|
||||||
|
if (/^(?:false|False|FALSE)$/.test(v)) return false;
|
||||||
|
throw new Ambiguous('not a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
indent: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct keys of a block mapping whose lines all sit at `indent` or deeper, each
|
||||||
|
* with its inline value and the lines nested under it. Throws on a line that is not a
|
||||||
|
* key at the mapping's indent, and on a duplicate key (js-yaml refuses those, so dsh
|
||||||
|
* would not boot).
|
||||||
|
*/
|
||||||
|
function mappingKeys(lines: Line[], indent: number): Map<string, { inline: string | undefined; nested: Line[] }> {
|
||||||
|
const keys = new Map<string, { inline: string | undefined; nested: Line[] }>();
|
||||||
|
let current: { inline: string | undefined; nested: Line[] } | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent > indent) {
|
||||||
|
if (!current) throw new Ambiguous('nested line with no key');
|
||||||
|
current.nested.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.indent < indent) throw new Ambiguous('dedent inside a mapping');
|
||||||
|
const m = KEY_LINE.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous(`not a key: ${line.text.slice(0, 20)}`);
|
||||||
|
if (keys.has(m[1])) throw new Ambiguous('duplicate key');
|
||||||
|
current = { inline: m[2] === undefined || m[2] === '' ? undefined : m[2], nested: [] };
|
||||||
|
keys.set(m[1], current);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item this reader cannot follow is certainly about another row: a plain
|
||||||
|
* `id:` at the item's indent naming a row other than dsh-TUI, no `insert:` and no
|
||||||
|
* mention of the dsh-TUI row anywhere in it.
|
||||||
|
*/
|
||||||
|
function isUnrelatedItem(item: Line[]): boolean {
|
||||||
|
const indent = item[0].indent;
|
||||||
|
const top = item.filter((l) => l.indent === indent);
|
||||||
|
if (top.some((l) => /^insert\s*:/.test(l.text))) return false;
|
||||||
|
const ids = top.map((l) => KEY_LINE.exec(l.text)).filter((m) => m?.[1] === 'id');
|
||||||
|
if (ids.length !== 1 || ids[0]![2] === undefined) return false;
|
||||||
|
try {
|
||||||
|
return stringScalar(ids[0]![2]) !== DSH_TUI_ROW_ID;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `config` of a dsh-TUI patch: its provider and model, if it names them. */
|
||||||
|
function configOf(entry: { inline: string | undefined; nested: Line[] }): { provider?: string; model?: string } {
|
||||||
|
if (entry.inline !== undefined) {
|
||||||
|
if (entry.nested.length) throw new Ambiguous('config with both an inline value and nested lines');
|
||||||
|
const v = entry.inline.trim();
|
||||||
|
// An empty flow mapping or a null names no route; anything else inline (a tag, a
|
||||||
|
// non-empty flow mapping, a block scalar) is beyond this reader.
|
||||||
|
if (v === '{}' || /^(?:~|null|Null|NULL)$/.test(v)) return {};
|
||||||
|
throw new Ambiguous('inline config');
|
||||||
|
}
|
||||||
|
if (!entry.nested.length) return {};
|
||||||
|
const keys = mappingKeys(entry.nested, entry.nested[0].indent);
|
||||||
|
const out: { provider?: string; model?: string } = {};
|
||||||
|
for (const field of ['provider', 'model'] as const) {
|
||||||
|
const value = keys.get(field);
|
||||||
|
if (!value) continue;
|
||||||
|
if (value.nested.length || value.inline === undefined) throw new Ambiguous(`${field} is not a single-line scalar`);
|
||||||
|
out[field] = stringScalar(value.inline);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a cordis patch-list file (a top-level YAML array of loader patches) does to the
|
||||||
|
* dsh-TUI row, in order. An empty list when the file does not touch it. Null when the
|
||||||
|
* file is beyond this reader's subset, or when it could re-insert the row.
|
||||||
|
*
|
||||||
|
* @param text the file's content
|
||||||
|
*/
|
||||||
|
export function parseDshTuiPatches(text: string): DshTuiRowPatch[] | null {
|
||||||
|
try {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let sawContent = false;
|
||||||
|
for (const rawLine of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
||||||
|
const stripped = stripComment(rawLine);
|
||||||
|
if (stripped.trim() === '') continue;
|
||||||
|
const indent = indentOf(stripped);
|
||||||
|
const body = stripped.slice(indent);
|
||||||
|
if (indent === 0 && body === '---') {
|
||||||
|
if (sawContent) throw new Ambiguous('a second document');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sawContent = true;
|
||||||
|
lines.push({ indent, text: body });
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
if (lines.length === 1 && lines[0].indent === 0 && lines[0].text === '[]') return [];
|
||||||
|
|
||||||
|
// Split the top-level block sequence into items.
|
||||||
|
const items: Line[][] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.indent === 0) {
|
||||||
|
const m = /^-(?:(\s+)(.*))?$/.exec(line.text);
|
||||||
|
if (!m) throw new Ambiguous('not a top-level sequence');
|
||||||
|
const item: Line[] = [];
|
||||||
|
// `- key: value`: the key sits at its real column, which its siblings below share.
|
||||||
|
if (m[2] !== undefined && m[2] !== '') item.push({ indent: 1 + m[1].length, text: m[2] });
|
||||||
|
items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (items.length === 0) throw new Ambiguous('indented content before the first item');
|
||||||
|
items[items.length - 1].push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patches: DshTuiRowPatch[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.length === 0) throw new Ambiguous('empty item');
|
||||||
|
// The first key's column is the item's indent; every key shares it.
|
||||||
|
let keys: ReturnType<typeof mappingKeys>;
|
||||||
|
try {
|
||||||
|
keys = mappingKeys(item, item[0].indent);
|
||||||
|
} catch (err) {
|
||||||
|
// An item this reader cannot follow is harmless only when it provably is about
|
||||||
|
// another row: its own id names one, and no line in it names the dsh-TUI row.
|
||||||
|
if (!(err instanceof Ambiguous) || !isUnrelatedItem(item)) throw err;
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = keys.get('id');
|
||||||
|
if (keys.has('insert')) {
|
||||||
|
// An insert that could bring a second dsh-TUI row is beyond this reader.
|
||||||
|
const body = item.map((l) => l.text).join('\n');
|
||||||
|
if (body.includes(DSH_TUI_ROW_ID)) throw new Ambiguous('insert mentioning the dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!id) continue; // dsh warns and skips a non-insert patch without an id
|
||||||
|
if (id.nested.length || id.inline === undefined) throw new Ambiguous('id is not a scalar');
|
||||||
|
if (stringScalar(id.inline) !== DSH_TUI_ROW_ID) {
|
||||||
|
// Another row; but a group row's config is a list of rows, and one of them could
|
||||||
|
// be a second dsh-TUI row (dsh indexes nested group entries by id too).
|
||||||
|
if (item.slice(1).some((l) => ROW_ID_LINE.test(l.text))) throw new Ambiguous('a nested dsh-tui row');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const patch: DshTuiRowPatch = {};
|
||||||
|
const name = keys.get('name');
|
||||||
|
if (name) {
|
||||||
|
if (name.nested.length || name.inline === undefined) throw new Ambiguous('name is not a scalar');
|
||||||
|
patch.name = stringScalar(name.inline);
|
||||||
|
}
|
||||||
|
const disabled = keys.get('disabled');
|
||||||
|
if (disabled) {
|
||||||
|
if (disabled.nested.length || disabled.inline === undefined) throw new Ambiguous('disabled is not a scalar');
|
||||||
|
patch.disabled = boolScalar(disabled.inline);
|
||||||
|
}
|
||||||
|
const config = keys.get('config');
|
||||||
|
if (config) patch.config = configOf(config);
|
||||||
|
patches.push(patch);
|
||||||
|
}
|
||||||
|
return patches;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Ambiguous) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model dsh-TUI's route config pins, given what the user layers do to its row in
|
||||||
|
* application order (profile layer first, then the home layer). Null unless the last
|
||||||
|
* `config` that applies names both a provider and a model, and the row is not
|
||||||
|
* disabled. Pure.
|
||||||
|
*
|
||||||
|
* @param layers each layer's patches for the row, or null for a layer that could not be read
|
||||||
|
*/
|
||||||
|
export function resolveDshTuiRouteModel(layers: Array<DshTuiRowPatch[] | null>): string | null {
|
||||||
|
let config: { provider?: string; model?: string } | undefined;
|
||||||
|
let disabled = false;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer === null) return null;
|
||||||
|
for (const patch of layer) {
|
||||||
|
if (patch.name !== undefined && patch.name !== DSH_TUI_PACKAGE) continue;
|
||||||
|
if (patch.disabled !== undefined) disabled = patch.disabled;
|
||||||
|
if (patch.config !== undefined) config = patch.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled || !config?.provider || !config.model) return null;
|
||||||
|
return config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file under the dsh home, read only if it provably is one; see {@link readHomeFile}. */
|
||||||
|
type FileRead = { state: 'absent' } | { state: 'read'; text: string } | { state: 'refused' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `path`, which must resolve inside `realHome`, bounded: probed first (a mount
|
||||||
|
* that does not answer is refused, never waited on), its real path checked against
|
||||||
|
* the dsh home (a symlink out of it is refused), size-capped.
|
||||||
|
*/
|
||||||
|
async function readHomeFile(path: string, realHome: string): Promise<FileRead> {
|
||||||
|
const kind = await probePathKind(path);
|
||||||
|
if (kind === 'absent') return { state: 'absent' };
|
||||||
|
if (kind !== 'file') return { state: 'refused' };
|
||||||
|
try {
|
||||||
|
const real = await fs.realpath(path);
|
||||||
|
if (!real.startsWith(realHome + sep)) return { state: 'refused' };
|
||||||
|
const stat = await fs.stat(real);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_ROUTE_FILE_BYTES) return { state: 'refused' };
|
||||||
|
return { state: 'read', text: await fs.readFile(real, 'utf8') };
|
||||||
|
} catch {
|
||||||
|
return { state: 'refused' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dsh home a session runs against: its own `DSH_HOME` (already clamped for a
|
||||||
|
* non-granted owner), else the server's, else `~/.dsh`, as the `dsh` wrapper's
|
||||||
|
* `${DSH_HOME:-...}` resolves it. Null for a relative value, which names no place.
|
||||||
|
*/
|
||||||
|
export function effectiveDshHome(env: (key: string) => string | undefined): string | null {
|
||||||
|
const value = env('DSH_HOME')?.trim();
|
||||||
|
if (!value) return join(homedir(), '.dsh');
|
||||||
|
return isAbsolute(value) ? resolve(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profiles under a dsh home, read with the same bounded rules as the route files.
|
||||||
|
* Used to name the profile a session boots when it named none, the way the launch's
|
||||||
|
* `launcherDefaultTarget` does (resolveDefaultDeepSeekProfile).
|
||||||
|
*/
|
||||||
|
async function listProfilesBounded(home: string): Promise<DeepSeekProfile[] | null> {
|
||||||
|
const profilesDir = join(home, 'profiles');
|
||||||
|
if ((await probePathKind(home)) !== 'directory' || (await probePathKind(profilesDir)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
names = (await fs.readdir(profilesDir, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name) && PROFILE_NAME.test(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, MAX_PROFILES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profiles: DeepSeekProfile[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const manifest = await readHomeFile(join(profilesDir, name, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') continue;
|
||||||
|
const profile = deepSeekProfileFromManifest(name, manifest.text);
|
||||||
|
if (profile) profiles.push(profile);
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the reader needs to know about one session. */
|
||||||
|
export interface DeepSeekRouteContext {
|
||||||
|
/** The session's `deepSeekConfig.profile`, if any. */
|
||||||
|
profile?: unknown;
|
||||||
|
/** The session's dsh home (see {@link effectiveDshHome}). */
|
||||||
|
home: string | null;
|
||||||
|
/** The server's own dsh home, which names the default profile (as the launch does). */
|
||||||
|
serverHome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the session's dsh-TUI route config pins, or null when it pins none or the
|
||||||
|
* answer is in any doubt. Read-only and bounded; see the module comment.
|
||||||
|
*/
|
||||||
|
export async function readDeepSeekRouteModel(ctx: DeepSeekRouteContext): Promise<string | null> {
|
||||||
|
const { home } = ctx;
|
||||||
|
if (!home) return null;
|
||||||
|
// An invalid name reads as unset at launch, so the default applies there too.
|
||||||
|
let profile = typeof ctx.profile === 'string' && PROFILE_NAME.test(ctx.profile) ? ctx.profile : null;
|
||||||
|
if (!profile) {
|
||||||
|
if (!ctx.serverHome) return null;
|
||||||
|
const listed = await listProfilesBounded(ctx.serverHome);
|
||||||
|
profile = listed ? resolveDefaultDeepSeekProfile(listed) : null;
|
||||||
|
if (!profile) return null;
|
||||||
|
}
|
||||||
|
if ((await probePathKind(home)) !== 'directory') return null;
|
||||||
|
let realHome: string;
|
||||||
|
try {
|
||||||
|
realHome = await fs.realpath(home);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profileDir = join(home, 'profiles', profile);
|
||||||
|
const manifest = await readHomeFile(join(profileDir, 'package.json'), realHome);
|
||||||
|
if (manifest.state !== 'read') return null;
|
||||||
|
if (!deepSeekProfileFromManifest(profile, manifest.text)?.bundles.includes(DSH_TUI_PACKAGE)) return null;
|
||||||
|
|
||||||
|
const layers: Array<DshTuiRowPatch[] | null> = [];
|
||||||
|
for (const file of [join(profileDir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')]) {
|
||||||
|
const read = await readHomeFile(file, realHome);
|
||||||
|
if (read.state === 'refused') return null;
|
||||||
|
layers.push(read.state === 'absent' ? [] : parseDshTuiPatches(read.text));
|
||||||
|
}
|
||||||
|
return resolveDshTuiRouteModel(layers);
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||||
* to the outer one, and is not scanned;
|
* to the outer one, and is not scanned;
|
||||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
* - 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
|
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
|
||||||
* symlinks), each reported separately;
|
* 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
|
* - 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.
|
* 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 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. */
|
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
/** Max file rows returned. The counts stay exact. */
|
/** 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. */
|
/** 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(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
// --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],
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
timeout: GIT_TIMEOUT_MS,
|
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
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) };
|
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;
|
let statusText: string;
|
||||||
try {
|
try {
|
||||||
statusText = await git(cwd, [
|
statusText = await git(
|
||||||
'status',
|
cwd,
|
||||||
'--porcelain=v2',
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
||||||
'--branch',
|
{ timeoutMs }
|
||||||
'-z',
|
);
|
||||||
'--untracked-files=normal',
|
|
||||||
'--ignore-submodules=dirty',
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
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> => {
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
return await git(cwd, args);
|
return await git(cwd, args, { timeoutMs });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -415,10 +415,12 @@ async function singleFlight<T>(
|
|||||||
*/
|
*/
|
||||||
export async function getGitWorkspaceStatus(
|
export async function getGitWorkspaceStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<GitWorkspaceStatus> {
|
): Promise<GitWorkspaceStatus> {
|
||||||
const git = opts.git ?? runGit;
|
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 };
|
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. */
|
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||||
function enclosingRepoRoot(
|
function enclosingRepoRoot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
|
||||||
): Promise<RepoToplevel> {
|
): Promise<RepoToplevel> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
try {
|
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' };
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
|
|||||||
const DISCOVERY_MAX_ENTRIES = 300;
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
/** Repositories reported for one workspace. */
|
/** Repositories reported for one workspace. */
|
||||||
export const MAX_REPOS = 12;
|
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. */
|
/** 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;
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
/** Folders that are never worth descending into when looking for projects. */
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
|
|||||||
reason?: 'remote' | 'docker';
|
reason?: 'remote' | 'docker';
|
||||||
error?: string;
|
error?: string;
|
||||||
repos: GitRepoEntry[];
|
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;
|
reposTruncated: boolean;
|
||||||
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
||||||
|
repoLimit?: number;
|
||||||
checkedAt: 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. */
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
export async function discoverChildRepos(
|
export async function discoverChildRepos(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
excludeRealRoots: string[] = []
|
excludeRealRoots: string[] = [],
|
||||||
|
maxRepos: number = MAX_REPOS
|
||||||
): Promise<{ dirs: string[]; truncated: boolean }> {
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let level = [cwd];
|
let level = [cwd];
|
||||||
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
|
|||||||
}
|
}
|
||||||
level = next;
|
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 } }>();
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
|
|||||||
home?: string;
|
home?: string;
|
||||||
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
dockerWorkspaces?: string[];
|
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 =
|
type WorkspaceRepos =
|
||||||
| { kind: 'docker' }
|
| { kind: 'docker' }
|
||||||
| { kind: 'error'; error: string }
|
| { kind: 'error'; error: string }
|
||||||
| { kind: 'enclosing'; root: 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
|
* 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> {
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
const now = opts.now ?? Date.now;
|
const now = opts.now ?? Date.now;
|
||||||
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
// 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.
|
// 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
|
// 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
|
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||||
// repositories below.
|
// 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 === 'error') return { kind: 'error', error: top.error };
|
||||||
if (top.state === 'ok') {
|
if (top.state === 'ok') {
|
||||||
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
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.
|
// 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 };
|
let found: { dirs: string[]; truncated: boolean };
|
||||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
else {
|
else {
|
||||||
found = await discoverChildRepos(cwd, dockerRoots);
|
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
|
||||||
discoveryCache.set(cwd, { at: now(), value: found });
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
||||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
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.
|
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
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 === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
if (where.kind === 'enclosing') {
|
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 === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
const root = primary.repoRoot ?? where.root;
|
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[] = [];
|
const repos: GitRepoEntry[] = [];
|
||||||
where.dirs.forEach((dir, i) => {
|
where.dirs.forEach((dir, i) => {
|
||||||
const status = statuses[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');
|
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(
|
export async function getGitFileDiff(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
file: { path: string; origPath?: string; kind: GitFileKind },
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
opts: { git?: GitRunner } = {}
|
opts: { git?: GitRunner; timeoutMs?: number } = {}
|
||||||
): Promise<GitFileDiff> {
|
): Promise<GitFileDiff> {
|
||||||
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
throw new Error('Invalid path');
|
throw new Error('Invalid path');
|
||||||
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
|
|||||||
let out: string;
|
let out: string;
|
||||||
let cutShort = false;
|
let cutShort = false;
|
||||||
try {
|
try {
|
||||||
out = await git(repoRoot, args);
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: unknown; stdout?: unknown };
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The config readers a CLI's registry entry may name for the model its
|
||||||
|
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
|
||||||
|
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
|
||||||
|
* config/cli-registry/profiles.ts).
|
||||||
|
*
|
||||||
|
* A reader answers the model the CLI's own config pins for one session, or null when
|
||||||
|
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
|
||||||
|
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
|
||||||
|
* the model id alone, never another config value.
|
||||||
|
*
|
||||||
|
* @module model-config-resolvers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
|
||||||
|
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
|
||||||
|
|
||||||
|
/** What a reader gets to know about the session. */
|
||||||
|
export interface ModelConfigContext {
|
||||||
|
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
|
||||||
|
config: Record<string, unknown> | undefined;
|
||||||
|
/** The environment the session's CLI runs with (its own overrides, then the server's). */
|
||||||
|
env: (key: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
|
||||||
|
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
|
||||||
|
// launch names from the server's own dsh home) read under the session's dsh home.
|
||||||
|
'deepseek-route': (ctx) =>
|
||||||
|
readDeepSeekRouteModel({
|
||||||
|
profile: ctx.config?.profile,
|
||||||
|
home: effectiveDshHome(ctx.env),
|
||||||
|
serverHome: effectiveDshHome((key) => process.env[key]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model the named reader resolves for a session, or null.
|
||||||
|
*
|
||||||
|
* @param name a `configResolver` from the registry (schema-checked at load)
|
||||||
|
* @param ctx what the reader may know about the session
|
||||||
|
*/
|
||||||
|
export async function resolveConfigModel(
|
||||||
|
name: ModelConfigResolverName,
|
||||||
|
ctx: ModelConfigContext
|
||||||
|
): Promise<string | null> {
|
||||||
|
const resolver = RESOLVERS[name];
|
||||||
|
return resolver ? resolver(ctx) : null;
|
||||||
|
}
|
||||||
@@ -204,6 +204,15 @@ export interface PaneCaptureOptions {
|
|||||||
* rendering it needs the real height to know the frame fits.
|
* rendering it needs the real height to know the frame fits.
|
||||||
*/
|
*/
|
||||||
capturedGeometry?: { cols: number; rows: number };
|
capturedGeometry?: { cols: number; rows: number };
|
||||||
|
/**
|
||||||
|
* Filled in by the implementation with the number of rows the pane holds in
|
||||||
|
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
|
||||||
|
* same query as the geometry. 0 means a full-history capture can return
|
||||||
|
* nothing beyond the visible frame: a pane in the alternate screen (a
|
||||||
|
* fullscreen CLI that keeps its transcript itself) never accumulates any.
|
||||||
|
* Absent when the pane could not be queried.
|
||||||
|
*/
|
||||||
|
capturedHistoryLines?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Which model a session is running, as far as the server can know it
|
||||||
|
* (`SessionState.displayModel`, shown in the tile grid's and split pane's headers).
|
||||||
|
*
|
||||||
|
* Pure: the session feeds it what it has and publishes the answer through `toState()`.
|
||||||
|
*
|
||||||
|
* ## Sources, strongest first
|
||||||
|
*
|
||||||
|
* 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered
|
||||||
|
* by that endpoint's `modelId`, whatever alias the CLI itself prints.
|
||||||
|
* 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's
|
||||||
|
* statusLine exporter posts `model.display_name` on every render (it follows an
|
||||||
|
* in-session `/model`); a CLI whose registry entry declares
|
||||||
|
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
|
||||||
|
* probe already takes.
|
||||||
|
* 3. **config**: the model the CLI's own config pins for this session, read by the
|
||||||
|
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
|
||||||
|
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
|
||||||
|
* a report from the running CLI, so any report outranks it.
|
||||||
|
* 4. **launch**: the model the session was launched with (claude's `--model` or the
|
||||||
|
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
|
||||||
|
* asked for, not what was reported, so it only shows when nothing reported.
|
||||||
|
*
|
||||||
|
* Nothing known means no field at all: the header shows the harness logo alone, never a
|
||||||
|
* placeholder or a guess.
|
||||||
|
*
|
||||||
|
* ## Untrusted text
|
||||||
|
*
|
||||||
|
* A screen-read model is pane text, and a statusline payload is a POST body: both are
|
||||||
|
* stripped of escape sequences and control characters, whitespace-collapsed and capped
|
||||||
|
* here, and the browser renders the result with `textContent`.
|
||||||
|
*
|
||||||
|
* Tests: `test/session-display-model.test.ts`.
|
||||||
|
*
|
||||||
|
* @module session-display-model
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DisplayModel, DisplayModelSource } from './types/session.js';
|
||||||
|
import { stripAnsi } from './utils/index.js';
|
||||||
|
import { getCli } from './config/cli-registry/index.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
|
||||||
|
/** Longest model name published (the header truncates long before this). */
|
||||||
|
export const MAX_DISPLAY_MODEL_CHARS = 64;
|
||||||
|
|
||||||
|
/** A report from the running CLI itself: the sources a restart may restore. */
|
||||||
|
export type ReportedModelSource = Extract<DisplayModelSource, 'statusline' | 'screen'>;
|
||||||
|
|
||||||
|
export interface ReportedModel {
|
||||||
|
model: string;
|
||||||
|
source: ReportedModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model name fit to publish, or undefined when nothing printable is left.
|
||||||
|
*
|
||||||
|
* @param raw anything; only a string can yield a name
|
||||||
|
*/
|
||||||
|
export function sanitizeModelName(raw: unknown): string | undefined {
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!clean) return undefined;
|
||||||
|
return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a footer field can show that is never the model. */
|
||||||
|
export interface ScreenModelRejects {
|
||||||
|
/** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */
|
||||||
|
rejectWords?: readonly string[];
|
||||||
|
/** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */
|
||||||
|
cwdBasename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern.
|
||||||
|
*
|
||||||
|
* Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern
|
||||||
|
* can anchor on the row above), which keeps the search below the transcript: the
|
||||||
|
* pattern itself must still anchor on chrome only that CLI draws.
|
||||||
|
*
|
||||||
|
* A footer whose model field is switched off shows its NEXT field where the model
|
||||||
|
* was, so the captured field is not taken when it is one of the CLI's declared
|
||||||
|
* non-model words (an effort level, a mode) or the session's own folder name, which a
|
||||||
|
* footer field equal to is the folder, never the model, whatever the CLI. Anything
|
||||||
|
* else the pattern captures is read as the model.
|
||||||
|
*
|
||||||
|
* @param paneText a plain `capture-pane -p` frame, or null when it could not be read
|
||||||
|
* @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model
|
||||||
|
* @param tailRows how many non-blank rows from the bottom the pattern sees
|
||||||
|
* @param rejects fields that are never the model (see {@link ScreenModelRejects})
|
||||||
|
* @returns the model, or undefined when the frame shows none
|
||||||
|
*/
|
||||||
|
export function readScreenModel(
|
||||||
|
paneText: string | null | undefined,
|
||||||
|
pattern: RegExp,
|
||||||
|
tailRows: number = 1,
|
||||||
|
rejects: ScreenModelRejects = {}
|
||||||
|
): string | undefined {
|
||||||
|
if (!paneText) return undefined;
|
||||||
|
const rows = stripAnsi(paneText)
|
||||||
|
.split('\n')
|
||||||
|
.map((row) => row.trimEnd())
|
||||||
|
.filter((row) => row !== '');
|
||||||
|
const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n');
|
||||||
|
// compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a
|
||||||
|
// stale lastIndex would make the same frame match every other call.
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
const match = pattern.exec(window);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const field = match[1] ?? '';
|
||||||
|
if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined;
|
||||||
|
if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined;
|
||||||
|
return sanitizeModelName(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session was launched with, read the way its spawn reads it: where the
|
||||||
|
* model param lives is registry data (`capabilities.model` names the param, the entry's
|
||||||
|
* `legacyConfigField` the `<Mode>Config` object holding it, or the option bag itself for
|
||||||
|
* claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell,
|
||||||
|
* dsh) has none.
|
||||||
|
*
|
||||||
|
* @param mode the session's CLI id
|
||||||
|
* @param bag the session's launch option bag (`model`, `codexConfig`, ...)
|
||||||
|
*/
|
||||||
|
export function launchModelFor(mode: string, bag: Record<string, unknown>): string | undefined {
|
||||||
|
const entry = getCli(mode);
|
||||||
|
const model = entry?.capabilities.model;
|
||||||
|
if (!entry || !model || model.source === 'none') return undefined;
|
||||||
|
const param = model.param ?? 'model';
|
||||||
|
const key = entry.launch.legacyConfigAliases?.[param] ?? param;
|
||||||
|
const value = legacyConfigForMode(mode, bag)?.[key];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persisted `displayModel` of a previous run, when it was a report from the CLI
|
||||||
|
* itself: a restart shows it until the next report replaces it. A custom-endpoint or
|
||||||
|
* launch answer is not restored, since the session derives those again by itself.
|
||||||
|
*/
|
||||||
|
export function restoredReportedModel(saved: unknown): ReportedModel | undefined {
|
||||||
|
if (!saved || typeof saved !== 'object') return undefined;
|
||||||
|
const { model, source } = saved as { model?: unknown; source?: unknown };
|
||||||
|
if (source !== 'statusline' && source !== 'screen') return undefined;
|
||||||
|
const name = sanitizeModelName(model);
|
||||||
|
return name ? { model: name, source } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model a session header shows, and where it came from.
|
||||||
|
*
|
||||||
|
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
|
||||||
|
* @param input.reported the newest report from the CLI itself
|
||||||
|
* @param input.configModel the model the CLI's config pins for the session
|
||||||
|
* @param input.launchModel the model the session was launched with
|
||||||
|
*/
|
||||||
|
export function resolveDisplayModel(input: {
|
||||||
|
customModelId?: string;
|
||||||
|
reported?: ReportedModel | null;
|
||||||
|
configModel?: string | null;
|
||||||
|
launchModel?: string;
|
||||||
|
}): DisplayModel | undefined {
|
||||||
|
const custom = sanitizeModelName(input.customModelId);
|
||||||
|
if (custom) return { model: custom, source: 'custom-endpoint' };
|
||||||
|
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
|
||||||
|
if (reported && input.reported) return { model: reported, source: input.reported.source };
|
||||||
|
const config = sanitizeModelName(input.configModel);
|
||||||
|
if (config) return { model: config, source: 'config' };
|
||||||
|
const launch = sanitizeModelName(input.launchModel);
|
||||||
|
if (launch) return { model: launch, source: 'launch' };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { basename } from 'node:path';
|
||||||
import { execSync, execFileSync } from 'node:child_process';
|
import { execSync, execFileSync } from 'node:child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
@@ -105,6 +106,7 @@ import {
|
|||||||
getClaudeBinaryPath,
|
getClaudeBinaryPath,
|
||||||
spawnPtyWithHelperRepair,
|
spawnPtyWithHelperRepair,
|
||||||
resolveLocalShell,
|
resolveLocalShell,
|
||||||
|
waitForProcessesExit,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
@@ -137,7 +139,18 @@ import {
|
|||||||
sanitizeAttachmentHistory,
|
sanitizeAttachmentHistory,
|
||||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||||
} from './session-attachment-history.js';
|
} from './session-attachment-history.js';
|
||||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
|
||||||
|
import { resolveConfigModel } from './model-config-resolvers.js';
|
||||||
|
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
|
||||||
|
import {
|
||||||
|
launchModelFor,
|
||||||
|
readScreenModel,
|
||||||
|
resolveDisplayModel,
|
||||||
|
restoredReportedModel,
|
||||||
|
sanitizeModelName,
|
||||||
|
type ReportedModel,
|
||||||
|
type ReportedModelSource,
|
||||||
|
} from './session-display-model.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||||
@@ -167,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
|||||||
|
|
||||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||||
|
|
||||||
/** Graceful shutdown delay when stopping session (100ms) */
|
/**
|
||||||
|
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
|
||||||
|
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
|
||||||
|
*/
|
||||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||||
|
|
||||||
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
||||||
@@ -265,10 +281,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
|
|||||||
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||||
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
|
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
|
||||||
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
|
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
|
||||||
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
|
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
|
||||||
* with the replay-side strip in session-routes.ts.
|
* 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
|
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
||||||
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
||||||
@@ -288,8 +305,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
||||||
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
|
* and the mouse-tracking DECSETs alone. Applies to every mode that is neither
|
||||||
* excludes, but ONLY when the session is tmux-backed (`useMux`).
|
* `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
|
* 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
|
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
||||||
@@ -314,7 +333,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
||||||
*/
|
*/
|
||||||
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
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)
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||||
@@ -550,6 +593,27 @@ export class Session extends EventEmitter {
|
|||||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/**
|
||||||
|
* The newest model the running CLI reported for itself (its statusline, or its own
|
||||||
|
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
|
||||||
|
* (src/session-display-model.ts). Persisted through `toState()` and restored after a
|
||||||
|
* restart, so an idle session keeps naming its model until the next report.
|
||||||
|
*/
|
||||||
|
private _reportedModel: ReportedModel | null = null;
|
||||||
|
/**
|
||||||
|
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
|
||||||
|
* read at each pane start, attach or relaunch; null when it pins none. Below any
|
||||||
|
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
|
||||||
|
*/
|
||||||
|
private _configModel: string | null = null;
|
||||||
|
/** Bumped per config read, so a read that lands after a newer one is dropped. */
|
||||||
|
private _configModelGen = 0;
|
||||||
|
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
|
||||||
|
private _modelLineRe: RegExp | null | undefined = undefined;
|
||||||
|
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
|
||||||
|
private _modelLineRows = 1;
|
||||||
|
/** Resolved with the pattern above: the fields it shows that are never the model. */
|
||||||
|
private _modelRejectWords: readonly string[] = [];
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
@@ -807,6 +871,8 @@ export class Session extends EventEmitter {
|
|||||||
claudeSessionChain?: string[];
|
claudeSessionChain?: string[];
|
||||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||||
paneExit?: PaneExit;
|
paneExit?: PaneExit;
|
||||||
|
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||||
discoveredMuxSession?: boolean;
|
discoveredMuxSession?: boolean;
|
||||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||||
@@ -974,6 +1040,7 @@ export class Session extends EventEmitter {
|
|||||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||||
this.setPaneExit(config.paneExit);
|
this.setPaneExit(config.paneExit);
|
||||||
|
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||||
// both the id and the saved parent come from disk.
|
// both the id and the saved parent come from disk.
|
||||||
@@ -1267,6 +1334,9 @@ export class Session extends EventEmitter {
|
|||||||
} finally {
|
} finally {
|
||||||
this._paneLifecycleOps--;
|
this._paneLifecycleOps--;
|
||||||
this._paneStartedAt = Date.now();
|
this._paneStartedAt = Date.now();
|
||||||
|
// A start, attach or relaunch is when the CLI read its config, so it is when
|
||||||
|
// the model that config pins is read here too.
|
||||||
|
this._refreshConfigModel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1316,7 +1386,7 @@ export class Session extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* True when this session's PTY is a tmux client rather than the program itself.
|
* 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
|
* 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 {
|
get usesMux(): boolean {
|
||||||
return this._useMux;
|
return this._useMux;
|
||||||
@@ -1858,6 +1928,7 @@ export class Session extends EventEmitter {
|
|||||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
advisorModel: this._advisorModel,
|
advisorModel: this._advisorModel,
|
||||||
customModel: this.customModel,
|
customModel: this.customModel,
|
||||||
|
displayModel: this.displayModel,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||||
@@ -2489,14 +2560,21 @@ export class Session extends EventEmitter {
|
|||||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||||
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
||||||
//
|
//
|
||||||
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
|
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
|
||||||
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
// 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
|
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
||||||
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
||||||
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
// 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 fullStrip = isAltScreenStripMode(this.mode);
|
||||||
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
|
||||||
if (fullStrip || altOnlyStrip) {
|
const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
||||||
|
if (fullStrip || mouseStrip || altOnlyStrip) {
|
||||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||||
@@ -2515,14 +2593,18 @@ export class Session extends EventEmitter {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
||||||
if (fullStrip) {
|
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
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[3J/g, '')
|
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
|
||||||
// eslint-disable-next-line no-control-regex
|
(seq) => {
|
||||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
|
||||||
this._recordStrippedMouseMode(seq);
|
this._recordStrippedMouseMode(seq);
|
||||||
return '';
|
return '';
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2743,19 +2825,14 @@ export class Session extends EventEmitter {
|
|||||||
this.id;
|
this.id;
|
||||||
|
|
||||||
// For NEW mux sessions: wait for readiness then clean buffer
|
// 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 (!isRestored) {
|
||||||
if (isExternalCliMode(this.mode)) {
|
if (isExternalCliMode(this.mode)) {
|
||||||
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
||||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
// 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.
|
// 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.
|
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
||||||
this._promptCheckTimeout = setTimeout(() => {
|
this._armPaneSettle(false);
|
||||||
this._promptCheckTimeout = null;
|
|
||||||
if (this._isStopped) return;
|
|
||||||
this._status = 'idle';
|
|
||||||
this.emit('needsRefresh');
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
} else {
|
||||||
// Claude mode: wait for ❯ prompt
|
// Claude mode: wait for ❯ prompt
|
||||||
this._promptCheckInterval = setInterval(() => {
|
this._promptCheckInterval = setInterval(() => {
|
||||||
@@ -2787,6 +2864,8 @@ export class Session extends EventEmitter {
|
|||||||
this._promptCheckTimeout = null;
|
this._promptCheckTimeout = null;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this._armPaneSettle(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||||
@@ -3134,9 +3213,130 @@ export class Session extends EventEmitter {
|
|||||||
this._lastPaneProbeWorking =
|
this._lastPaneProbeWorking =
|
||||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||||
this._readWatching(text);
|
this._readWatching(text);
|
||||||
|
this._readScreenModel(text);
|
||||||
return this._lastPaneProbeWorking;
|
return this._lastPaneProbeWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model the CLI's own footer names off the same capture, for a CLI whose
|
||||||
|
* registry entry declares `capabilities.modelDetect`.
|
||||||
|
*
|
||||||
|
* Unlike `_readWatching`, a capture that could not be read, or a footer the pattern
|
||||||
|
* does not find (a popup covering it, a footer turned off), KEEPS the last model. The
|
||||||
|
* two are not symmetric: background work ends and its badge must go, while a model does
|
||||||
|
* not stop running because something was drawn over the row that names it.
|
||||||
|
*/
|
||||||
|
private _readScreenModel(paneText: string | null): void {
|
||||||
|
if (paneText === null) return;
|
||||||
|
const pattern = this._modelLinePattern();
|
||||||
|
if (!pattern) return;
|
||||||
|
const model = readScreenModel(paneText, pattern, this._modelLineRows, {
|
||||||
|
rejectWords: this._modelRejectWords,
|
||||||
|
// A footer field equal to the folder this session runs in is the folder, never the
|
||||||
|
// model: the generic half of the rule, for every CLI.
|
||||||
|
cwdBasename: basename(this.workingDir),
|
||||||
|
});
|
||||||
|
if (model) this.noteReportedModel('screen', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex reading this CLI's model off its footer, or null for a CLI that declares
|
||||||
|
* none. Compiled once per session through `compileVersionRegex()` (null, never a throw,
|
||||||
|
* for a pattern it refuses), like the working- and watching-line patterns.
|
||||||
|
*/
|
||||||
|
private _modelLinePattern(): RegExp | null {
|
||||||
|
if (this._modelLineRe === undefined) {
|
||||||
|
const detect = getCli(this.mode)?.capabilities.modelDetect;
|
||||||
|
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
|
||||||
|
this._modelLineRows = detect?.screenLines ?? 1;
|
||||||
|
this._modelRejectWords = detect?.rejectWords ?? [];
|
||||||
|
}
|
||||||
|
return this._modelLineRe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a model the running CLI reported for itself: its statusline (claude's
|
||||||
|
* exporter, via `POST /api/status-telemetry`) or its own footer. The newest report
|
||||||
|
* wins whatever its source. An empty or unprintable report changes nothing.
|
||||||
|
*
|
||||||
|
* @returns true when the reported model changed (and `displayModelChanged` was emitted)
|
||||||
|
*/
|
||||||
|
noteReportedModel(source: ReportedModelSource, raw: unknown): boolean {
|
||||||
|
const model = sanitizeModelName(raw);
|
||||||
|
if (!model) return false;
|
||||||
|
if (this._reportedModel?.model === model && this._reportedModel.source === source) return false;
|
||||||
|
this._reportedModel = { model, source };
|
||||||
|
// The status does not change with it, so it needs a broadcast (and a persist) of its own.
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model this session runs as far as the server knows, and where that came from:
|
||||||
|
* the custom endpoint's model, else the newest report from the CLI, else the launch
|
||||||
|
* model (src/session-display-model.ts). Undefined when none is known.
|
||||||
|
*/
|
||||||
|
get displayModel(): DisplayModel | undefined {
|
||||||
|
return resolveDisplayModel({
|
||||||
|
customModelId: this._customModel?.modelId,
|
||||||
|
reported: this._reportedModel,
|
||||||
|
configModel: this._configModel,
|
||||||
|
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same option bag the spawn reads its launch params from: `model` at the top for
|
||||||
|
* claude (the `--model` or app-wide default it was created with; inert for every other
|
||||||
|
* CLI, which is why it is not handed over for them), each other CLI's own
|
||||||
|
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
|
||||||
|
*/
|
||||||
|
private _launchOptionBag(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
|
codexConfig: this._codexConfig,
|
||||||
|
geminiConfig: this._geminiConfig,
|
||||||
|
antigravityConfig: this._antigravityConfig,
|
||||||
|
piConfig: this._piConfig,
|
||||||
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
|
ompConfig: this._ompConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the model this session's CLI config pins, with the reader its registry entry
|
||||||
|
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
|
||||||
|
* bounded (the reader probes before it reads); a read that lands after a newer one,
|
||||||
|
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
|
||||||
|
* config on another machine or in its container, so nothing local is read for it.
|
||||||
|
*/
|
||||||
|
private _refreshConfigModel(): void {
|
||||||
|
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
|
||||||
|
if (!name || this._remote || this._docker) return;
|
||||||
|
const gen = ++this._configModelGen;
|
||||||
|
const overrides = this._envOverrides;
|
||||||
|
resolveConfigModel(name, {
|
||||||
|
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
|
||||||
|
// The session's own env first (already clamped for a non-granted owner), then the
|
||||||
|
// server's: what the pane's CLI inherits.
|
||||||
|
env: (key) => overrides?.[key] ?? process.env[key],
|
||||||
|
}).then(
|
||||||
|
(model) => {
|
||||||
|
if (gen !== this._configModelGen || this._isStopped) return;
|
||||||
|
// Sanitized where it is published (resolveDisplayModel), like every source.
|
||||||
|
const next = model || null;
|
||||||
|
if (next === this._configModel) return;
|
||||||
|
this._configModel = next;
|
||||||
|
this.emit('displayModelChanged');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
/* A reader answers null on doubt and never throws; a throw changes nothing. */
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the background-work chip off the same capture the working probe just took.
|
* Read the background-work chip off the same capture the working probe just took.
|
||||||
*
|
*
|
||||||
@@ -3272,14 +3472,74 @@ export class Session extends EventEmitter {
|
|||||||
// 1. Claude was working and is now at prompt (normal case)
|
// 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)
|
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
||||||
const wasWorking = this._isWorking;
|
const wasWorking = this._isWorking;
|
||||||
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
if (wasWorking || this._status === 'busy') this._concludeIdle(wasWorking);
|
||||||
if (wasWorking || isInitialReady) {
|
}
|
||||||
this._isWorking = false;
|
|
||||||
this._status = 'idle';
|
/**
|
||||||
this._lastPromptTime = Date.now();
|
* The one place a pane is concluded idle: status, working flag and prompt stamp
|
||||||
if (wasWorking) this._maybeCaptureOmpSessionId();
|
* change together, and the change is ANNOUNCED. The `idle` event is what the web
|
||||||
this.emit('idle');
|
* 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4267,7 +4527,7 @@ export class Session extends EventEmitter {
|
|||||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
// ⚠ 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
|
// 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
|
// 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
|
// on screen for as long as the command runs, promptStillInComposer() reads that as
|
||||||
@@ -4545,8 +4805,10 @@ export class Session extends EventEmitter {
|
|||||||
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give it a moment to terminate gracefully
|
// Give it a moment to terminate gracefully. For a tmux-backed session this
|
||||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
// is the attach client, gone within a few ms of SIGTERM, and this used to be
|
||||||
|
// a fixed 100ms sleep on every close.
|
||||||
|
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
|
||||||
|
|
||||||
// Force kill with SIGKILL if still alive
|
// Force kill with SIGKILL if still alive
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
||||||
*/
|
*/
|
||||||
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
||||||
const subagents = this.getSubagentsForSession(workingDir);
|
const targets = this.getSubagentsForSession(workingDir).filter(
|
||||||
for (const agent of subagents) {
|
// Only kill subagents belonging to this specific session
|
||||||
if (agent.status === 'active' || agent.status === 'idle') {
|
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
|
||||||
// Only kill subagents belonging to this specific session
|
);
|
||||||
if (sessionId && agent.sessionId !== sessionId) continue;
|
if (targets.length === 0) return;
|
||||||
await this.killSubagent(agent.agentId);
|
|
||||||
|
// ONE process scan for the lot. This used to call killSubagent() per agent, and
|
||||||
|
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
|
||||||
|
// box with ~100 matching processes), so closing a session right after a workflow
|
||||||
|
// paid that once per recently active subagent. The match rules are
|
||||||
|
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
|
||||||
|
const pidMap = await this.getClaudePids();
|
||||||
|
const signalled = new Set<number>();
|
||||||
|
for (const agent of targets) {
|
||||||
|
// The liveness checker may have completed it while the scan ran.
|
||||||
|
if (agent.status !== 'active' && agent.status !== 'idle') continue;
|
||||||
|
for (const [pid, procInfo] of pidMap) {
|
||||||
|
if (signalled.has(pid)) continue;
|
||||||
|
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
|
||||||
|
signalled.add(pid);
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited
|
||||||
|
}
|
||||||
|
break; // one process per agent, as killSubagent() does
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
this.markSubagentAsCompleted(agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
||||||
- The session persists across disconnects — your work is safe.
|
- 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.
|
- 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`.
|
||||||
@@ -94,7 +94,13 @@ import {
|
|||||||
type DockerMount,
|
type DockerMount,
|
||||||
type DockerSeedCopy,
|
type DockerSeedCopy,
|
||||||
} from './docker-hosts.js';
|
} from './docker-hosts.js';
|
||||||
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
|
import {
|
||||||
|
wrapWithNice,
|
||||||
|
SAFE_PATH_PATTERN,
|
||||||
|
resolveLocalShell,
|
||||||
|
loginShellArgs,
|
||||||
|
waitForProcessesExit,
|
||||||
|
} from './utils/index.js';
|
||||||
import type {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
MuxSession,
|
MuxSession,
|
||||||
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
|
|||||||
const GET_PID_MAX_RETRIES = 5;
|
const GET_PID_MAX_RETRIES = 5;
|
||||||
const GET_PID_RETRY_MS = 200;
|
const GET_PID_RETRY_MS = 200;
|
||||||
|
|
||||||
/** Delay after tmux kill command (200ms) */
|
/**
|
||||||
|
* How long killSession gives a pane's children to exit on SIGTERM before it
|
||||||
|
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
|
||||||
|
*/
|
||||||
const TMUX_KILL_WAIT_MS = 200;
|
const TMUX_KILL_WAIT_MS = 200;
|
||||||
|
|
||||||
/** Delay for graceful shutdown (100ms) */
|
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
|
||||||
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||||
|
|
||||||
|
/** How long killSession waits for every process it signalled to be gone before it warns. */
|
||||||
|
const KILL_VERIFY_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
/** Default stats collection interval (2 seconds) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
@@ -688,6 +700,12 @@ interface PaneCursorGeometry {
|
|||||||
rows: number;
|
rows: number;
|
||||||
cursorX: number;
|
cursorX: number;
|
||||||
cursorY: number;
|
cursorY: number;
|
||||||
|
/**
|
||||||
|
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
|
||||||
|
* full-history capture can add. Absent when the query did not return it.
|
||||||
|
* Optional and validated on its own, so a bad value never voids the caret.
|
||||||
|
*/
|
||||||
|
historyLines?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -704,7 +722,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
|||||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(cursorX) ||
|
!Number.isFinite(cursorX) ||
|
||||||
!Number.isFinite(cursorY) ||
|
!Number.isFinite(cursorY) ||
|
||||||
@@ -717,7 +735,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
|||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { cols, rows, cursorX, cursorY };
|
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
|
||||||
|
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
|
||||||
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SGR attributes, which is all `capture-pane -e` emits. */
|
/** SGR attributes, which is all `capture-pane -e` emits. */
|
||||||
@@ -2350,6 +2370,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link getPanePid} without blocking the event loop, for the kill path: a close
|
||||||
|
* must not stall every other session's I/O while tmux answers.
|
||||||
|
*/
|
||||||
|
private async getPanePidAsync(muxName: string): Promise<number | null> {
|
||||||
|
if (IS_TEST_MODE) return 99999;
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const pid = parseInt(stdout.trim(), 10);
|
||||||
|
return Number.isNaN(pid) ? null : pid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a tmux session exists.
|
* Check if a tmux session exists.
|
||||||
*/
|
*/
|
||||||
@@ -2604,7 +2646,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a process is still alive
|
// Check if a process is still alive. Signal decisions only: an unreaped zombie
|
||||||
|
// counts here, so its process group still gets the SIGKILL below. The WAITS use
|
||||||
|
// waitForProcessesExit(), which counts a zombie as exited.
|
||||||
private isProcessAlive(pid: number): boolean {
|
private isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -2614,20 +2658,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all PIDs are dead, with retry
|
// Verify all PIDs are dead, returning as soon as they are
|
||||||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||||||
const startTime = Date.now();
|
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
|
||||||
const checkInterval = 100;
|
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitMs) {
|
|
||||||
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
|
||||||
if (aliveCount === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
||||||
}
|
|
||||||
|
|
||||||
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
|
||||||
if (stillAlive.length > 0) {
|
if (stillAlive.length > 0) {
|
||||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -2685,7 +2718,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (isValidMuxName(session.muxName)) {
|
if (isValidMuxName(session.muxName)) {
|
||||||
try {
|
try {
|
||||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2702,7 +2735,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current PID (may have changed)
|
// Get current PID (may have changed)
|
||||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
|
||||||
|
|
||||||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||||||
|
|
||||||
@@ -2724,7 +2757,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
// Most children are gone within a few ms; the re-scan below still runs, to
|
||||||
|
// catch anything spawned since the first one.
|
||||||
|
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
|
||||||
|
|
||||||
childPids = await this.getChildPidsFresh(currentPid);
|
childPids = await this.getChildPidsFresh(currentPid);
|
||||||
for (const childPid of childPids) {
|
for (const childPid of childPids) {
|
||||||
@@ -2742,7 +2777,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
try {
|
try {
|
||||||
process.kill(-currentPid, 'SIGTERM');
|
process.kill(-currentPid, 'SIGTERM');
|
||||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
|
||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
process.kill(-currentPid, 'SIGKILL');
|
process.kill(-currentPid, 'SIGKILL');
|
||||||
}
|
}
|
||||||
@@ -2751,10 +2786,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
|
||||||
|
// Async: tmux takes tens of ms to tear a session down, and execSync held the
|
||||||
|
// whole server for that long on every close.
|
||||||
if (isValidMuxName(session.muxName)) {
|
if (isValidMuxName(session.muxName)) {
|
||||||
try {
|
try {
|
||||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2798,7 +2835,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify all processes are dead
|
// Verify all processes are dead
|
||||||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
|
||||||
if (!allDead) {
|
if (!allDead) {
|
||||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||||
}
|
}
|
||||||
@@ -3861,7 +3898,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// to keep when a move follows to put the caret back above them.
|
// to keep when a move follows to put the caret back above them.
|
||||||
const geometry = queryPaneCursor(() =>
|
const geometry = queryPaneCursor(() =>
|
||||||
execSync(
|
execSync(
|
||||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -3873,6 +3910,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// geometry is reported there for diagnosis rather than for repair. Only
|
// geometry is reported there for diagnosis rather than for repair. Only
|
||||||
// the caller can see both sizes, so hand it this one.
|
// the caller can see both sizes, so hand it this one.
|
||||||
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
|
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
|
||||||
|
// Same query, no extra tmux call: how much scrollback a full-history pull
|
||||||
|
// could return. A pane in the alternate screen (fullscreen claude) holds
|
||||||
|
// none, and the partial-history notice must not promise it.
|
||||||
|
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
|
||||||
|
|
||||||
if (fullHistory) {
|
if (fullHistory) {
|
||||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||||
|
|||||||
@@ -641,6 +641,24 @@ export interface CustomModelSelection {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's {@link DisplayModel} came from (src/session-display-model.ts):
|
||||||
|
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
|
||||||
|
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
|
||||||
|
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
|
||||||
|
* - `config`: what the CLI's own config pins for this session
|
||||||
|
* (`capabilities.modelDetect.configResolver`), while its screen names none.
|
||||||
|
* - `launch`: what the session was launched with; nothing has reported since.
|
||||||
|
*/
|
||||||
|
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
|
||||||
|
|
||||||
|
/** The model a session runs as far as the server knows, for a session header. */
|
||||||
|
export interface DisplayModel {
|
||||||
|
/** Display text: sanitized (no control characters) and at most 64 characters. */
|
||||||
|
model: string;
|
||||||
|
source: DisplayModelSource;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full custom-model selection a session keeps: the public selection plus the
|
* The full custom-model selection a session keeps: the public selection plus the
|
||||||
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
* bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what
|
||||||
@@ -794,7 +812,8 @@ export interface SessionState {
|
|||||||
/**
|
/**
|
||||||
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
* 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
|
* 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
|
* 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.
|
* sent mouse reports to a CLI that never asked for them.
|
||||||
*/
|
*/
|
||||||
@@ -847,6 +866,16 @@ export interface SessionState {
|
|||||||
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
* written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`.
|
||||||
*/
|
*/
|
||||||
customModel?: CustomModelSelection;
|
customModel?: CustomModelSelection;
|
||||||
|
/**
|
||||||
|
* The model this session runs, as far as the server knows it, and where that came from
|
||||||
|
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
|
||||||
|
* from the CLI itself (statusline or its own footer), else the model its config pins,
|
||||||
|
* else the launch model. Absent when
|
||||||
|
* none is known; a session header then shows the harness alone. Untrusted display text
|
||||||
|
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
|
||||||
|
* value is restored after a restart until the next report replaces it.
|
||||||
|
*/
|
||||||
|
displayModel?: DisplayModel;
|
||||||
/** Sanitized per-session attachment history. */
|
/** Sanitized per-session attachment history. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'presentation'
|
| 'presentation'
|
||||||
|
| 'spreadsheet'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'text';
|
| 'text';
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
|
|||||||
/** Profile directory names that are not profiles. */
|
/** Profile directory names that are not profiles. */
|
||||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||||
|
|
||||||
|
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
|
||||||
|
export function isProfileDirName(name: string): boolean {
|
||||||
|
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
|
||||||
|
}
|
||||||
|
|
||||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||||
const haystack = [name, ...bundles].join(' ');
|
const haystack = [name, ...bundles].join(' ');
|
||||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||||
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
|||||||
*/
|
*/
|
||||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile from its directory name and the text of its `package.json`, or null when
|
||||||
|
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
|
||||||
|
* same classification as the inventory below.
|
||||||
|
*/
|
||||||
|
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
|
||||||
|
try {
|
||||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||||
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
|||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
|
||||||
.map((e) => e.name);
|
.map((e) => e.name);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
|||||||
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
||||||
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
||||||
export { StaleExpirationMap } from './stale-expiration-map.js';
|
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||||
|
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
|
||||||
export {
|
export {
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Wait for processes to exit, and return as soon as they have.
|
||||||
|
*
|
||||||
|
* The session kill path used to sleep a FIXED interval after each signal (100 ms
|
||||||
|
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
|
||||||
|
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
|
||||||
|
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
|
||||||
|
* timers. This keeps every deadline the kill path had; it only stops waiting
|
||||||
|
* once there is nothing left to wait for.
|
||||||
|
*
|
||||||
|
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
|
||||||
|
* it, and on the kill path that parent is the tmux server or the service
|
||||||
|
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
|
||||||
|
* zombie from a running process, so on Linux the state letter in
|
||||||
|
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
|
||||||
|
*
|
||||||
|
* @module utils/process-exit-wait
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
/** Poll step while waiting: an exit is noticed within about one frame. */
|
||||||
|
export const PROCESS_EXIT_POLL_MS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while `pid` names a process that has not exited. A pid we may not signal
|
||||||
|
* reads as not running, which is what the kill path's own check always did:
|
||||||
|
* there is nothing it could do about such a process anyway.
|
||||||
|
*/
|
||||||
|
export function isProcessRunning(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'linux') return true;
|
||||||
|
let stat: string;
|
||||||
|
try {
|
||||||
|
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
// Gone between the two reads. Any other failure: trust kill(pid, 0).
|
||||||
|
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
|
||||||
|
}
|
||||||
|
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
|
||||||
|
// so the state is the field after the LAST ')'.
|
||||||
|
const close = stat.lastIndexOf(')');
|
||||||
|
const state = close === -1 ? '' : stat.charAt(close + 2);
|
||||||
|
return state !== 'Z' && state !== 'X';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitForExitOptions {
|
||||||
|
/** Give up after this long; the survivors are returned, never thrown. */
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
|
||||||
|
pollMs?: number;
|
||||||
|
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
|
||||||
|
isRunning?: (pid: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
|
||||||
|
* that have not. Never rejects.
|
||||||
|
*/
|
||||||
|
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
|
||||||
|
const isRunning = options.isRunning ?? isProcessRunning;
|
||||||
|
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
|
||||||
|
const deadline = Date.now() + options.timeoutMs;
|
||||||
|
let running = pids.filter((pid) => isRunning(pid));
|
||||||
|
while (running.length > 0) {
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
|
||||||
|
running = running.filter((pid) => isRunning(pid));
|
||||||
|
}
|
||||||
|
return running;
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -692,6 +692,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="tab-number">${base + i + 1}</span>
|
<span class="tab-number">${base + i + 1}</span>
|
||||||
<span class="tab-status idle" aria-hidden="true"></span>
|
<span class="tab-status idle" aria-hidden="true"></span>
|
||||||
<span class="tab-info"><span class="tab-name-row">
|
<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 class="tab-name">w${base + i + 1}-demo</span>
|
||||||
</span></span>`;
|
</span></span>`;
|
||||||
container.appendChild(tab);
|
container.appendChild(tab);
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||||
this._gitStatusFetchedAt = Date.now();
|
this._gitStatusFetchedAt = Date.now();
|
||||||
try {
|
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;
|
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
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). */
|
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||||
isGitStatusTree() {
|
isGitStatusTree() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let uncommitted = 0;
|
let uncommitted = 0;
|
||||||
let unpushed = 0;
|
let unpushed = 0;
|
||||||
let conflicted = 0;
|
let conflicted = 0;
|
||||||
|
let unreadable = 0;
|
||||||
for (const r of overview.repos) {
|
for (const r of overview.repos) {
|
||||||
|
if (r.status.state === 'error') unreadable += 1;
|
||||||
uncommitted += r.status.counts.uncommitted;
|
uncommitted += r.status.counts.uncommitted;
|
||||||
unpushed += r.status.unpushedCount;
|
unpushed += r.status.unpushedCount;
|
||||||
conflicted += r.status.counts.conflicted;
|
conflicted += r.status.counts.conflicted;
|
||||||
}
|
}
|
||||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
|
||||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
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. */
|
/** 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.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.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.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');
|
if (!bits.length) bits.push('everything is committed and pushed');
|
||||||
let where;
|
let where;
|
||||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||||
else {
|
else {
|
||||||
const d = overview.repos[0].status;
|
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.`;
|
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.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||||
|
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
|
||||||
if (!parts.length) parts.push('✓');
|
if (!parts.length) parts.push('✓');
|
||||||
if (label) label.textContent = parts.join(' ');
|
if (label) label.textContent = parts.join(' ');
|
||||||
const sentence = this._gitStatusSentence(data);
|
const sentence = this._gitStatusSentence(data);
|
||||||
@@ -333,17 +358,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repos = overview.repos;
|
const repos = overview.repos;
|
||||||
if (repos.length === 1) {
|
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
|
||||||
// One repository: the panel is that repository, as it always was.
|
// 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;
|
const d = repos[0].status;
|
||||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||||
this._renderGitRepoInto(body, d);
|
this._renderGitRepoInto(body, d);
|
||||||
} else {
|
} 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));
|
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||||
if (overview.reposTruncated) {
|
if (overview.reposTruncated) {
|
||||||
body.append(
|
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) {
|
_gitRepoSection(r) {
|
||||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
const d = r.status;
|
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 section = el('details', 'git-status-repo');
|
||||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
@@ -582,7 +623,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||||
this._gitDiffView = view;
|
this._gitDiffView = view;
|
||||||
this._renderGitStatusPanel();
|
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}`);
|
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.
|
// Back, another file or another session while this was in flight: drop the answer.
|
||||||
if (this._gitDiffView !== view) return;
|
if (this._gitDiffView !== view) return;
|
||||||
|
|||||||
@@ -70,17 +70,13 @@ const HOME_SESSIONS_PILL_LABEL = {
|
|||||||
done: 'done',
|
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 = {
|
const HOME_SESSIONS_MODE_BADGE = {
|
||||||
shell: 'sh',
|
shell: 'sh',
|
||||||
opencode: 'oc',
|
|
||||||
codex: 'cx',
|
|
||||||
gemini: 'gm',
|
|
||||||
antigravity: 'ag',
|
|
||||||
pi: 'pi',
|
|
||||||
grok: 'gk',
|
|
||||||
deepseek: 'ds',
|
|
||||||
omp: 'om',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
@@ -430,6 +426,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
badge.setAttribute('data-i18n-skip', '');
|
badge.setAttribute('data-i18n-skip', '');
|
||||||
badge.textContent = row.modeBadge;
|
badge.textContent = row.modeBadge;
|
||||||
line1.appendChild(badge);
|
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');
|
const name = document.createElement('span');
|
||||||
// .session-name is in the i18n skip list: a session name is user content.
|
// .session-name is in the i18n skip list: a session name is user content.
|
||||||
|
|||||||
@@ -45,6 +45,15 @@
|
|||||||
// Exact English-source translations. Technical names, command examples, model
|
// Exact English-source translations. Technical names, command examples, model
|
||||||
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
||||||
const ZH_CN = Object.freeze({
|
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': '跳转到终端',
|
'Skip to terminal': '跳转到终端',
|
||||||
'Go to main page': '返回主页',
|
'Go to main page': '返回主页',
|
||||||
'Session tabs': '会话标签页',
|
'Session tabs': '会话标签页',
|
||||||
@@ -96,6 +105,88 @@
|
|||||||
'Split: close the second session': '分屏:关闭第二个会话',
|
'Split: close the second session': '分屏:关闭第二个会话',
|
||||||
'Close split': '关闭分屏',
|
'Close split': '关闭分屏',
|
||||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||||
|
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
|
||||||
|
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
|
||||||
|
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
|
||||||
|
// Counts, exit codes and durations are patterns in translateDynamic.
|
||||||
|
Tiles: '平铺',
|
||||||
|
Split: '分屏',
|
||||||
|
'Tiled sessions': '平铺的会话',
|
||||||
|
'Tiles: show several sessions side by side (right-click for how many)':
|
||||||
|
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
||||||
|
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
||||||
|
'How many tiles': '窗格数量',
|
||||||
|
// The Tiles button's hover card (the count and the fits note are patterns).
|
||||||
|
'Click: open the grid': '单击:打开平铺网格',
|
||||||
|
'Click: close the grid': '单击:关闭平铺网格',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
|
||||||
|
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
|
||||||
|
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
||||||
|
'Toggle Tile Grid': '切换平铺网格',
|
||||||
|
'Focus Tile Left': '聚焦左侧窗格',
|
||||||
|
'Focus Tile Right': '聚焦右侧窗格',
|
||||||
|
'Focus Tile Up': '聚焦上方窗格',
|
||||||
|
'Focus Tile Down': '聚焦下方窗格',
|
||||||
|
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
|
||||||
|
'Move Tile Left': '向左移动窗格',
|
||||||
|
'Move Tile Right': '向右移动窗格',
|
||||||
|
'Move Tile Up': '向上移动窗格',
|
||||||
|
'Move Tile Down': '向下移动窗格',
|
||||||
|
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
|
||||||
|
Drag: '拖动',
|
||||||
|
"a tile's header": '窗格的标题栏',
|
||||||
|
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||||
|
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||||
|
'Remove Focused Tile': '移除聚焦的窗格',
|
||||||
|
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||||
|
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
|
||||||
|
'a tab': '标签页',
|
||||||
|
'the Tiles button': '平铺按钮',
|
||||||
|
Click: '单击',
|
||||||
|
'Right-click': '右键单击',
|
||||||
|
Arrows: '方向键',
|
||||||
|
'not bound': '未绑定',
|
||||||
|
'Open group as tiles': '以平铺方式打开分组',
|
||||||
|
'No sessions to show as tiles': '没有可平铺显示的会话',
|
||||||
|
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
|
||||||
|
'Zoom this tile': '放大此窗格',
|
||||||
|
'Restore the grid': '恢复平铺网格',
|
||||||
|
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||||
|
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||||
|
// A 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': '调整窗格列宽',
|
||||||
|
'Resize tile rows': '调整窗格行高',
|
||||||
|
Attach: '附加',
|
||||||
|
'Attaching…': '正在附加…',
|
||||||
|
'Not attached': '未附加',
|
||||||
|
'The session ended': '会话已结束',
|
||||||
|
'The agent exited': '智能体已退出',
|
||||||
|
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
|
||||||
|
'Could not attach the session': '无法附加会话',
|
||||||
|
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
|
||||||
|
// its exit-code forms and the tab's accessible name are patterns.
|
||||||
|
exited: '已退出',
|
||||||
|
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
|
||||||
|
// are a pattern; mode codes and product names stay), and the toolbar beside it.
|
||||||
|
'Terminal / Shell': '终端 / Shell',
|
||||||
|
'Send Enter': '发送回车',
|
||||||
|
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
|
||||||
|
// input like Click (单击).
|
||||||
|
Tabs: '标签页',
|
||||||
|
'Toggle Session Sidebar': '切换会话侧边栏',
|
||||||
|
'Copy Selection': '复制选中内容',
|
||||||
|
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
|
||||||
|
'Focus Tabs': '聚焦标签页',
|
||||||
|
Wheel: '滚轮',
|
||||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||||
Notifications: '通知',
|
Notifications: '通知',
|
||||||
@@ -140,10 +231,16 @@
|
|||||||
'Run DeepSeek': '运行 DeepSeek',
|
'Run DeepSeek': '运行 DeepSeek',
|
||||||
'Run OMP': '运行 OMP',
|
'Run OMP': '运行 OMP',
|
||||||
'Run Shell': '运行 Shell',
|
'Run Shell': '运行 Shell',
|
||||||
|
'More tools': '更多工具',
|
||||||
'Select AI backend': '选择 AI 后端',
|
'Select AI backend': '选择 AI 后端',
|
||||||
'Create New Case': '新建案例',
|
'Create New Case': '新建案例',
|
||||||
'Create new case': '新建案例',
|
'Create new case': '新建案例',
|
||||||
'Link Existing': '关联现有目录',
|
'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': '添加案例',
|
'Add Case': '添加案例',
|
||||||
'Open sessions': '打开会话',
|
'Open sessions': '打开会话',
|
||||||
'Recent Sessions': '最近会话',
|
'Recent Sessions': '最近会话',
|
||||||
@@ -221,6 +318,7 @@
|
|||||||
Running: '运行中',
|
Running: '运行中',
|
||||||
Idle: '空闲',
|
Idle: '空闲',
|
||||||
Working: '工作中',
|
Working: '工作中',
|
||||||
|
Waiting: '等待中',
|
||||||
Today: '今天',
|
Today: '今天',
|
||||||
Home: '主页',
|
Home: '主页',
|
||||||
Local: '本地',
|
Local: '本地',
|
||||||
@@ -271,6 +369,53 @@
|
|||||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
'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: '面板',
|
Panels: '面板',
|
||||||
Monitor: '监视器',
|
Monitor: '监视器',
|
||||||
'Project Insights': '项目洞察',
|
'Project Insights': '项目洞察',
|
||||||
@@ -351,7 +496,6 @@
|
|||||||
'Remote Access': '远程访问',
|
'Remote Access': '远程访问',
|
||||||
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
||||||
'Tunnel URL': '隧道地址',
|
'Tunnel URL': '隧道地址',
|
||||||
'Upload URL': '上传地址',
|
|
||||||
Updates: '更新',
|
Updates: '更新',
|
||||||
'Current Version': '当前版本',
|
'Current Version': '当前版本',
|
||||||
'Check for Updates': '检查更新',
|
'Check for Updates': '检查更新',
|
||||||
@@ -592,6 +736,13 @@
|
|||||||
'Nothing to copy': '没有可复制的内容',
|
'Nothing to copy': '没有可复制的内容',
|
||||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||||
'Session not found': '未找到会话',
|
'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
|
// 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.
|
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||||
Copy: '复制',
|
Copy: '复制',
|
||||||
@@ -753,6 +904,40 @@
|
|||||||
'Wrap lines': '自动换行',
|
'Wrap lines': '自动换行',
|
||||||
'Unsaved changes': '未保存的更改',
|
'Unsaved changes': '未保存的更改',
|
||||||
Saved: '已保存',
|
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 JSON': '导出为 JSON',
|
||||||
'Export as Markdown': '导出为 Markdown',
|
'Export as Markdown': '导出为 Markdown',
|
||||||
'Mark all read': '全部标为已读',
|
'Mark all read': '全部标为已读',
|
||||||
@@ -951,6 +1136,17 @@
|
|||||||
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
|
||||||
|
const TILE_STATE_ZH = {
|
||||||
|
'needs you': '需要你',
|
||||||
|
error: '错误',
|
||||||
|
waiting: '等待中',
|
||||||
|
working: '工作中',
|
||||||
|
idle: '空闲',
|
||||||
|
done: '已完成',
|
||||||
|
exited: '已退出',
|
||||||
|
};
|
||||||
|
|
||||||
function translateDynamic(source) {
|
function translateDynamic(source) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||||
@@ -969,12 +1165,82 @@
|
|||||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
[/^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.
|
// Group names are user text: they pass through untranslated.
|
||||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||||
[
|
[
|
||||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||||
],
|
],
|
||||||
|
// Tile grid: counts, exit codes and durations pass through.
|
||||||
|
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
||||||
|
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
|
||||||
|
[
|
||||||
|
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
|
||||||
|
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
|
||||||
|
],
|
||||||
|
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
||||||
|
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\)$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
|
||||||
|
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^The window is too small for (\d+) tiles: showing the focused one$/,
|
||||||
|
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||||
|
],
|
||||||
|
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||||
|
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||||
|
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||||
|
// and where the model came from when the CLI did not report it. The harness
|
||||||
|
// and model names pass through untranslated.
|
||||||
|
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
|
||||||
|
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
|
||||||
|
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
|
||||||
|
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
|
||||||
|
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
|
||||||
|
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
|
||||||
|
// The tab's exited-agent badge, and the tab's accessible name carrying it.
|
||||||
|
// The session name is user text: it passes through untranslated.
|
||||||
|
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
|
||||||
|
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
|
||||||
|
[
|
||||||
|
/^(.+) session, agent exited \(signal (\d+)\)$/,
|
||||||
|
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
|
||||||
|
],
|
||||||
|
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
|
||||||
|
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
|
||||||
|
// A session name is user text: it passes through untranslated.
|
||||||
|
[
|
||||||
|
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
|
||||||
|
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
|
||||||
|
],
|
||||||
|
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
|
||||||
|
// is required: bare state words stay out of the table, they collide with
|
||||||
|
// state strings on other surfaces (see mobile-overview.js).
|
||||||
|
[
|
||||||
|
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
|
||||||
|
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
|
||||||
|
],
|
||||||
|
// The same while tiles can move, with the drag hint on a second line.
|
||||||
|
// Anchored on the hint, so a bare state word is safe here.
|
||||||
|
[
|
||||||
|
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||||
|
(_m, state, duration) =>
|
||||||
|
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [pattern, replacement] of patterns) {
|
for (const [pattern, replacement] of patterns) {
|
||||||
const match = source.match(pattern);
|
const match = source.match(pattern);
|
||||||
@@ -1033,6 +1299,23 @@
|
|||||||
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
return !element || Boolean(element.closest(SKIP_SELECTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
|
||||||
|
// a pane changes: thousands of mutation records a second with a grid of tiles,
|
||||||
|
// each paying a closest() over the whole skip list. All rows of one terminal
|
||||||
|
// share that parent, so its own shouldSkip() verdict is kept once it says
|
||||||
|
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
|
||||||
|
// its `.xterm`. A rows container that is not skipped is never kept: its rows
|
||||||
|
// go through the full check below like any other node.
|
||||||
|
const skippedRows = new WeakSet();
|
||||||
|
function isSkippedRow(node) {
|
||||||
|
const rows = node.parentNode;
|
||||||
|
if (!rows?.classList?.contains('xterm-rows')) return false;
|
||||||
|
if (skippedRows.has(rows)) return true;
|
||||||
|
if (!shouldSkip(rows)) return false;
|
||||||
|
skippedRows.add(rows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipText(node) {
|
function shouldSkipText(node) {
|
||||||
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
||||||
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
|
||||||
@@ -1128,6 +1411,13 @@
|
|||||||
observer = new MutationObserver((mutations) => {
|
observer = new MutationObserver((mutations) => {
|
||||||
if (applying) return;
|
if (applying) return;
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
|
// A change inside a skipped surface cannot need translating: every
|
||||||
|
// node it adds or edits sits under the same skip ancestor, so both
|
||||||
|
// translators would return on their own closest() check anyway. One
|
||||||
|
// check per record instead of one per text node and attribute matters
|
||||||
|
// for xterm's DOM renderer, which replaces rows every frame (the split
|
||||||
|
// pane, every tile of the grid).
|
||||||
|
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
|
||||||
if (mutation.type === 'characterData') translateNode(mutation.target);
|
if (mutation.type === 'characterData') translateNode(mutation.target);
|
||||||
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
if (mutation.type === 'attributes') translateAttributes(mutation.target);
|
||||||
for (const added of mutation.addedNodes) translateNode(added);
|
for (const added of mutation.addedNodes) translateNode(added);
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||||
_handleImagePaste() {
|
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||||
|
// default to the primary pane. A second terminal (the split pane) passes its
|
||||||
|
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||||
|
_handleImagePaste(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const sessionId = target.sessionId || this.activeSessionId;
|
||||||
|
|
||||||
// Create a hidden contenteditable div to receive the paste
|
// Create a hidden contenteditable div to receive the paste
|
||||||
const trap = document.createElement('div');
|
const trap = document.createElement('div');
|
||||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||||
// Refocus the terminal
|
// Refocus the terminal
|
||||||
if (self.terminal) self.terminal.focus();
|
if (terminal) terminal.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
self._uploadAndInsertImages(imageFiles);
|
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||||
} else {
|
} else {
|
||||||
// No image -- route text through xterm's paste() so bracketed-paste
|
// No image -- route text through xterm's paste() so bracketed-paste
|
||||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||||
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// indistinguishable from typed input, weakening the CLI's
|
// indistinguishable from typed input, weakening the CLI's
|
||||||
// prompt-injection defenses.
|
// prompt-injection defenses.
|
||||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
if (text && self.terminal) self.terminal.paste(text);
|
if (text && terminal) terminal.paste(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/** Upload a batch and normally insert its paths into the active terminal.
|
/** Upload a batch and normally insert its paths into the active terminal.
|
||||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||||
* into its textarea instead. Returns successful paths in selection order. */
|
* into its textarea instead. `options.sessionId` names the session to upload
|
||||||
|
* to (default: the active one). Returns successful paths in selection order. */
|
||||||
async _uploadAndInsertImages(fileList, options = {}) {
|
async _uploadAndInsertImages(fileList, options = {}) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = options.sessionId || this.activeSessionId;
|
||||||
if (!sessionId) return [];
|
if (!sessionId) return [];
|
||||||
|
|
||||||
let files = Array.from(fileList || []);
|
let files = Array.from(fileList || []);
|
||||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paths = results.filter(Boolean);
|
const paths = results.filter(Boolean);
|
||||||
if (paths.length > 0 && options.insert !== false) {
|
if (paths.length > 0 && options.insert !== false) {
|
||||||
// Insert all paths in one shot, space-separated, in selection order.
|
// Insert all paths in one shot, space-separated, in selection order, into
|
||||||
await this.sendInput(paths.join(' '));
|
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
||||||
|
// activeSessionId, and after the awaits above that is whatever tab the
|
||||||
|
// user switched to mid-upload, so the paths landed in the wrong session.
|
||||||
|
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
||||||
|
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
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
|
here and boot will contradict this value, animating the drawer open by
|
||||||
itself on every load between 768 and 1023px. -->
|
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) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
<style>
|
<style>
|
||||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
.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>
|
<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>
|
<span>Admin Panel</span>
|
||||||
</button>
|
</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">⊞</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">⊞</button>
|
||||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||||
<span class="tunnel-dot"></span>
|
<span class="tunnel-dot"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||||
<span class="connection-dot" id="connectionDot"></span>
|
<span class="connection-dot" id="connectionDot"></span>
|
||||||
<span class="connection-text" id="connectionText"></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>
|
||||||
<div class="header-font-controls">
|
<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>
|
<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>
|
||||||
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
|
<span class="stat-ring" id="statCpuRing" aria-hidden="true"></span>
|
||||||
<span class="stat-label">CPU</span>
|
<span class="stat-label">CPU</span>
|
||||||
<div class="stat-bar">
|
<div class="stat-bar">
|
||||||
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
|
||||||
@@ -169,6 +171,7 @@
|
|||||||
<span class="stat-value" id="statCpu">--%</span>
|
<span class="stat-value" id="statCpu">--%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
|
<span class="stat-ring" id="statMemRing" aria-hidden="true"></span>
|
||||||
<span class="stat-label">MEM</span>
|
<span class="stat-label">MEM</span>
|
||||||
<div class="stat-bar">
|
<div class="stat-bar">
|
||||||
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
|
||||||
@@ -189,9 +192,10 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||||
|
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||||
@@ -435,6 +439,12 @@
|
|||||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tile grid (tile-grid.js): 1 to 6 sessions side by side, each a
|
||||||
|
TerminalTile. A SIBLING of .terminal-wrap, never a parent: while
|
||||||
|
.main.tiles-active is set the main terminal is parked (hidden) and
|
||||||
|
this section takes its place. -->
|
||||||
|
<section class="tile-grid" id="tileGrid" aria-label="Tiled sessions"></section>
|
||||||
|
|
||||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||||
@@ -450,9 +460,14 @@
|
|||||||
<div class="welcome-content">
|
<div class="welcome-content">
|
||||||
<h1 class="welcome-title">Codeman</h1>
|
<h1 class="welcome-title">Codeman</h1>
|
||||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
<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-actions">
|
||||||
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
|
<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>
|
<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
|
Cloudflare Tunnel
|
||||||
</button>
|
</button>
|
||||||
@@ -690,11 +705,9 @@
|
|||||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||||
Enter
|
Enter
|
||||||
</button>
|
</button>
|
||||||
<div class="tab-count-group" title="Instance count">
|
<!-- Run Shell had a second, identical instance-count stepper here. The
|
||||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
toolbar carried two of them side by side, so it is gone and Run
|
||||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
Shell reads the one above (#tabCount) like the Run button does. -->
|
||||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
|
||||||
</div>
|
|
||||||
<div class="case-select-group">
|
<div class="case-select-group">
|
||||||
<div class="case-combobox" id="quickStartCasePicker">
|
<div class="case-combobox" id="quickStartCasePicker">
|
||||||
<input
|
<input
|
||||||
@@ -714,8 +727,9 @@
|
|||||||
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
|
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
|
||||||
<option value="testcase">testcase</option>
|
<option value="testcase">testcase</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
<!-- "New or link a case…" and "Case settings…" are the last two rows of
|
||||||
<button class="btn-case-settings" onclick="app.toggleCaseSettings()" title="Case settings">⚙</button>
|
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">
|
<div class="case-settings-popover hidden" id="caseSettingsPopover">
|
||||||
<label class="checkbox-inline">
|
<label class="checkbox-inline">
|
||||||
<input type="checkbox" id="caseAgentTeams" onchange="app.onCaseSettingChanged()">
|
<input type="checkbox" id="caseAgentTeams" onchange="app.onCaseSettingChanged()">
|
||||||
@@ -807,7 +821,6 @@
|
|||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
<h4>Session</h4>
|
<h4>Session</h4>
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
|
||||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||||
@@ -822,11 +835,23 @@
|
|||||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||||
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
||||||
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
||||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
<div><kbd data-i18n-skip>Home</kbd></div><div>Focus First Tab</div>
|
||||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="shortcut-section">
|
||||||
|
<h4>Tiles</h4>
|
||||||
|
<div class="shortcuts-grid">
|
||||||
|
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></div><div>Toggle Tile Grid</div>
|
||||||
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||||
|
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||||
|
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||||
|
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||||
|
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||||
|
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="shortcut-section">
|
<section class="shortcut-section">
|
||||||
<h4>Terminal</h4>
|
<h4>Terminal</h4>
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
@@ -1904,7 +1929,7 @@
|
|||||||
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
|
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
|
||||||
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
|
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw">
|
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
||||||
@@ -1914,10 +1939,22 @@
|
|||||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||||
|
<label class="set-chip" data-search="tiles tile grid side by side several sessions" data-preview="header" data-preview-order="11.6"><input type="checkbox" id="appSettingsShowTileGridButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg><span>Tiles</span></label>
|
||||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
<label class="set-chip" data-preview="header" data-preview-order="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>
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1972,6 +2009,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2070,6 +2121,28 @@
|
|||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
|
||||||
<div class="set-group-body">
|
<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 has-field" data-search="tab orientation horizontal vertical side rail">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Tab Orientation</span>
|
<span class="set-row-label">Tab Orientation</span>
|
||||||
@@ -2093,7 +2166,7 @@
|
|||||||
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Vertical Rail Order</span>
|
<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>
|
</div>
|
||||||
<select id="appSettingsTabRailSort" class="set-select">
|
<select id="appSettingsTabRailSort" class="set-select">
|
||||||
<option value="activity">By activity (home screen order)</option>
|
<option value="activity">By activity (home screen order)</option>
|
||||||
@@ -2153,7 +2226,7 @@
|
|||||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
<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>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2527,6 +2600,30 @@
|
|||||||
<div class="set-group" id="appSettingsCodexGroup">
|
<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-head"><h4>Codex</h4><span class="set-scope">synced</span></div>
|
||||||
<div class="set-group-body">
|
<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" data-search="codex bypass approvals sandbox">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Bypass approvals and sandbox</span>
|
<span class="set-row-label">Bypass approvals and sandbox</span>
|
||||||
@@ -2626,6 +2723,26 @@
|
|||||||
<span class="set-unit">min</span>
|
<span class="set-unit">min</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="toast display time popup dismiss seconds notification">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Toast display time</span>
|
||||||
|
<span class="set-row-desc">How long the corner pop-ups stay on screen.</span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row-actions">
|
||||||
|
<input type="number" id="appSettingsNotifToastSecs" class="set-num" value="3" min="1" max="300">
|
||||||
|
<span class="set-unit">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" data-search="browser notification auto close dismiss seconds">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Browser notification display time</span>
|
||||||
|
<span class="set-row-desc">How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.</span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row-actions">
|
||||||
|
<input type="number" id="appSettingsNotifBrowserSecs" class="set-num" value="8" min="1" max="300">
|
||||||
|
<span class="set-unit">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2933,10 +3050,6 @@
|
|||||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -3919,7 +4032,9 @@
|
|||||||
<script defer src="app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
<script defer src="tab-rail-resize.js"></script>
|
<script defer src="tab-rail-resize.js"></script>
|
||||||
<script defer src="terminal-ui.js"></script>
|
<script defer src="terminal-ui.js"></script>
|
||||||
|
<script defer src="terminal-tile.js"></script>
|
||||||
<script defer src="terminal-split.js"></script>
|
<script defer src="terminal-split.js"></script>
|
||||||
|
<script defer src="tile-grid.js"></script>
|
||||||
<script defer src="respawn-ui.js"></script>
|
<script defer src="respawn-ui.js"></script>
|
||||||
<script defer src="ralph-panel.js"></script>
|
<script defer src="ralph-panel.js"></script>
|
||||||
<script defer src="orchestrator-panel.js"></script>
|
<script defer src="orchestrator-panel.js"></script>
|
||||||
@@ -3944,5 +4059,6 @@
|
|||||||
<script defer src="ultracode-windows.js"></script>
|
<script defer src="ultracode-windows.js"></script>
|
||||||
<script defer src="session-lineage.js"></script>
|
<script defer src="session-lineage.js"></script>
|
||||||
<script defer src="image-input.js"></script>
|
<script defer src="image-input.js"></script>
|
||||||
|
<script defer src="spreadsheet-preview.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -410,6 +410,12 @@ const KeyboardHandler = {
|
|||||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
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) {
|
if (isSmallMedium) {
|
||||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||||
// via CSS. Use translateY to lift them above the keyboard.
|
// via CSS. Use translateY to lift them above the keyboard.
|
||||||
@@ -426,6 +432,9 @@ const KeyboardHandler = {
|
|||||||
if (accessoryBar) {
|
if (accessoryBar) {
|
||||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||||
|
}
|
||||||
if (main && keyboardHeight > 0) {
|
if (main && keyboardHeight > 0) {
|
||||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||||
@@ -436,6 +445,9 @@ const KeyboardHandler = {
|
|||||||
if (accessoryBar) {
|
if (accessoryBar) {
|
||||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.bottom = `${keyboardHeight}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CJK textarea positioning (always position:fixed on touch devices).
|
// CJK textarea positioning (always position:fixed on touch devices).
|
||||||
@@ -464,6 +476,10 @@ const KeyboardHandler = {
|
|||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||||
const cjkInput = document.getElementById('cjkInput');
|
const cjkInput = document.getElementById('cjkInput');
|
||||||
const main = document.querySelector('.main');
|
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) {
|
if (toolbar) {
|
||||||
toolbar.style.transform = '';
|
toolbar.style.transform = '';
|
||||||
@@ -476,6 +492,10 @@ const KeyboardHandler = {
|
|||||||
cjkInput.style.transform = '';
|
cjkInput.style.transform = '';
|
||||||
cjkInput.style.bottom = '';
|
cjkInput.style.bottom = '';
|
||||||
}
|
}
|
||||||
|
if (caseSheet) {
|
||||||
|
caseSheet.style.transform = '';
|
||||||
|
caseSheet.style.bottom = '';
|
||||||
|
}
|
||||||
if (main) {
|
if (main) {
|
||||||
main.style.paddingBottom = '';
|
main.style.paddingBottom = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel {
|
|||||||
|
|
||||||
/* No "open in new window" (detach) on phones/tablets — popped-out browser
|
/* No "open in new window" (detach) on phones/tablets — popped-out browser
|
||||||
windows aren't usable there. !important beats the hover/detached reveal
|
windows aren't usable there. !important beats the hover/detached reveal
|
||||||
rules in styles.css */
|
rules in styles.css. A native wrapper that opens windows of its own (side
|
||||||
.session-tab .tab-detach {
|
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;
|
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).
|
/* 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
|
Registered so the keyframes can interpolate them as lengths; @property is
|
||||||
only valid at the top level, hence out here. */
|
only valid at the top level, hence out here. */
|
||||||
@@ -755,6 +785,31 @@ html.mobile-init .file-browser-panel {
|
|||||||
font-weight: 500;
|
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
|
/* 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
|
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. */
|
made the chip visibly wider on the right than on the left. */
|
||||||
@@ -852,6 +907,13 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: none;
|
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 */
|
/* Gear icon on active tab - tiny, subtle */
|
||||||
.session-tab.active .tab-gear {
|
.session-tab.active .tab-gear {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1449,15 +1511,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
padding: 6px 14px;
|
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 */
|
/* When keyboard is visible, also move accessory bar up */
|
||||||
.keyboard-visible .keyboard-accessory-bar.visible {
|
.keyboard-visible .keyboard-accessory-bar.visible {
|
||||||
/* Position is handled by JS transform along with toolbar */
|
/* Position is handled by JS transform along with toolbar */
|
||||||
@@ -1471,33 +1524,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: none !important;
|
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 */
|
/* Panels on mobile - full width, positioned above toolbar, visibility controlled by JS */
|
||||||
.monitor-panel,
|
.monitor-panel,
|
||||||
.subagents-panel {
|
.subagents-panel {
|
||||||
@@ -1722,17 +1748,24 @@ html.mobile-init .file-browser-panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-actions {
|
.welcome-actions {
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
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%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0.75rem 1rem;
|
font-size: 0.9rem;
|
||||||
font-size: 0.85rem;
|
}
|
||||||
|
|
||||||
|
.welcome-chip {
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-show-more {
|
.history-show-more {
|
||||||
@@ -2289,6 +2322,18 @@ html.mobile-init .file-browser-panel {
|
|||||||
font-size: 1.5rem;
|
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 {
|
.mobile-case-picker-footer {
|
||||||
padding-bottom: calc(12px + var(--safe-area-bottom));
|
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
|
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;
|
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. */
|
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);
|
background: var(--control-bg);
|
||||||
border-color: var(--control-border);
|
border-color: var(--control-border);
|
||||||
color: var(--text-dim);
|
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);
|
background: var(--control-bg-hover);
|
||||||
border-color: var(--control-border-hover);
|
border-color: var(--control-border-hover);
|
||||||
color: var(--text);
|
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));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* The NotificationManager class implements five notification layers:
|
* The NotificationManager class implements five notification layers:
|
||||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||||
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
||||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
* 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications)
|
||||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||||
*
|
*
|
||||||
@@ -93,6 +93,10 @@ class NotificationManager {
|
|||||||
browserNotifications: !isMobile,
|
browserNotifications: !isMobile,
|
||||||
audioAlerts: false,
|
audioAlerts: false,
|
||||||
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
||||||
|
// How long a corner toast stays on screen, and how long a browser notification
|
||||||
|
// stays up before Codeman closes it (ms; per-device like the rest of these)
|
||||||
|
toastDurationMs: DEFAULT_TOAST_DURATION_MS,
|
||||||
|
browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS,
|
||||||
// Legacy urgency muting (keep for backwards compat)
|
// Legacy urgency muting (keep for backwards compat)
|
||||||
muteCritical: false,
|
muteCritical: false,
|
||||||
muteWarning: false,
|
muteWarning: false,
|
||||||
@@ -167,11 +171,24 @@ class NotificationManager {
|
|||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...prefs,
|
...prefs,
|
||||||
|
toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs),
|
||||||
|
browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs),
|
||||||
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
||||||
_version: 5,
|
_version: 5,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */
|
||||||
|
clampDuration(value, fallback) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display time for corner toasts that do not set their own `duration`. */
|
||||||
|
getToastDurationMs() {
|
||||||
|
return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
loadPreferences() {
|
loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const storageKey = this.getStorageKey();
|
const storageKey = this.getStorageKey();
|
||||||
@@ -403,7 +420,7 @@ class NotificationManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-close
|
// Auto-close
|
||||||
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
|
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission() {
|
async requestPermission() {
|
||||||
@@ -466,7 +483,7 @@ class NotificationManager {
|
|||||||
notif.read = true;
|
notif.read = true;
|
||||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||||
this.updateBadge();
|
this.updateBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to session if available
|
// Switch to session if available
|
||||||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||||||
|
|||||||
@@ -4178,6 +4178,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
} else if (ext === 'docx' || ext === 'pptx') {
|
} else if (ext === 'docx' || ext === 'pptx') {
|
||||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} else if (ext === 'xlsx') {
|
||||||
|
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// Bounded like the workspace text preview: a Range for the first
|
// Bounded like the workspace text preview: a Range for the first
|
||||||
@@ -4274,6 +4276,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} else if (data.type === 'audio') {
|
} else if (data.type === 'audio') {
|
||||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
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') {
|
} else if (data.type === 'binary') {
|
||||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
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>`;
|
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() {
|
detachFilePreview() {
|
||||||
if (!this.filePreviewDetachUrl) return;
|
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');
|
const win = window.open(this.filePreviewDetachUrl, '_blank');
|
||||||
if (!win) {
|
if (!win) {
|
||||||
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
|
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.
|
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
||||||
*/
|
*/
|
||||||
_stopFilePreviewMedia() {
|
_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');
|
const bodyEl = this.$('filePreviewBody');
|
||||||
if (!bodyEl) return;
|
if (!bodyEl) return;
|
||||||
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
||||||
@@ -4361,6 +4375,42 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = '';
|
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
|
// 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 class="attachment-history-empty-title">No attachments yet</div>
|
||||||
<div>Show a file here by running:</div>
|
<div>Show a file here by running:</div>
|
||||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -5812,7 +5862,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `duration` defaults to 3000ms for every toast type. A message worth
|
* `duration` defaults to the "Toast display time" preference (3000ms unless changed in
|
||||||
|
* Settings → Notifications) for every toast type. A message worth
|
||||||
* reading rather than glancing at (e.g. "Session started on the native
|
* reading rather than glancing at (e.g. "Session started on the native
|
||||||
* backend — could not apply the custom endpoint: <the actual reason>")
|
* backend — could not apply the custom endpoint: <the actual reason>")
|
||||||
* passes an explicit `opts.duration: 0` at its own call site instead of
|
* passes an explicit `opts.duration: 0` at its own call site instead of
|
||||||
@@ -5824,7 +5875,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* regardless of duration.
|
* regardless of duration.
|
||||||
*/
|
*/
|
||||||
showToast(message, type = 'info', opts = {}) {
|
showToast(message, type = 'info', opts = {}) {
|
||||||
const { duration = 3000, action } = opts;
|
const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts;
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
|
|
||||||
@@ -6028,6 +6079,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) {
|
if (memEl && memBar) {
|
||||||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||||||
memEl.textContent = `${memGB}G`;
|
memEl.textContent = `${memGB}G`;
|
||||||
@@ -6045,6 +6101,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 ──────────────────────────────────────────────────────────────
|
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async _onClipboardWrite(data) {
|
async _onClipboardWrite(data) {
|
||||||
|
|||||||
@@ -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,
|
* 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
|
* 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
|
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
||||||
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
||||||
* glance which tab spawned which.
|
* 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
|
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||||
*
|
*
|
||||||
* Two constraints that are not obvious from the code:
|
* Constraints that are not obvious from the code:
|
||||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
* - 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
|
* 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
|
* 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.
|
* 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
|
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
* @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)
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||||
*/
|
*/
|
||||||
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
applyLineageLineSettings() {
|
applyLineageLineSettings() {
|
||||||
const prev = this._lineageLinesOn;
|
const prev = this._lineageLinesOn;
|
||||||
const next = this._syncLineageLinesEnabled();
|
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
|
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
||||||
* (that index is what nests sibling arcs instead of overprinting them).
|
* 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
|
* Keyed on whether any family exists at all, never on which one is selected: a
|
||||||
* strip's own left-to-right order, which is what the user sees.
|
* 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() {
|
_collectLineageEdges() {
|
||||||
const edges = [];
|
const edges = [];
|
||||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||||
const seenPerParent = new Map();
|
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const parentId = session && session.parentSessionId;
|
const parentId = session && session.parentSessionId;
|
||||||
// A parent that is gone (closed, or never came back after a restart) draws
|
// 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.
|
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||||
const depth = seenPerParent.get(parentId) || 0;
|
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
||||||
seenPerParent.set(parentId, depth + 1);
|
|
||||||
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
|
||||||
}
|
}
|
||||||
return edges;
|
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
|
* 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
|
* 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
|
* 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
|
* 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.
|
* generation while each generation's fan-out stays uniform.
|
||||||
*
|
*
|
||||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
* 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
|
* 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
|
* 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.
|
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
||||||
*/
|
*/
|
||||||
_lineageColorFor(parentId) {
|
_lineageColorFor(parentId) {
|
||||||
@@ -140,19 +202,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_appendLineageConnectionLines(svg, rects) {
|
_appendLineageConnectionLines(svg, rects) {
|
||||||
this._lineageEdgeCount = 0;
|
this._lineageEdgeCount = 0;
|
||||||
if (!svg || !this._lineageLinesEnabled()) return;
|
if (!svg || !this._lineageLinesEnabled()) return;
|
||||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
||||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
||||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
// channel; parent/child adjacency reads fine there without lines.
|
||||||
// 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.
|
|
||||||
if (this.isSessionSidebarActive?.()) return;
|
if (this.isSessionSidebarActive?.()) return;
|
||||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
||||||
if (!compute) return;
|
if (!computeTree) return;
|
||||||
|
|
||||||
const edges = this._collectLineageEdges();
|
const edges = this._collectLineageEdges();
|
||||||
|
this._lineageTotalEdges = edges.length;
|
||||||
if (edges.length === 0) return;
|
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();
|
if (!rects) rects = new Map();
|
||||||
|
|
||||||
// PHASE 1 — reads.
|
// PHASE 1 — reads.
|
||||||
@@ -162,9 +225,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const orientation =
|
const orientation =
|
||||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
// 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
|
// to anchor to, so its end of the line moves to that group's header (a
|
||||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
||||||
// header would be an arc from a row to itself: skipped.
|
// its parent would be a line from a row to itself: skipped.
|
||||||
const resolveEndpoint = (id) => {
|
const resolveEndpoint = (id) => {
|
||||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
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)}"]`);
|
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||||
};
|
};
|
||||||
const resolvedEdges = [];
|
const measure = (endpoint) => {
|
||||||
for (const edge of edges) {
|
if (!rects.has(endpoint.key)) {
|
||||||
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;
|
|
||||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
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.
|
// PHASE 2 — writes, from the cache only.
|
||||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
||||||
const parentRect = rects.get(parentEndpoint.key);
|
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
||||||
const childRect = rects.get(childEndpoint.key);
|
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
||||||
if (!parentRect || !childRect) continue;
|
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
||||||
|
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
||||||
const geom = compute({
|
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,
|
parent: parentRect,
|
||||||
child: childRect,
|
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
||||||
strip: stripRect,
|
strip: stripRect,
|
||||||
depth: edge.depth,
|
tabs: tabRects,
|
||||||
|
spineLeft,
|
||||||
orientation,
|
orientation,
|
||||||
|
lane,
|
||||||
|
laneCount,
|
||||||
});
|
});
|
||||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
if (!geom || geom.routes.length === 0) continue;
|
||||||
|
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
||||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
const color = this._lineageColorFor(family.parentId);
|
||||||
line.setAttribute('d', geom.d);
|
// One group per family: it carries the translucency, so the stretches its
|
||||||
// The working class marches the dashes, so an active worker is visible along
|
// routes share (the trunk) do not stack into a brighter line than the branches,
|
||||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
// and the emphasis for the selected tab's families (styles.css).
|
||||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
||||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
group.setAttribute('data-parent-tab', family.parentId);
|
||||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
||||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
// shared trunk and only the working child's own branch shows its dashes.
|
||||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
const routes = geom.routes
|
||||||
const color = this._lineageColorFor(edge.parentId);
|
.map((route) => ({ route, child: byId.get(route.id) }))
|
||||||
if (color) line.style.setProperty('--lineage-color', color);
|
.filter((r) => r.child)
|
||||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
||||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
for (const { route, child } of routes) {
|
||||||
line.setAttribute('data-parent-tab', edge.parentId);
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
line.setAttribute('data-child-tab', edge.childId);
|
line.setAttribute('d', route.d);
|
||||||
svg.appendChild(line);
|
// `status` is the CHILD's, which is the interesting end: a working child's
|
||||||
|
// route is dashed (and marches, motion permitting).
|
||||||
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||||
// markers need a <defs> block and fight the dash pattern.
|
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
line.setAttribute(
|
||||||
dot.setAttribute('cx', String(geom.endX));
|
'class',
|
||||||
dot.setAttribute('cy', String(geom.endY));
|
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
||||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
);
|
||||||
// works, so the two have to be changed together.
|
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
||||||
dot.setAttribute('r', '3.5');
|
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
if (color) line.style.setProperty('--lineage-color', color);
|
||||||
dot.setAttribute('data-child-tab', edge.childId);
|
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||||
if (color) dot.style.setProperty('--lineage-color', color);
|
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
||||||
svg.appendChild(dot);
|
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
|
* 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
|
* scroll moves both endpoints without firing any render, so the lines would slide
|
||||||
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
* 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.
|
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||||
*/
|
*/
|
||||||
@@ -248,11 +378,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._lineageScrollHandler = () => {
|
this._lineageScrollHandler = () => {
|
||||||
// Sidebar layout and the vertical rail scroll the SAME element
|
// Sidebar layout and the vertical rail scroll the SAME element
|
||||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
// rects too (the sidebar skips lineage entirely, and the rail can show
|
||||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
||||||
// would never redraw them).
|
// never redraw them).
|
||||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||||
};
|
};
|
||||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -125,6 +125,17 @@ const RUN_MODE_LAUNCH = {
|
|||||||
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
||||||
const CASE_PICKER_REFRESH_MS = 5000;
|
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 EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...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';
|
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, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Build envOverrides payload from case + global settings.
|
* Build envOverrides payload from case + global settings.
|
||||||
@@ -343,13 +361,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
|
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
|
||||||
const selectedName = select.value || 'testcase';
|
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);
|
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) {
|
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');
|
list.classList.remove('hidden');
|
||||||
input.removeAttribute('aria-activedescendant');
|
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,11 +409,22 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('') + actionsBlock;
|
||||||
list.classList.remove('hidden');
|
list.classList.remove('hidden');
|
||||||
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
|
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 } = {}) {
|
selectQuickStartCase(caseName, { save = true } = {}) {
|
||||||
const select = document.getElementById('quickStartCase');
|
const select = document.getElementById('quickStartCase');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@@ -419,18 +467,26 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
|
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
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);
|
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
|
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
|
||||||
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
|
||||||
} else if (event.key === 'Enter') {
|
} 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) {
|
if (option) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.selectQuickStartCase(option.name);
|
this.selectQuickStartCase(option.name);
|
||||||
this.run?.();
|
this.run?.();
|
||||||
|
} else if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.runCasePickerAction(action.id);
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -443,6 +499,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
list.addEventListener('mousedown', event => event.preventDefault());
|
list.addEventListener('mousedown', event => event.preventDefault());
|
||||||
list.addEventListener('click', event => {
|
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');
|
const option = event.target.closest?.('.case-combobox-option');
|
||||||
if (option?.dataset?.case) {
|
if (option?.dataset?.case) {
|
||||||
this.selectQuickStartCase(option.dataset.case);
|
this.selectQuickStartCase(option.dataset.case);
|
||||||
@@ -573,6 +634,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session?.id) this._onSessionCreated(session);
|
if (session?.id) this._onSessionCreated(session);
|
||||||
|
// A session this tab's Run created joins an open tile grid (tile-grid.js),
|
||||||
|
// so Run's selectSession() below focuses its tile instead of leaving the
|
||||||
|
// grid. Only here: sessions created elsewhere arrive by session:created.
|
||||||
|
this._joinTileGridFromRun?.(sessionId);
|
||||||
// session:created normally uses the debounced renderer. The direct POST path
|
// session:created normally uses the debounced renderer. The direct POST path
|
||||||
// needs the tab in the DOM before selectSession() marks it active.
|
// needs the tab in the DOM before selectSession() marks it active.
|
||||||
this._renderSessionTabsImmediate?.();
|
this._renderSessionTabsImmediate?.();
|
||||||
@@ -1799,19 +1864,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
input.value = Math.max(1, current - 1);
|
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>,
|
// 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
|
// 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.
|
// remote/docker launch paths so all tabs follow the same naming convention.
|
||||||
@@ -2078,7 +2130,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
async runShell() {
|
async runShell() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
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(
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||||
@@ -2430,7 +2484,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// 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');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
@@ -3090,7 +3144,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Close on outside click (one-shot listener)
|
// Close on outside click (one-shot listener)
|
||||||
const closeHandler = (e) => {
|
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');
|
popover.classList.add('hidden');
|
||||||
document.removeEventListener('click', closeHandler);
|
document.removeEventListener('click', closeHandler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Header visibility settings
|
// Header visibility settings
|
||||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
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('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||||
@@ -430,6 +431,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||||
|
document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
|
||||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||||
@@ -453,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||||
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
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)
|
// 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
|
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||||
@@ -500,7 +504,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
|
||||||
document.getElementById('appSettingsTabRailSort').value =
|
document.getElementById('appSettingsTabRailSort').value =
|
||||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
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 =
|
document.getElementById('appSettingsSessionListLayout').value =
|
||||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||||
@@ -526,6 +534,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.codexDangerouslyBypassApprovals ?? false;
|
settings.codexDangerouslyBypassApprovals ?? false;
|
||||||
document.getElementById('appSettingsCodexAnimations').checked =
|
document.getElementById('appSettingsCodexAnimations').checked =
|
||||||
settings.codexAnimationsEnabled ?? false;
|
settings.codexAnimationsEnabled ?? false;
|
||||||
|
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
|
||||||
|
document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? '';
|
||||||
this._applyCodexSettingsVisibility();
|
this._applyCodexSettingsVisibility();
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||||
@@ -553,6 +563,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
|
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
|
||||||
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
|
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
|
||||||
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
|
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
|
||||||
|
document.getElementById('appSettingsNotifToastSecs').value = Math.round(
|
||||||
|
(this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000
|
||||||
|
);
|
||||||
|
document.getElementById('appSettingsNotifBrowserSecs').value = Math.round(
|
||||||
|
(notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000
|
||||||
|
);
|
||||||
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
|
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
|
||||||
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
|
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
|
||||||
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
|
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
|
||||||
@@ -1641,40 +1657,88 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return flags[tool] !== false;
|
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() {
|
renderWelcomeCliActions() {
|
||||||
const container = document.getElementById('welcomeCliActions');
|
const container = document.getElementById('welcomeCliActions');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
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();
|
container.replaceChildren();
|
||||||
for (const cli of catalog) {
|
if (!primary) return;
|
||||||
if (!cli.enabled || !this.isCliAvailable(cli.id)) continue;
|
|
||||||
const btn = document.createElement('button');
|
const main = document.createElement('button');
|
||||||
btn.type = 'button';
|
main.type = 'button';
|
||||||
btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`;
|
main.className = 'welcome-primary';
|
||||||
btn.dataset.mode = cli.id;
|
main.dataset.mode = primary.id;
|
||||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
// The mark sits on a small light disc: brand marks (Claude's is orange) turn
|
||||||
icon.setAttribute('width', '20');
|
// muddy straight on the accent fill, and the disc reads on every skin.
|
||||||
icon.setAttribute('height', '20');
|
const disc = document.createElement('span');
|
||||||
icon.setAttribute('viewBox', '0 0 24 24');
|
disc.className = 'welcome-primary-logo';
|
||||||
icon.setAttribute('fill', 'none');
|
disc.appendChild(logo(primary));
|
||||||
icon.setAttribute('stroke', 'currentColor');
|
main.appendChild(disc);
|
||||||
icon.setAttribute('stroke-width', '2');
|
// One raw string, kept whole: i18n.js matches the exact text node. The span
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
// only lets a long custom label ellipsize (styles.css .welcome-label).
|
||||||
const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
const mainLabel = document.createElement('span');
|
||||||
play.setAttribute('points', '5 3 19 12 5 21 5 3');
|
mainLabel.className = 'welcome-label';
|
||||||
icon.appendChild(play);
|
mainLabel.textContent = runLabel(primary);
|
||||||
btn.appendChild(icon);
|
main.appendChild(mainLabel);
|
||||||
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
|
main.onclick = launch(primary);
|
||||||
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
|
container.appendChild(main);
|
||||||
// label simply has no dictionary entry, so it renders as typed.
|
|
||||||
btn.append(`Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`);
|
const rest = offered.filter((cli) => cli !== primary);
|
||||||
btn.onclick = () => {
|
if (!rest.length) return;
|
||||||
this.setRunMode(cli.id);
|
const chips = document.createElement('div');
|
||||||
void this.run();
|
chips.className = 'welcome-chips';
|
||||||
};
|
chips.setAttribute('role', 'group');
|
||||||
container.appendChild(btn);
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1708,17 +1772,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
_updateTunnelUrlDisplay(url) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById('tunnelUrlRow');
|
||||||
const display = document.getElementById(displayId);
|
const display = document.getElementById('tunnelUrlDisplay');
|
||||||
if (!row || !display) return;
|
if (!row || !display) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url + suffix;
|
|
||||||
row.style.display = '';
|
row.style.display = '';
|
||||||
display.textContent = fullUrl;
|
display.textContent = url;
|
||||||
display.onclick = () => {
|
display.onclick = () => {
|
||||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
this.showToast('Tunnel URL copied', 'success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -1728,11 +1791,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlDisplay(url) {
|
|
||||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
|
||||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
|
||||||
},
|
|
||||||
|
|
||||||
showTunnelQR() {
|
showTunnelQR() {
|
||||||
// Close existing popup if open
|
// Close existing popup if open
|
||||||
this.closeTunnelQR();
|
this.closeTunnelQR();
|
||||||
@@ -2475,6 +2533,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Header visibility settings
|
// Header visibility settings
|
||||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||||
|
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
|
||||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||||
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
|
||||||
@@ -2492,6 +2551,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||||
|
showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked,
|
||||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
@@ -2502,6 +2562,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').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,
|
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
@@ -2524,6 +2587,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||||
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
|
||||||
|
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||||
|
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||||
@@ -2534,6 +2599,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
// Codex CLI settings
|
// Codex CLI settings
|
||||||
|
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
|
||||||
|
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
|
||||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
@@ -2553,6 +2620,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
|
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
|
||||||
// UI, but their features still read settings.showTokenCount / settings.showCost
|
// UI, but their features still read settings.showTokenCount / settings.showCost
|
||||||
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
||||||
@@ -2597,6 +2673,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
|
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
|
||||||
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
|
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
|
||||||
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
|
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
|
||||||
|
toastDurationMs: (parseInt(document.getElementById('appSettingsNotifToastSecs').value) || 3) * 1000,
|
||||||
|
browserAutoCloseMs: (parseInt(document.getElementById('appSettingsNotifBrowserSecs').value) || 8) * 1000,
|
||||||
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
|
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
|
||||||
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
|
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
|
||||||
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
|
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
|
||||||
@@ -2666,7 +2744,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_version: 5,
|
_version: 5,
|
||||||
};
|
};
|
||||||
if (this.notificationManager) {
|
if (this.notificationManager) {
|
||||||
this.notificationManager.preferences = notifPrefsToSave;
|
this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave);
|
||||||
this.notificationManager.savePreferences();
|
this.notificationManager.savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2730,6 +2808,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||||
// (moving it into displayKeys alone is not the strip; this is).
|
// (moving it into displayKeys alone is not the strip; this is).
|
||||||
showSplitButton: _ssp,
|
showSplitButton: _ssp,
|
||||||
|
// Same as Split: a per-device header button (and the Tiles chord), absent
|
||||||
|
// from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT.
|
||||||
|
showTileGridButton: _stg,
|
||||||
webglRendererEnabled: _wgl,
|
webglRendererEnabled: _wgl,
|
||||||
terminalWheelLocalScrollback: _twls,
|
terminalWheelLocalScrollback: _twls,
|
||||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||||
@@ -2757,6 +2838,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||||
showGitStatus: _sgs,
|
showGitStatus: _sgs,
|
||||||
gitStatusTree: _gst,
|
gitStatusTree: _gst,
|
||||||
|
gitStatusMaxRepos: _gsm,
|
||||||
|
gitStatusTimeoutSeconds: _gst2,
|
||||||
showTabDetachButton: _tdb,
|
showTabDetachButton: _tdb,
|
||||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
mobileOverviewEnabled: _mov,
|
mobileOverviewEnabled: _mov,
|
||||||
@@ -2764,6 +2847,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// .strict() schema — syncing it would push a desktop-shaped choice onto
|
// .strict() schema — syncing it would push a desktop-shaped choice onto
|
||||||
// devices that cannot render it at all.
|
// devices that cannot render it at all.
|
||||||
sessionLineageLines: _sll,
|
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
|
...serverSettings
|
||||||
} = settings;
|
} = settings;
|
||||||
let webhookError = '';
|
let webhookError = '';
|
||||||
@@ -3160,12 +3250,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
||||||
_syncCliLaunchCatalog() {
|
_syncCliLaunchCatalog() {
|
||||||
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
|
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) => ({
|
window.__codemanCliCatalog = this._cliList.map((cli) => ({
|
||||||
id: cli.id,
|
id: cli.id,
|
||||||
label: cli.label,
|
label: cli.label,
|
||||||
shortBadge: cli.shortBadge,
|
shortBadge: cli.shortBadge,
|
||||||
order: cli.order,
|
order: cli.order,
|
||||||
kind: cli.kind,
|
kind: cli.kind,
|
||||||
|
external: previous.get(cli.id)?.external,
|
||||||
enabled: cli.enabled,
|
enabled: cli.enabled,
|
||||||
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
||||||
}));
|
}));
|
||||||
@@ -3420,6 +3516,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ultracodeFloatingWindows: false,
|
ultracodeFloatingWindows: false,
|
||||||
showMultiMonitorButton: false,
|
showMultiMonitorButton: false,
|
||||||
showSplitButton: false,
|
showSplitButton: false,
|
||||||
|
showTileGridButton: false,
|
||||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||||
// policy guard keeps passing.
|
// policy guard keeps passing.
|
||||||
@@ -3448,6 +3545,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tabRailWidth: 256,
|
tabRailWidth: 256,
|
||||||
tabRailDetail: 'rich',
|
tabRailDetail: 'rich',
|
||||||
tabRailSort: 'activity',
|
tabRailSort: 'activity',
|
||||||
|
tabArrangement: 'classic',
|
||||||
|
tabStateOrder: 'urgent-first',
|
||||||
sessionListLayout: 'header',
|
sessionListLayout: 'header',
|
||||||
sessionSidebarFontSize: 12,
|
sessionSidebarFontSize: 12,
|
||||||
cjkInputEnabled: false,
|
cjkInputEnabled: false,
|
||||||
@@ -3458,7 +3557,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
// Desktop defaults - rely on ?? operators in apply functions
|
// Desktop defaults - rely on ?? operators in apply functions
|
||||||
// This allows desktop to have different defaults without duplication
|
// 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() {
|
loadAppSettingsFromStorage() {
|
||||||
@@ -3475,8 +3581,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load app settings:', err);
|
console.error('Failed to load app settings:', err);
|
||||||
}
|
}
|
||||||
// Return device-specific defaults
|
// Return device-specific defaults, without showTileGridButton: its default
|
||||||
this._cachedAppSettings = this.getDefaultSettings();
|
// 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;
|
return this._cachedAppSettings;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3522,6 +3635,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (result && this.notificationManager) {
|
if (result && this.notificationManager) {
|
||||||
this.notificationManager.originalTitle = document.title;
|
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,
|
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||||
@@ -3549,6 +3666,81 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return now === before ? undefined : now;
|
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() {
|
applyHeaderVisibilitySettings() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
@@ -3557,7 +3749,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
|
// 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
|
// .tab-detach without it (a tab that is already detached keeps its icon as
|
||||||
// the re-focus affordance for the popped-out window).
|
// 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);
|
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
||||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||||
@@ -3579,6 +3775,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (tokenCountEl) {
|
if (tokenCountEl) {
|
||||||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
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
|
// Hide lifecycle log button when setting is disabled
|
||||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||||
@@ -3632,6 +3834,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||||
this._applySplitButtonVisibility?.(showSplitButton);
|
this._applySplitButtonVisibility?.(showSplitButton);
|
||||||
|
|
||||||
|
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||||
|
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
|
||||||
|
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||||
|
|
||||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||||
// from the mobile-header-buttons-policy guard.
|
// from the mobile-header-buttons-policy guard.
|
||||||
@@ -3746,6 +3952,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
|
||||||
root.dataset.tabRailSort = sort;
|
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 tabsEl = document.getElementById('sessionTabs');
|
||||||
const rail = document.getElementById('tabRail');
|
const rail = document.getElementById('tabRail');
|
||||||
const headerHost = document.getElementById('sessionTabsHost');
|
const headerHost = document.getElementById('sessionTabsHost');
|
||||||
@@ -3769,7 +3988,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
// the row template, not toggled by CSS — same reasoning as the sidebar's
|
||||||
// detail half in applySessionListLayout(). Taller rows also move every
|
// detail half in applySessionListLayout(). Taller rows also move every
|
||||||
// connector anchored to a tab rect.
|
// 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) {
|
if (orientationChanged) {
|
||||||
this.updateTabOverflowMode?.();
|
this.updateTabOverflowMode?.();
|
||||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||||
@@ -4039,20 +4258,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||||
// are NOT display keys — they control server-side behavior and must sync from server.
|
// are NOT display keys — they control server-side behavior and must sync from server.
|
||||||
const displayKeys = new Set([
|
const displayKeys = new Set([
|
||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'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',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection', 'copyStripMargin',
|
'autoCopySelection', 'copyStripMargin',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
'showSplitButton',
|
'showSplitButton',
|
||||||
|
'showTileGridButton',
|
||||||
]);
|
]);
|
||||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||||
|
|||||||
@@ -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' });
|
||||||
@@ -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);
|
||||||
@@ -329,7 +329,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
...(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: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||||
: []),
|
: []),
|
||||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
* the case this module exists for, and the case its browser test asserts by
|
* 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.
|
* 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 recovery never guesses the character: the `input` event already carries
|
||||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
* 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
|
* 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
|
* also be a CAPTURE listener; see the measured table at the addEventListener
|
||||||
* call below.
|
* call below.
|
||||||
*
|
*
|
||||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
* @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by
|
||||||
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
* 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) {
|
(function (global) {
|
||||||
'use strict';
|
'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) {
|
function create(options) {
|
||||||
const textarea = options?.textarea;
|
const textarea = options?.textarea;
|
||||||
const emitRecovered = options?.emitRecovered;
|
const emitRecovered = options?.emitRecovered;
|
||||||
@@ -65,6 +90,9 @@
|
|||||||
let keydownSnapshot = 0;
|
let keydownSnapshot = 0;
|
||||||
let composing = false;
|
let composing = false;
|
||||||
const pending = [];
|
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
|
* 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
|
// reach the PTY — see flushPending(). This runs from xterm's custom key
|
||||||
// handler, i.e. before xterm processes the key, so a recovered character
|
// handler, i.e. before xterm processes the key, so a recovered character
|
||||||
// is always ordered ahead of the bytes this keydown produces.
|
// 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();
|
flushPending();
|
||||||
keydownSnapshot = canonicalCount;
|
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() {
|
function onCompositionStart() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
composing = true;
|
composing = true;
|
||||||
@@ -191,6 +312,11 @@
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
cancelPending();
|
cancelPending();
|
||||||
|
try {
|
||||||
|
uninstallEditSync?.();
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
textarea.removeEventListener('input', onInput, true);
|
textarea.removeEventListener('input', onInput, true);
|
||||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||||
@@ -228,5 +354,5 @@
|
|||||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||||
}
|
}
|
||||||
|
|
||||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -1,640 +1,16 @@
|
|||||||
// src/web/public/terminal-split.js
|
// src/web/public/terminal-split.js
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
* @fileoverview Split-pane orchestration: opens a second live session
|
||||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
* a draggable divider, a session picker, and auto-collapse when either
|
||||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
|
||||||
*
|
*
|
||||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
* @dependency terminal-tile.js (window.TerminalTile)
|
||||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
|
||||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
|
||||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
||||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
|
||||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
|
||||||
* giant synchronous write that blocks the main thread while parsing a long
|
|
||||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
|
||||||
* (terminal-ui.js): that one is wired into session-switch generation
|
|
||||||
* counters and the live-output gate this simpler, independently
|
|
||||||
* created/destroyed pane has no equivalent of.
|
|
||||||
*/
|
|
||||||
function writeChunked(terminal, buffer, isDestroyed) {
|
|
||||||
if (!buffer) return Promise.resolve();
|
|
||||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
|
||||||
terminal.write(buffer);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
|
||||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
|
||||||
// across the whole replay rather than just the fetch that precedes it.
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let offset = 0;
|
|
||||||
const writeNext = () => {
|
|
||||||
if (isDestroyed() || !terminal) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
|
||||||
offset += chunk.length;
|
|
||||||
terminal.write(chunk);
|
|
||||||
if (offset < buffer.length) {
|
|
||||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
|
||||||
else setTimeout(writeNext, 16);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class SplitTerminalPane {
|
|
||||||
constructor(sessionId, mountEl, opts = {}) {
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.mountEl = mountEl;
|
|
||||||
this.sessionMode = opts.mode;
|
|
||||||
this.fontSettings = opts.fontSettings || {};
|
|
||||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
|
||||||
// detaching this session AFTER the split is already open must still be
|
|
||||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
|
||||||
// fight the split picker already refuses to open at pick time.
|
|
||||||
this.detachedSessions = opts.detachedSessions;
|
|
||||||
this.terminal = null;
|
|
||||||
this.fitAddon = null;
|
|
||||||
this.ws = null;
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = false;
|
|
||||||
this._destroyed = false;
|
|
||||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
|
||||||
this._bufferLoading = false;
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
||||||
// `_liveQueue` is non-null from the pull's response until its finally
|
|
||||||
// block: live frames are held there with their arrival time instead of
|
|
||||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
|
||||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
|
||||||
this._historyPullAt = 0;
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
this._liveQueue = null;
|
|
||||||
this._markerOwed = false;
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
theme: { ...global.codemanCurrentXtermTheme() },
|
|
||||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
|
||||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
|
||||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
cursorBlink: false,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
||||||
scrollback: DEFAULT_SCROLLBACK,
|
|
||||||
allowTransparency: true,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
|
||||||
this.terminal.open(this.mountEl);
|
|
||||||
this.fitAddon.fit();
|
|
||||||
|
|
||||||
this._installWheelListener();
|
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pane B has no gates of its own by default, so every app-level chord
|
|
||||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
|
||||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
|
||||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
|
||||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
|
||||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
|
||||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
|
||||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
|
||||||
// registry-aware predicates so a rebind or a disable restores plain
|
|
||||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
|
||||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
|
||||||
// to, so intercepting it here would only break paste.
|
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
|
||||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
|
||||||
if (
|
|
||||||
ev.altKey &&
|
|
||||||
!ev.ctrlKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
|
||||||
// swallow — in a plain shell session this is the user's own
|
|
||||||
// job-control tool and must reach the PTY, but in every other mode
|
|
||||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
|
||||||
// loop dead. Pane B has its own PTY/session and must not send a
|
|
||||||
// suspend into a non-shell one just because the primary pane's own
|
|
||||||
// gate lives elsewhere.
|
|
||||||
if (
|
|
||||||
ev.type === 'keydown' &&
|
|
||||||
ev.key.toLowerCase() === 'z' &&
|
|
||||||
ev.ctrlKey &&
|
|
||||||
!ev.altKey &&
|
|
||||||
!ev.metaKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
this.sessionMode !== 'shell'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
|
||||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
|
||||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
|
||||||
// newline from a submit. Without this gate, Pane B's onData would
|
|
||||||
// send that bare \r straight over the WS and submit an incomplete
|
|
||||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
|
||||||
// session (this.sessionId), never the primary pane's
|
|
||||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
|
||||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
|
||||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
|
||||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
|
||||||
if (ev.type === 'keydown') {
|
|
||||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
|
||||||
}).catch(() => {
|
|
||||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
|
||||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
|
||||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
|
||||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
|
||||||
// explicit, never-falls-through copy chord, and the predicate above
|
|
||||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
|
||||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
|
||||||
// a branch that assigns c.key only for a couple of special cases
|
|
||||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
|
||||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
|
||||||
// interrupt byte reaching the PTY (verified live: it does not).
|
|
||||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
|
||||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
|
||||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
|
||||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
|
||||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
|
||||||
// Re-implemented against this.terminal rather than reusing
|
|
||||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
|
||||||
// and would copy the wrong pane's selection.
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
|
||||||
const raw = this.terminal?.getSelection?.() || '';
|
|
||||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
|
||||||
// Both clean options are read for THIS pane, never the primary one:
|
|
||||||
// the gutter width comes from this.sessionId's own run mode, and the
|
|
||||||
// partial-first-line flag from this terminal's own selection range.
|
|
||||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
|
||||||
// same split and the same keystroke.
|
|
||||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
|
||||||
const selection = isColumnSelection
|
|
||||||
? raw
|
|
||||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
|
||||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
|
||||||
}) ?? raw);
|
|
||||||
if (selection.trim()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
void global.app._copyText?.(selection).then((ok) => {
|
|
||||||
this.terminal?.clearSelection?.();
|
|
||||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Nothing worth copying — clear for feedback (a padding-only
|
|
||||||
// selection cleans to '' and this press still falls through to the
|
|
||||||
// PTY as 0x03, matching the primary pane's own rule).
|
|
||||||
if (this.terminal?.hasSelection?.()) {
|
|
||||||
this.terminal.clearSelection?.();
|
|
||||||
global.app.showToast?.('Nothing to copy', 'warning');
|
|
||||||
}
|
|
||||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
|
||||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing scrollback before going live. The WS below is
|
|
||||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
||||||
// 'terminal' events), so without this Pane B stays blank until the
|
|
||||||
// target session happens to produce new output. It LOOKED
|
|
||||||
// intermittent rather than always-broken because _sendResize() below
|
|
||||||
// often nudges the shared session's real tmux window to a new size,
|
|
||||||
// and tmux repaints its current screen on resize — that repaint was
|
|
||||||
// getting captured and streamed here, incidentally populating the
|
|
||||||
// pane. When Pane B's computed dimensions happened to already match
|
|
||||||
// the session's last-known size, Session.resize() (session.ts) skips
|
|
||||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
|
||||||
// The await covers the whole chunked replay, not just the fetch, so a
|
|
||||||
// live frame from the socket below can never land in the middle of it.
|
|
||||||
await this._loadBuffer();
|
|
||||||
if (this._destroyed) return;
|
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._wsReady = true;
|
|
||||||
this._sendResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.t === 'o') {
|
|
||||||
this._onLiveOutput(msg.d);
|
|
||||||
} else if (msg.t === 'c') {
|
|
||||||
this._onLiveClear();
|
|
||||||
} else if (msg.t === 'r') {
|
|
||||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
|
||||||
// data was dropped). The primary pane routes this to
|
|
||||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
|
||||||
// buffer loader for the same reason connect() does.
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
||||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
||||||
// closed socket per the WebSocket spec (no exception, no log). No
|
|
||||||
// reconnect logic here — Pane B is deliberately plainer than the
|
|
||||||
// primary pane (see the fileoverview above); a drop just stops
|
|
||||||
// resizing until the parent recreates the pane. But onData already
|
|
||||||
// silently drops keystrokes while _wsReady is false (below), so
|
|
||||||
// without a visible marker a dropped socket left Pane B looking
|
|
||||||
// normal while it quietly ate everything typed into it. v1 scope is
|
|
||||||
// "say so", not reconnect — collapsing the split would lose the
|
|
||||||
// user's place in Pane B's scrollback for a transient blip.
|
|
||||||
this.ws.onclose = () => this._onSocketClosed();
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
// onclose fires after onerror — cleanup happens there.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The socket's close, split out of connect() so the tests can drive it.
|
|
||||||
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
|
||||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
|
||||||
// written now, it would sit above the output a pull is still holding (flushed
|
|
||||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
|
||||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
|
||||||
// response holds the marker too, for as long as the request takes (up to its
|
|
||||||
// budget, see _pullHistory()).
|
|
||||||
_onSocketClosed() {
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = true;
|
|
||||||
if (this._bufferLoading) this._markerOwed = true;
|
|
||||||
else this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
|
||||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
|
||||||
// a closed socket. Called from each load's own finally, just before
|
|
||||||
// _endBufferLoad() starts any trailing refresh.
|
|
||||||
_stampMarkerIfOwed() {
|
|
||||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
|
||||||
// a write() on a later tick: a marker written here would land in the
|
|
||||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
|
||||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
|
||||||
const owed = this._markerOwed;
|
|
||||||
this._markerOwed = false;
|
|
||||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
|
||||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
|
||||||
_writeDisconnectedMarker() {
|
|
||||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches and writes the session's current scrollback. Used both by
|
|
||||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
|
||||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
|
||||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
|
||||||
// terminal, neither of which applies to this independent pane, so this is
|
|
||||||
// a standalone equivalent rather than a call into it.
|
|
||||||
//
|
|
||||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
|
||||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
|
||||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
|
||||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
|
||||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
|
||||||
// get one full replay. `fetch` here goes through the global wrapper
|
|
||||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
|
||||||
// WebSocket URL above, which does not.
|
|
||||||
//
|
|
||||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
|
||||||
// (writeChunked resolves after its last chunk), so two replays can never
|
|
||||||
// interleave their chunks into one terminal. A second call while one is
|
|
||||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
|
||||||
// a trailing re-run instead.
|
|
||||||
async _loadBuffer() {
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
this._bufferLoading = true;
|
|
||||||
try {
|
|
||||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
|
||||||
const payload = (await res.json())?.data ?? {};
|
|
||||||
if (payload.terminalBuffer && this.terminal) {
|
|
||||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output still arrives once the socket connects. */
|
|
||||||
} finally {
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
||||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
||||||
_endBufferLoad() {
|
|
||||||
this._bufferLoading = false;
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) {
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live terminal output. Written straight through, except while a history
|
|
||||||
// pull is replaying: a capture is current only up to the instant tmux took
|
|
||||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
||||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
||||||
// _finishBufferLoad `since` rule), never written underneath it.
|
|
||||||
_onLiveOutput(data) {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
||||||
else this.terminal?.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
||||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
||||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
||||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
||||||
_onLiveClear() {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
||||||
else this.terminal?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
||||||
// event it consumes, so a bubbling listener here would never see the wheel
|
|
||||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
||||||
// xterm keeps doing the scrolling.
|
|
||||||
_installWheelListener() {
|
|
||||||
this._onWheel = (ev) => {
|
|
||||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
||||||
};
|
|
||||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
||||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
||||||
// so this pane's xterm ends up with about one screen of scrollback while
|
|
||||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
||||||
// history was unreachable. The primary pane has the same pull
|
|
||||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
||||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
||||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
||||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
||||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
||||||
// tmux the browser xterm never enters the alternate buffer.
|
|
||||||
_maybeLoadMoreHistory() {
|
|
||||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
||||||
// _sendResize(): a detached session's own window already owns its PTY
|
|
||||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const active = this.terminal.buffer.active;
|
|
||||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
||||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
||||||
// rather than latch; a pull that could only have downgraded the pane
|
|
||||||
// waits far longer.
|
|
||||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._historyPullAt < cooldown) return;
|
|
||||||
this._historyPullAt = now;
|
|
||||||
void this._pullHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
||||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
||||||
// main thread) and replays it under the reader's current place. Holds the
|
|
||||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
||||||
async _pullHistory() {
|
|
||||||
this._bufferLoading = true;
|
|
||||||
let replayed = false;
|
|
||||||
let capturedAt = 0;
|
|
||||||
// Two budgets on one signal. The request itself gets the primary pane's
|
|
||||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
|
||||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
|
||||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
|
||||||
// for that whole budget. Once the headers land live output IS held, so the
|
|
||||||
// body read gets the short one instead: a body that hangs would otherwise
|
|
||||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
|
||||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
|
||||||
// be re-armed, hence the controller; without AbortController the pull
|
|
||||||
// simply has no deadline.
|
|
||||||
const controller = global.AbortController ? new global.AbortController() : null;
|
|
||||||
let abortTimer = null;
|
|
||||||
const armDeadline = (ms) => {
|
|
||||||
if (!controller) return;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
||||||
// for the instant tmux took the capture, which lies somewhere in the
|
|
||||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
||||||
// by one round trip and not closable without a server-side capture time.
|
|
||||||
capturedAt = performance.now();
|
|
||||||
// Opened only now: a frame from before the response is either replaced by
|
|
||||||
// the capture or written unchanged, so holding it for the round trip
|
|
||||||
// bought nothing and froze the pane for as long as the fetch took.
|
|
||||||
this._liveQueue = [];
|
|
||||||
const payload = (await res.json())?.data;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const buffer = payload?.terminalBuffer;
|
|
||||||
const term = this.terminal;
|
|
||||||
if (!buffer || !term || this._destroyed) return;
|
|
||||||
const rowsBefore = term.buffer.active.length;
|
|
||||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
||||||
// lines, so a window of short lines can carry more rows than this pane
|
|
||||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
||||||
const scrollbackCap = term.options?.scrollback || 0;
|
|
||||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
||||||
// Nothing to gain (this also covers a downgrade, which would delete
|
|
||||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
||||||
// untruncated window IS all of tmux's history and the next burst can add
|
|
||||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
||||||
// what the pane shows, and every ask costs the server a capture-pane of
|
|
||||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
||||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
||||||
// off too, since no window can ever fit in it.
|
|
||||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
||||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
term.write('\x1bc');
|
|
||||||
replayed = true;
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
await writeChunked(term, buffer, () => this._destroyed);
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// xterm parses asynchronously: an empty write's callback fires only
|
|
||||||
// after everything before it, so the row count below is the settled one.
|
|
||||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
||||||
// rows down; land there and the recovered history sits above it.
|
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
||||||
else this.terminal.scrollToTop();
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
||||||
} finally {
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const queued = this._liveQueue ?? [];
|
|
||||||
this._liveQueue = null;
|
|
||||||
// After a replay, only frames that arrived after the capture are news;
|
|
||||||
// earlier ones are already in it. With no replay, every held frame is.
|
|
||||||
const cutoff = replayed ? capturedAt : 0;
|
|
||||||
for (const entry of queued) {
|
|
||||||
if (entry.at < cutoff) continue;
|
|
||||||
if (entry.clear) this.terminal?.clear();
|
|
||||||
else this.terminal?.write(entry.data);
|
|
||||||
}
|
|
||||||
// Settled after the queue flush so the marker is the last thing on
|
|
||||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
|
||||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
|
||||||
// one written before it, which would paint a fresh, current-looking
|
|
||||||
// history while onData keeps silently dropping every keystroke on the
|
|
||||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
|
||||||
// is left to that refresh, which writes it below its own replay.
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
|
||||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
|
||||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
|
||||||
// and no further frame is coming to correct stale content.
|
|
||||||
_refreshBuffer() {
|
|
||||||
if (this._bufferLoading) {
|
|
||||||
this._bufferRefreshPending = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.terminal?.clear();
|
|
||||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
|
||||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
|
||||||
// so a refresh on a closed socket owes it back once its replay is written.
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
void this._loadBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
|
||||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
|
||||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
|
||||||
// pane's own convention (throttledResize in terminal-ui.js).
|
|
||||||
localFit() {
|
|
||||||
if (!this.fitAddon) return;
|
|
||||||
this.fitAddon.fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fit() {
|
|
||||||
this.localFit();
|
|
||||||
this._sendResize();
|
|
||||||
}
|
|
||||||
|
|
||||||
_sendResize() {
|
|
||||||
if (!this._wsReady || !this.fitAddon) return;
|
|
||||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
|
||||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
|
||||||
// to its own window AFTER this split was opened, so its own window now
|
|
||||||
// owns the PTY's size and Pane B must stand aside.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const dims = this.fitAddon.proposeDimensions();
|
|
||||||
if (!dims) return;
|
|
||||||
// Send the real proposed dimensions unclamped, matching the primary
|
|
||||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
||||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
||||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
||||||
// divider's own reachable 20% floor position, causing real
|
|
||||||
// output-wrapping bugs.
|
|
||||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._destroyed = true;
|
|
||||||
if (this._onWheel) {
|
|
||||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
// onclose fires asynchronously AFTER close(); without this it ran
|
|
||||||
// its "disconnected" write against a pane already torn down.
|
|
||||||
this.ws.onclose = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.dispose();
|
|
||||||
this.terminal = null;
|
|
||||||
}
|
|
||||||
this.fitAddon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.SplitTerminalPane = SplitTerminalPane;
|
|
||||||
})(window);
|
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||||
@@ -670,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the old exact-node check below) bubbled straight through to
|
// the old exact-node check below) bubbled straight through to
|
||||||
// `document` and self-closed the menu it just opened.
|
// `document` and self-closed the menu it just opened.
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
|
// The tile grid and the split are never open together (tile-grid.js); the
|
||||||
|
// Split button shows as unavailable meanwhile.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
if (this._splitPane) {
|
if (this._splitPane) {
|
||||||
this.closeSplitPane();
|
this.closeSplitPane();
|
||||||
return;
|
return;
|
||||||
@@ -763,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||||
// a CSS backstop exist.
|
// a CSS backstop exist.
|
||||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||||
|
// Never beside the tile grid: the main terminal is parked while it is open.
|
||||||
|
if (this._tilesOwnTerminal?.()) return;
|
||||||
// No active session means there is no `.terminal-wrap` to split against
|
// No active session means there is no `.terminal-wrap` to split against
|
||||||
// (the welcome overlay is showing) — without this, a split opened from
|
// (the welcome overlay is showing) — without this, a split opened from
|
||||||
// the home screen still created the container and connected Pane B, just
|
// the home screen still created the container and connected Pane B, just
|
||||||
@@ -777,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// B's own session tab while split can otherwise land here with
|
// B's own session tab while split can otherwise land here with
|
||||||
// sessionId === activeSessionId: two live WebSockets to the same
|
// sessionId === activeSessionId: two live WebSockets to the same
|
||||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
// resize frame. Refuse before creating any DOM or TerminalTile.
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.activeSessionId) return;
|
||||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||||
// re-applied here: the menu can sit open while a listed session's CLI
|
// re-applied here: the menu can sit open while a listed session's CLI
|
||||||
@@ -803,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paneB = document.createElement('div');
|
const paneB = document.createElement('div');
|
||||||
paneB.className = 'terminal-pane-b';
|
paneB.className = 'terminal-pane-b';
|
||||||
paneB.innerHTML = `
|
// Pane B's header: the harness logo, the name and the model, as on a grid
|
||||||
<div class="terminal-pane-b-header">
|
// tile, and the close button at a tile button's size.
|
||||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
const headerB = this._buildSplitPaneHeader();
|
||||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
const close = document.createElement('button');
|
||||||
</div>
|
close.type = 'button';
|
||||||
<div class="terminal-pane-b-container"></div>
|
close.className = 'tile-btn tile-remove terminal-pane-b-close';
|
||||||
`;
|
close.title = 'Close split';
|
||||||
|
close.setAttribute('aria-label', 'Close split');
|
||||||
|
close.textContent = '\u00D7';
|
||||||
|
close.addEventListener('click', () => this.closeSplitPane());
|
||||||
|
headerB.el.appendChild(close);
|
||||||
|
const bodyB = document.createElement('div');
|
||||||
|
bodyB.className = 'terminal-pane-b-container';
|
||||||
|
paneB.append(headerB.el, bodyB);
|
||||||
|
// Pane A is the main terminal, which has no header of its own: while the
|
||||||
|
// split is open it gets the same strip, so each session in the split view
|
||||||
|
// names its harness and model. It takes height from the main terminal,
|
||||||
|
// which the opening resize below fits through syncTerminalGeometry (#464);
|
||||||
|
// closeSplitPane gives it back.
|
||||||
|
const headerA = this._buildSplitPaneHeader();
|
||||||
|
headerA.el.classList.add('terminal-pane-a-header');
|
||||||
|
this._splitHeaders = { a: headerA, b: headerB };
|
||||||
|
|
||||||
parent.insertBefore(container, wrap);
|
parent.insertBefore(container, wrap);
|
||||||
|
wrap.insertBefore(headerA.el, wrap.firstChild);
|
||||||
container.appendChild(wrap);
|
container.appendChild(wrap);
|
||||||
wrap.style.flexBasis = '50%';
|
wrap.style.flexBasis = '50%';
|
||||||
container.appendChild(divider);
|
container.appendChild(divider);
|
||||||
container.appendChild(paneB);
|
container.appendChild(paneB);
|
||||||
paneB.style.flexBasis = '50%';
|
paneB.style.flexBasis = '50%';
|
||||||
|
|
||||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
|
||||||
mode: session?.mode,
|
mode: session?.mode,
|
||||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||||
detachedSessions: this.detachedSessions,
|
detachedSessions: this.detachedSessions,
|
||||||
@@ -828,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
initial load — live output still arrives once/if the socket connects. */
|
initial load — live output still arrives once/if the socket connects. */
|
||||||
});
|
});
|
||||||
this._splitSessionId = sessionId;
|
this._splitSessionId = sessionId;
|
||||||
|
this._renderSplitChrome();
|
||||||
|
|
||||||
// Pane A just went from full width to 50%, but nothing has told its
|
// Pane A just went from full width to 50%, but nothing has told its
|
||||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||||
@@ -855,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._splitPane.destroy();
|
this._splitPane.destroy();
|
||||||
this._splitPane = null;
|
this._splitPane = null;
|
||||||
this._splitSessionId = null;
|
this._splitSessionId = null;
|
||||||
|
// Before the refit below, so the main terminal gets its full height back.
|
||||||
|
this._splitHeaders?.a.el.remove();
|
||||||
|
this._splitHeaders = null;
|
||||||
this._updateSplitButtonState(false);
|
this._updateSplitButtonState(false);
|
||||||
|
|
||||||
const container = document.querySelector('.terminal-split-container');
|
const container = document.querySelector('.terminal-split-container');
|
||||||
@@ -865,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
parent.insertBefore(wrap, container);
|
parent.insertBefore(wrap, container);
|
||||||
container.remove();
|
container.remove();
|
||||||
|
|
||||||
if (this.fitAddon) this.fitAddon.fit();
|
// Pane A takes the whole width back and, with its header strip gone, its
|
||||||
|
// whole height: through syncTerminalGeometry (#464), never a bare
|
||||||
|
// fitAddon.fit(). sendResize fits that way as its first step.
|
||||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||||
// while activeSessionId is still the id the server just removed, so a
|
// while activeSessionId is still the id the server just removed, so a
|
||||||
// resize from here would be aimed at a session that no longer exists;
|
// resize from here would be aimed at a session that no longer exists;
|
||||||
// the promoted session gets its own resize from selectSession().
|
// the promoted session gets its own resize from selectSession(), and the
|
||||||
if (!options.skipPrimaryResize) {
|
// terminal is only refitted here.
|
||||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
|
||||||
}
|
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
// A click on .btn-split does one of two things — open the picker, or
|
// A click on .btn-split does one of two things — open the picker, or
|
||||||
@@ -880,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||||
// the same active-state language as the codebase's other toggle buttons
|
// the same active-state language as the codebase's other toggle buttons
|
||||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||||
|
/**
|
||||||
|
* A split pane's header strip: the harness logo, the session name and the
|
||||||
|
* model, the three a grid tile's header shows. Built from nodes (the name
|
||||||
|
* and the model are untrusted text) and painted by _renderSplitChrome.
|
||||||
|
*/
|
||||||
|
_buildSplitPaneHeader() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'terminal-pane-b-header';
|
||||||
|
const harness = document.createElement('span');
|
||||||
|
harness.className = 'split-harness run-mode-dot';
|
||||||
|
harness.setAttribute('role', 'img');
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'split-title';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
// `.session-name` is one of the translator's skipped surfaces (user text).
|
||||||
|
name.className = 'session-name';
|
||||||
|
// As on a tile: the name inside is never translated, the tooltip may be,
|
||||||
|
// and screen readers hear the model once, in the logo's accessible name.
|
||||||
|
const model = document.createElement('span');
|
||||||
|
model.className = 'split-model';
|
||||||
|
model.setAttribute('aria-hidden', 'true');
|
||||||
|
model.hidden = true;
|
||||||
|
const modelName = document.createElement('span');
|
||||||
|
modelName.setAttribute('data-i18n-skip', '');
|
||||||
|
model.appendChild(modelName);
|
||||||
|
title.append(name, model);
|
||||||
|
el.append(harness, title);
|
||||||
|
return { el, harness, name, model, modelName };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both split headers from their sessions: Pane A shows the active session,
|
||||||
|
* Pane B its own. Runs after every tab render, so a rename or a model
|
||||||
|
* change reaches them; unchanged values write nothing.
|
||||||
|
*/
|
||||||
|
_renderSplitChrome() {
|
||||||
|
const headers = this._splitHeaders;
|
||||||
|
if (!headers || !this._splitPane) return;
|
||||||
|
for (const [parts, id] of [
|
||||||
|
[headers.a, this.activeSessionId],
|
||||||
|
[headers.b, this._splitSessionId],
|
||||||
|
]) {
|
||||||
|
const session = id ? this.sessions.get(id) : null;
|
||||||
|
if (!session) continue;
|
||||||
|
const name = this.getSessionName?.(session) || session.name || 'Session';
|
||||||
|
if (parts.nameValue !== name) {
|
||||||
|
parts.nameValue = name;
|
||||||
|
parts.name.textContent = name;
|
||||||
|
}
|
||||||
|
this._paintSessionHarness(parts, session, 'split-harness');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a session header's harness logo and model: a grid tile's, and the
|
||||||
|
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
|
||||||
|
* data, never a branch), the model is text (describeSessionHarness,
|
||||||
|
* constants.js). Diffs against the values it last wrote, kept on `parts`,
|
||||||
|
* never against the DOM, which the translator may have rewritten: an
|
||||||
|
* unchanged session writes nothing, and this runs on every tab render.
|
||||||
|
*
|
||||||
|
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
|
||||||
|
* header's nodes (the model's box and the name inside it); the memo lives here too
|
||||||
|
* @param {object} session - the session the header shows
|
||||||
|
* @param {string} logoClass - the header's own class for its logo
|
||||||
|
*/
|
||||||
|
_paintSessionHarness(parts, session, logoClass) {
|
||||||
|
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
|
||||||
|
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
|
||||||
|
if (parts.harnessClass !== cls) {
|
||||||
|
parts.harnessClass = cls;
|
||||||
|
parts.harness.className = cls;
|
||||||
|
}
|
||||||
|
if (parts.harnessTitle !== harness.title) {
|
||||||
|
parts.harnessTitle = harness.title;
|
||||||
|
parts.harness.title = harness.title;
|
||||||
|
parts.harness.setAttribute('aria-label', harness.title);
|
||||||
|
parts.model.title = harness.title;
|
||||||
|
}
|
||||||
|
if (parts.modelValue !== harness.model) {
|
||||||
|
parts.modelValue = harness.model;
|
||||||
|
parts.modelName.textContent = harness.model;
|
||||||
|
parts.model.hidden = !harness.model;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_updateSplitButtonState(open) {
|
_updateSplitButtonState(open) {
|
||||||
const btn = document.querySelector('.btn-split');
|
const btn = document.querySelector('.btn-split');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -900,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// frame). Coalesced to one call per animation frame below — a raw
|
// frame). Coalesced to one call per animation frame below — a raw
|
||||||
// mousemove stream fires far faster than the browser repaints, and
|
// mousemove stream fires far faster than the browser repaints, and
|
||||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
|
||||||
// no client-side "dims unchanged" skip), which fanned out into a
|
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
|
||||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
// fifty of each dragging across half a wide viewport.
|
||||||
// of each dragging across half a wide viewport.
|
|
||||||
const applyDragPercent = (clientX) => {
|
const applyDragPercent = (clientX) => {
|
||||||
const container = divider.parentElement;
|
const container = divider.parentElement;
|
||||||
// The split can auto-collapse mid-drag (the other pane's session
|
// The split can auto-collapse mid-drag (the other pane's session
|
||||||
@@ -1039,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
|||||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||||
// dimensions via its own `{t:'z',...}` resize frame.
|
// dimensions via its own `{t:'z',...}` resize frame.
|
||||||
|
// Every tab render (any session change: a rename, a model switch) refreshes
|
||||||
|
// the split headers too, the way tile-grid.js refreshes the tile headers.
|
||||||
|
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
|
||||||
|
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
|
||||||
|
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
|
||||||
|
this._renderSplitChrome?.();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||||
|
|||||||