mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d59e59e37 | ||
|
|
019118ba9d | ||
|
|
7a4d520cd0 | ||
|
|
08d7fa1985 | ||
|
|
0c6cbd0718 | ||
|
|
8b2cb49940 | ||
|
|
fb5c1bfca5 | ||
|
|
7b95856cc5 | ||
|
|
3a40dae1dd | ||
|
|
a81d344fb5 | ||
|
|
16980d644d | ||
|
|
b538429f6e | ||
|
|
7689a43782 | ||
|
|
950450b137 | ||
|
|
55cd59a346 | ||
|
|
209a539489 | ||
|
|
71e6cfa0a2 | ||
|
|
be9454a29b | ||
|
|
29c2701f83 | ||
|
|
f152d551d1 | ||
|
|
6844cccd18 | ||
|
|
ac8e0bdcbb | ||
|
|
9d6c010819 | ||
|
|
183efacc93 | ||
|
|
4a94eb9ba7 | ||
|
|
e589004926 | ||
|
|
5f373a9324 | ||
|
|
ac5f2aa3f4 | ||
|
|
2881ef5fa5 | ||
|
|
310a4c2489 | ||
|
|
29d8ca16b4 | ||
|
|
92e54163e0 | ||
|
|
39a91a0c7f | ||
|
|
cea199651a | ||
|
|
656ab98934 | ||
|
|
e6382ecfc0 | ||
|
|
4d6d187883 | ||
|
|
d3fc07ee28 | ||
|
|
b2de04c730 | ||
|
|
eada647d7b | ||
|
|
233d5f25c4 | ||
|
|
cf9022ac9a | ||
|
|
87f1c9ccd4 | ||
|
|
d8c23210bb | ||
|
|
7c3877016b | ||
|
|
7c4ad22f9c | ||
|
|
dd7a5b9275 | ||
|
|
217c90b6a1 | ||
|
|
93642fa616 | ||
|
|
6db526dbad | ||
|
|
e6deab98a6 | ||
|
|
5e0cea6ec8 | ||
|
|
2cc6ea1cfd | ||
|
|
c5ddb77adc | ||
|
|
d2d2ba3e4a | ||
|
|
33ad314bea | ||
|
|
3f4af2aa2e | ||
|
|
cf11a253d3 | ||
|
|
17824f1847 | ||
|
|
53f61ca539 | ||
|
|
3be03c6896 | ||
|
|
ba36cc36d4 | ||
|
|
7aecbd29df | ||
|
|
f12b5ac88b | ||
|
|
5206a044bb | ||
|
|
abc8598100 | ||
|
|
6ba311f38d | ||
|
|
decd263b17 | ||
|
|
82aeeaec02 | ||
|
|
ad57394618 | ||
|
|
fda1897109 | ||
|
|
eceaecf076 | ||
|
|
6f87186deb | ||
|
|
aa4217b8b1 | ||
|
|
c75d62ce04 | ||
|
|
c2e55fc210 | ||
|
|
24e51a3b8e | ||
|
|
0481db569d | ||
|
|
6d862d5323 | ||
|
|
57d5c7b1c2 | ||
|
|
9c63c41283 | ||
|
|
26487f0ec5 | ||
|
|
8da4a07a60 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'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",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
|
||||
|
||||
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.
|
||||
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; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
|
||||
|
||||
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||
|
||||
|
||||
+3
-1
@@ -107,7 +107,9 @@ todo.md
|
||||
@fix_plan.md
|
||||
readme-preview.mjs
|
||||
|
||||
# Uploaded images land here under each session working dir (runtime artifact)
|
||||
# Prompt uploads land here under each session working dir (runtime artifact);
|
||||
# .claude-images/ is where they landed before the move.
|
||||
.codeman-uploads/
|
||||
.claude-images/
|
||||
|
||||
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
|
||||
|
||||
@@ -12,6 +12,6 @@ Quick pointers:
|
||||
|
||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
||||
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
|
||||
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
|
||||
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
|
||||
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||
- Never commit secrets or local state from `~/.codeman/`
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.41.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 87f1c9c: ### Thanks
|
||||
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
|
||||
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
|
||||
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
|
||||
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
|
||||
|
||||

|
||||
|
||||
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
|
||||
|
||||
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
|
||||
|
||||
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
|
||||
|
||||
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
|
||||
|
||||
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
|
||||
|
||||
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
|
||||
|
||||
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
|
||||
|
||||
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
|
||||
|
||||
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
|
||||
|
||||
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
|
||||
|
||||
**Fixes.**
|
||||
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
|
||||
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
|
||||
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
|
||||
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
|
||||
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
|
||||
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
|
||||
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
|
||||
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
|
||||
|
||||
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
|
||||
|
||||
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
|
||||
|
||||
## 1.40.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -944,6 +944,24 @@ codeman tui --list # numbered session list (plain tex
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### `codeman agent` — session-to-session verbs in every CLI mode
|
||||
|
||||
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
@@ -22,6 +22,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/tab-rail-search.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -45,6 +46,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/spreadsheet-preview.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
'test/run-mode-menu-scroll.browser.test.ts',
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+23
-2
@@ -466,6 +466,27 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
| `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. |
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
|
||||
|
||||
A `multipart/form-data` body with one `image` part. The file is written into the
|
||||
session's workspace as `<workingDir>/.codeman-uploads/paste-<ms>-<hex>.<ext>`, and
|
||||
`data` carries `path` and `filename` for the client to type the path into the
|
||||
prompt. The folder is Codeman's own: hidden, created on first use with a
|
||||
`.gitignore` containing `*` (written once, never over a file already there), and
|
||||
cleaned up the way pasted images always were: `paste-*` files older than 7 days
|
||||
go in an hourly sweep, and the folder goes when the last session of that
|
||||
workspace is killed. Uploads made before this release sit in `.claude-images/`;
|
||||
that folder receives nothing new, and is swept and removed the same way for one
|
||||
release. A remote (SSH) session answers 400, since the file would land on the
|
||||
Codeman host under a path the remote agent cannot read. A Docker session of an
|
||||
owned case is fine, its workspace is bind-mounted at the same absolute path; an
|
||||
adopted container (`owned: false`) mounts nothing, so its agent can open the file
|
||||
only if the container itself exposes that host path.
|
||||
|
||||
## Session lineage (`parentSessionId`)
|
||||
|
||||
A create request may name the session that spawned it, which the web UI draws as a
|
||||
@@ -812,10 +833,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
|
||||
Result (`data`):
|
||||
|
||||
- `applied` — `false` for the dry run.
|
||||
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
||||
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
||||
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
|
||||
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
|
||||
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
|
||||
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
|
||||
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
|
||||
|
||||
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
**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 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`.
|
||||
**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 also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. 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.
|
||||
|
||||
@@ -254,7 +254,7 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne
|
||||
- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay.
|
||||
- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand.
|
||||
|
||||
Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.claude-images` while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
|
||||
Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.codeman-uploads` (and the pre-move `.claude-images`) while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
|
||||
|
||||
### Dead-pane respawn: the resume pin
|
||||
|
||||
@@ -306,7 +306,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
|
||||
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing.
|
||||
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
|
||||
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
|
||||
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. The one exception is the rail's **Search sessions** box (#580): while it holds text `_projectTabGroups()` passes `collapsedGroupIds: []`, so every group is DRAWN open and a match inside a collapsed one can show, and `toggleTabGroupCollapsed()` refuses; the stored set and its localStorage key are never written, so clearing the search puts the collapse back exactly as it was (see Session list layout, the rail search).
|
||||
- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible.
|
||||
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
@@ -351,7 +351,7 @@ The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings →
|
||||
|
||||
⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up.
|
||||
|
||||
⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`.
|
||||
⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's upload dirs (`.codeman-uploads`, and the pre-move `.claude-images`).
|
||||
|
||||
⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
|
||||
|
||||
@@ -728,7 +728,7 @@ The guard is a one-shot flag on each trap rather than a browser test or a readin
|
||||
|
||||
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
||||
|
||||
Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones.
|
||||
Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones. Apply (`applyAppSettings()`) sits beside Save in the same actions and saves without closing; it only sets a one-shot flag that `saveAppSettings()` consumes before its first await, so a Save clicked mid-Apply still closes the sheet.
|
||||
|
||||
**The rail means two different things, and that is deliberate:**
|
||||
|
||||
@@ -793,6 +793,20 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the closed drawer keeps `display: flex`, so without `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable.
|
||||
|
||||
⚠️ **The rail's Search sessions box and the sidebar's filter box are ONE row filter** (#580): `applySidebarFilter(query)` sets `_sidebarFilter`, the rail box (`setTabRailSearch`, `#tabRailSearch` at the top of `#tabRail`) sets `_tabRailSearch`, and both run `_applyTabListFilter()`, which reads the rows the last render drew and applies the pure `CodemanTabSearch` (constants.js) as classes. Only one needle is ever live, because the rail and the sidebar never host `#sessionTabs` at the same time (`applyTabOrientation()` forces horizontal while the sidebar owns the list); off the rail and out of a reachable sidebar the needle is empty and every class comes off. The rules that keep it a view filter:
|
||||
|
||||
- **Classes only.** `tab-filtered-out` on a row, and on a `.tab-layout-group` or `.tab-cluster` the filter emptied; never a reorder, a re-render per keystroke, a layout write or a request, so `sessionOrder`, the Alt+N badges, the server order and the tab layout never move. Both render paths end in `applySidebarFilter()`, which re-applies it. The hide rules are layout-scoped like the sidebar's original one (`html[data-tab-orientation='vertical'] .tab-rail ...` for rows, groups and case boxes; `html[data-session-list="sidebar"] ...` for rows and case boxes), so a leaked class can never hide anything on the header strip, which has no box to clear it from.
|
||||
- **Matching.** The rail matches the NAME only (`.tab-name`'s `data-full-name`, a web tab's title, never a URL or folder); the sidebar keeps its name + working directory. Trimmed, case-insensitive substring, lower-cased with `toLowerCase()`, never `toLocaleLowerCase()` (a Turkish locale lowers "API" to "apı").
|
||||
- **A row with a tab alert is never hidden** (owner decision on #580, the collapsed-header promise again: a prompt waiting on you is never hidden by a view filter). The row goes into the core as `keep: true` when `tabAlerts` holds anything for it (red `action` or yellow `idle`); a kept row counts toward its section, so its group or case box stays on screen, but not toward `matchCount`, so "No sessions match" can show above a lone alerted row. Alerts come and go through `renderSessionTabs()`, whose tail re-runs the filter, so nothing else has to.
|
||||
- **Counts and the tree.** A section's count shows the rows left showing during a search and is restored from the `data-total` it stashed on first touch. In the grouped tree `_tabTreeItems()` skips a hidden group whole (header included), the filter tail re-runs `_applyTabTreePositions()` and moves the one roving stop off a hidden item, but only while a search hides something or right after one changed what shows (both render paths already set them over an unfiltered tree). Only the grouped rail is a tree; the flat rail stays a tablist.
|
||||
- **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes.
|
||||
- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects.
|
||||
- **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler.
|
||||
- **Drag stays on while searching** (owner decision): searching for a tab and dragging it into a group is a useful flow. A drop uses the same anchor logic relative to the visible rows, so a row can land between visible neighbours with hidden rows on either side.
|
||||
- **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`.
|
||||
|
||||
Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one).
|
||||
|
||||
⚠️ **The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not**: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse `home-sessions-blink-red`/`-yellow` rather than declaring a second pair, and every state dot rule excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no `.active` rule in that block on purpose: `.session-tab.active` already paints border/background/box-shadow `!important`.
|
||||
|
||||
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
|
||||
@@ -805,7 +819,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
### 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.
|
||||
**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, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `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 })`.
|
||||
|
||||
@@ -815,7 +829,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
⚠️ **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.
|
||||
⚠️ **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 on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. 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.
|
||||
|
||||
@@ -825,7 +839,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
⚠️ **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`.
|
||||
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); 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, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). 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
|
||||
|
||||
@@ -877,7 +891,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
### Entrance animations
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section.
|
||||
|
||||
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
|
||||
|
||||
@@ -889,6 +903,14 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
|
||||
|
||||
**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`.
|
||||
|
||||
⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both.
|
||||
|
||||
⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible.
|
||||
|
||||
⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says.
|
||||
|
||||
### Mobile tab strip scrolling
|
||||
|
||||
**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.
|
||||
@@ -1063,6 +1085,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`.
|
||||
|
||||
### Process-tree walks are bounded
|
||||
|
||||
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path).
|
||||
|
||||
@@ -77,11 +77,13 @@ A test that starts a server binds an ephemeral port, never a fixed one:
|
||||
|
||||
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
|
||||
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
|
||||
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
|
||||
excepted).
|
||||
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
|
||||
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
|
||||
convention in `test/mobile/README.md` for now.
|
||||
any `WebServer` built under `test/` on a non-zero port.
|
||||
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
|
||||
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
|
||||
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
|
||||
binds an ephemeral port too; read it from `server.boundPort`.
|
||||
- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
|
||||
server process on port 3222 that the guard cannot see.
|
||||
- Never port 3000: that is the live instance.
|
||||
|
||||
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
|
||||
|
||||
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
||||
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||
|
||||
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
@@ -265,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
|
||||
## MCP server sync
|
||||
|
||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||
|
||||
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
|
||||
|
||||
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||
|
||||
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
||||
|
||||
|
||||
+101
-34
@@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in
|
||||
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
|
||||
compare with the last English value, not the translated DOM.
|
||||
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
|
||||
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
|
||||
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
|
||||
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
|
||||
the open sessions in tab order, the active one always included and focused, and
|
||||
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
|
||||
(from tab order) to the count. A remembered grid comes back with its tiles first, in
|
||||
their cells, then sessions in tab order, to the count in total: the count is a shape
|
||||
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
|
||||
column when all fit, else they pack in reading order) and the added tiles fill the empty
|
||||
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
|
||||
remembered grid still wins over an open split: the split closes and its sessions are not
|
||||
seeded first. A page-load restore brings back exactly the stored grid, whatever the
|
||||
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
|
||||
session among them and focused (owner answer: N, not N+1).
|
||||
decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
|
||||
`toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
|
||||
it: which session sits in which cell, holes included, its tile count, divider sizes,
|
||||
focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
|
||||
filled to the remembered count and never trimmed to the window (a window too small
|
||||
for it shows the focused tile alone until it fits, the arrangement kept). A session
|
||||
that no longer exists frees its cell, which the ranking fills. Only with nothing
|
||||
stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
|
||||
open split's two sessions, else the open sessions as `rankTileSessions` orders them
|
||||
(owner request: "prefer to load in tiles that are working and then the most recent,
|
||||
so the oldest don't get opened"): WORKING first (the most recently started turn
|
||||
first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
|
||||
tab alerts), then the rest by most recent activity, tab order breaking ties; the
|
||||
active one always included and focused, and `tileGridSetForCount` trims it (from the
|
||||
end, the session to focus kept) or fills it (from the ranking) to the remembered count
|
||||
(default 6, at most what the window fits). The states and stamps are the home
|
||||
screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
|
||||
wins over an open split: the split closes and its sessions are not seeded first. A
|
||||
page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
|
||||
with the grid closed opens what the toggle would with that session among the tiles and
|
||||
focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
|
||||
while the grid holds fewer than the count, else it takes the last tile's place.
|
||||
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
|
||||
to right click over the tile button to adjust it"). It replaces the button's native title:
|
||||
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
|
||||
@@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in
|
||||
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
|
||||
element close it (the single view a close starts focuses its terminal when its replay
|
||||
lands; a menu left open behind that would send its keys there). A pick is remembered per
|
||||
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
|
||||
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
|
||||
stays, the others leave from the end or join from tab order, filling empty cells first,
|
||||
device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
|
||||
it, its tiles first in their cells); with the grid open it re-forms it
|
||||
(`_reformTileGrid`): the focused tile always stays, the others leave from the end or
|
||||
join from the ranking, filling empty cells first,
|
||||
every joining tile mounted and laid out before any connects (one fit, one PTY resize
|
||||
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
|
||||
"Open group as tiles", Run) still add up to the cap of 6.
|
||||
@@ -97,7 +106,10 @@ or settled a question the spec left open. The invariants as built are in
|
||||
`test/header-icon-hover.test.ts`.
|
||||
- **The grid opens and closes with a short animation, on by default** (owner request:
|
||||
"when clicking on the tile button first make this animation nicer"). It is the grid's
|
||||
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
|
||||
own `settle` style, the default of App Settings → Animations → Tile Animations, which
|
||||
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
|
||||
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
|
||||
Opening, each tile
|
||||
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
|
||||
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
|
||||
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
|
||||
@@ -389,16 +401,34 @@ the harness/model request; see "As built")
|
||||
|
||||
### Persistence
|
||||
|
||||
Decided: per device, restored on reload. Stored in localStorage key
|
||||
`codeman:tile-grid`:
|
||||
Decided: per device (per browser), restored on reload when it was open, and by
|
||||
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
|
||||
server:
|
||||
|
||||
```json
|
||||
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
|
||||
"colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
|
||||
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||
```
|
||||
|
||||
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
|
||||
duplicate ids on load. Never restored in a solo window.
|
||||
Session ids and the layout, never content. `ids` are the CELLS in reading order,
|
||||
`null` for an empty one. `count` is how many tiles the user's own last change
|
||||
left (open, add, remove by hand, a count picked): a session that goes away by
|
||||
itself (deleted, popped out, its socket refused) does not lower it, so the next
|
||||
time the grid opens the ranking fills that place, while a hole the user made
|
||||
stays. It is written on every change (a move, a divider drag at pointer-up, a
|
||||
tile added or removed, a count picked, a focus, a zoom) and kept, as
|
||||
`open: false`, however the grid closes: the toggle, a non-tiled tab,
|
||||
`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
|
||||
frees its cell), Home, the width gate, the last tile, "Open group as tiles",
|
||||
closing or killing sessions. Nothing is written while a stored grid is being
|
||||
put back, so a half-built grid never overwrites it.
|
||||
|
||||
A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
|
||||
reports the cells their sessions freed (`freed`), and derives `count` for a
|
||||
value written before it existed (the number of sessions the cells name); the
|
||||
old packed `ids` (no nulls) read as cells with no hole, and anything that is not
|
||||
a v1 object is ignored. The format stays `v: 1`, so an older build still reads
|
||||
a newer value (it ignores `count`). Never read or written in a solo window.
|
||||
|
||||
The restore runs INSIDE `handleInit`, in place of its initial
|
||||
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
||||
@@ -409,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
|
||||
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
||||
against the live list without rebuilding tiles that are still alive.
|
||||
|
||||
A cell freed since the grid was stored is filled during that restore, from a
|
||||
ranking that knows each session's status and stamps (the init payload) but not
|
||||
yet its pending approvals: `seedApprovals` asks the server for them
|
||||
asynchronously, and the restore has run by the time they land. So on a reload a
|
||||
session waiting on a permission dialog or an unseen finished turn ranks with the
|
||||
quiet ones for that one fill (working sessions still rank first). Accepted: a
|
||||
fill held back for the approvals would open fewer tiles, which can be another
|
||||
shape, and then reshape the grid and move the user's tiles a second after the
|
||||
reload; so approvals that land later never re-form a restored grid. The Tiles
|
||||
toggle, run once the page has loaded, ranks with them.
|
||||
|
||||
### Gating
|
||||
|
||||
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
||||
@@ -815,7 +856,8 @@ Separate follow-up PRs worth doing (see "Follow-ups").
|
||||
open, and an open menu owns the Escape (it closes alone and the keyboard goes
|
||||
back to the Tiles button, like the tab-group menu).
|
||||
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
|
||||
- **No secrets in localStorage**: the stored grid holds ids only.
|
||||
- **No secrets in localStorage**: the stored grid holds session ids and its
|
||||
layout only, never content.
|
||||
- **Memory**: everything a tile creates is released in `destroy()`.
|
||||
|
||||
## Delivery: two PRs
|
||||
@@ -1008,13 +1050,17 @@ exits green. Use the browser runner for those files and read the file count.
|
||||
viewer keeps a stale width and renders garbled output (#464).
|
||||
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||
on drain) for grids over slow links.
|
||||
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
||||
a "Load full history" action inside a tile. (Done since: a tile pages a
|
||||
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
|
||||
#555 route, and hand-reports a plain click while its session has
|
||||
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
|
||||
tile leaves the wheel to xterm.)
|
||||
4. Tile parity extras: a "Load full history" action inside a tile. (Done
|
||||
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
|
||||
the primary pane's #555 route, hand-reports a plain click while its session
|
||||
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
|
||||
renderer as SGR wheel reports from its own cells
|
||||
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
|
||||
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
|
||||
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
|
||||
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
|
||||
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
|
||||
since xterm turns it into a horizontal no-op off macOS.)
|
||||
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
|
||||
6. Named grid presets, possibly per owner on the server.
|
||||
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
||||
@@ -1053,7 +1099,8 @@ exits green. Use the browser runner for those files and read the file count.
|
||||
is on right-click of the button (its title says so, as do the wiki and the
|
||||
Help modal). Superseded in part by decision 10: right-click is now the count
|
||||
menu, and a remembered grid is filled to the count instead of opening
|
||||
exactly as stored.
|
||||
exactly as stored; decision 11 then restored "exactly as stored" and put a
|
||||
ranking in place of the tab order.
|
||||
9. **No + in the tile header.** Decided by the owner ("remove the + button from
|
||||
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
|
||||
session in this case" went with it. Tiles are added from the Tiles button
|
||||
@@ -1066,12 +1113,32 @@ exits green. Use the browser runner for those files and read the file count.
|
||||
right-click menu offers 2, 4 and 6, remembered per device; the session
|
||||
picker is gone, and decision 8's "picker on right-click" is superseded. The
|
||||
owner's answers on the details: the count wins over a remembered grid's
|
||||
size (its tiles first, in their cells, holes filled first, then tab order);
|
||||
size (its tiles first, in their cells, holes filled first, then tab order;
|
||||
superseded by decision 11: a click brings the remembered grid back as it
|
||||
was, and only a count picked in the menu re-forms it);
|
||||
Ctrl/Cmd+click with the grid closed opens the count in total, that session
|
||||
focused; shrinking keeps the focused tile; only the toggle animates the
|
||||
close; a remembered count larger than the window stays checked but greyed
|
||||
and a click opens what fits; the close keeps its dimmed still until the
|
||||
single view has painted (at most 700 ms); paced connect is in.
|
||||
11. **The grid keeps the layout the user arranged, and a fresh one ranks by
|
||||
work.** Decided by the owner ("when I hit the tiles button, it should prefer
|
||||
to load in tiles that are working and then the most recent working, so the
|
||||
oldest dont get opened ... when I moved around and modified it, save it per
|
||||
browser the layout, so when I turn tiles off and on, always keep what the
|
||||
last setting was, if there was no setting before take the working ones, that
|
||||
ones needs input and then the most recent ones in order"). The layout
|
||||
(cells and holes, tile count, divider sizes, focus, a zoom the user chose)
|
||||
is saved per browser on every change and comes back exactly from the toggle,
|
||||
however the grid closed, and from a reload when the grid was open (a grid
|
||||
closed before the reload stays remembered for the toggle; the page shows the
|
||||
single view); it is never filled to the remembered count nor trimmed to the
|
||||
window. A session gone since frees its cell for the
|
||||
ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
|
||||
working, then needing input, then most recent), which also fills every place
|
||||
the grid fills on its own (a count picked in the menu, a freed cell, an open
|
||||
split's fill). Supersedes decision 10's "the count wins over a remembered
|
||||
grid's size"; the count menu itself, its counts and its other answers stay.
|
||||
|
||||
## Code anchors
|
||||
|
||||
|
||||
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
|
||||
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
|
||||
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
|
||||
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
|
||||
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
|
||||
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
|
||||
because that helper caches servers by port.
|
||||
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
|
||||
read `server.boundPort`. Never 3000.
|
||||
|
||||
## Finding your way around
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
|
||||
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
|
||||
supervising other sessions.**
|
||||
|
||||
Two routes. Start with the skill.
|
||||
Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
|
||||
`codeman agent` commands. Raw HTTP is there for everything else.
|
||||
|
||||
## The agent skill
|
||||
|
||||
@@ -59,6 +60,36 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
|
||||
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
|
||||
completion signals.
|
||||
|
||||
## The `codeman agent` commands
|
||||
|
||||
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
|
||||
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
|
||||
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
|
||||
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
|
||||
ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
|
||||
case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
|
||||
|
||||
```bash
|
||||
codeman agent ls # sessions; * marks this one
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
|
||||
codeman agent read "$SID" # last answer (as the server reads it for that mode)
|
||||
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
|
||||
codeman agent interrupt "$SID" # a bare ESC, conversation intact
|
||||
codeman agent rm "$SID" # any session except this one
|
||||
```
|
||||
|
||||
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
|
||||
ambiguous prefix.
|
||||
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
|
||||
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
|
||||
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
|
||||
prompt is output too, so ask for the marker in halves and wait on the joined form.
|
||||
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
|
||||
worker exited, `4` refused. `--json` prints the response's `data`.
|
||||
|
||||
## The manual path
|
||||
|
||||
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||
|
||||
@@ -90,7 +90,6 @@ every session or only the active tab.
|
||||
| Setting | Notes |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
|
||||
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
|
||||
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
|
||||
| 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). |
|
||||
@@ -99,11 +98,22 @@ every session or only the active tab.
|
||||
| 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. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
### Animations
|
||||
|
||||
All per device, all off by default, applied as you pick them.
|
||||
|
||||
| Setting | Notes |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
|
||||
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
|
||||
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
|
||||
|
||||
### Models
|
||||
|
||||
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||
@@ -142,7 +152,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
|
||||
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
||||
| Animated status effects | Cosmetic. |
|
||||
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
|
||||
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), 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`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
|
||||
|
||||
### Notifications
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
|
||||
| **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. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
|
||||
|
||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
@@ -73,6 +73,10 @@ precedence there.
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
|
||||
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
+36
-16
@@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
|
||||
|
||||
## 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.
|
||||
- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
|
||||
this browser before, you get it back exactly as you left it: the same sessions in the
|
||||
same places, an empty place where you left one, the same number of tiles, your column
|
||||
widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
|
||||
closed since frees its place, which is filled the way a new grid is filled (below).
|
||||
Otherwise, or when none of those sessions is left, you get as many tiles as you last
|
||||
chose (six until you choose; fewer if the window is too small or you have fewer sessions
|
||||
open): an open split's two first; otherwise the sessions that are working (the most
|
||||
recently started first), then the ones waiting on you (red and yellow tabs), then the
|
||||
rest, the most recently used first, so the oldest are the ones left out. The session you
|
||||
are on always comes along and is 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.
|
||||
count you chose 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.
|
||||
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
|
||||
browser. Picking a count opens the grid with that many tiles (the grid you left, its
|
||||
tiles in their places, new ones in the empty places first), and it is what a click opens
|
||||
when there is no grid to bring back. 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 working ones
|
||||
first, then the ones waiting on you, then the most recent. 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
|
||||
the grid closed it opens what the Tiles button would show with that session added: in the
|
||||
empty place while the grid has fewer tiles than the count you chose, else in place of the
|
||||
last tile (never more than the count). 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
|
||||
@@ -89,7 +98,8 @@ 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.
|
||||
the empty slot included, is saved with the grid and comes back when you turn the grid off
|
||||
and on, and on a page reload while the grid is open.
|
||||
|
||||
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
|
||||
@@ -122,8 +132,18 @@ session finder) shows that session on its own, the normal single view. The grid
|
||||
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.
|
||||
The grid is saved in this browser every time you change it (moving, resizing, adding or
|
||||
removing a tile, changing the count, focusing or zooming a tile), and never sent to the
|
||||
server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
|
||||
tile or session), the Tiles button brings it back as it was. A page reload brings it back
|
||||
when the grid was open; after you left it, a reload shows the single view and the Tiles
|
||||
button still brings the grid back. A session that was closed or popped out into its own
|
||||
window in the meantime frees its place for another one, picked the way a new grid picks
|
||||
them; the place stays empty only when no other session is left. Right after a page reload
|
||||
the page does not know yet which sessions are waiting for your answer, so that pick goes by
|
||||
which sessions are working and which you used last. If the window has become too small for
|
||||
all the tiles, the tile you were in fills the grid until the window is wide enough again,
|
||||
and the rest of the layout is kept.
|
||||
|
||||
Split shows the same logo, name and model above both of its panes.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ It renders what it can:
|
||||
| Kind | Behaviour |
|
||||
| ------------------------ | ------------------------------------------------------------------------- |
|
||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
||||
| 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). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||
| Images | Inline. |
|
||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -13389,7 +13389,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.40.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"version": "1.41.0",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -42,7 +42,7 @@
|
||||
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
|
||||
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
|
||||
"knip": "npx --yes knip@latest --config config/knip.json",
|
||||
"release": "changeset publish"
|
||||
"release": "node scripts/npm-release.mjs"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
@@ -171,7 +171,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/Ark0N/Codeman/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Ark0N/Codeman#readme",
|
||||
"homepage": "https://getcodeman.com",
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/postinstall.js",
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 87f1c9c: ### Thanks
|
||||
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
|
||||
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
|
||||
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
|
||||
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
|
||||
|
||||

|
||||
|
||||
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
|
||||
|
||||
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
|
||||
|
||||
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
|
||||
|
||||
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
|
||||
|
||||
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
|
||||
|
||||
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
|
||||
|
||||
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
|
||||
|
||||
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
|
||||
|
||||
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
|
||||
|
||||
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
|
||||
|
||||
**Fixes.**
|
||||
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
|
||||
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
|
||||
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
|
||||
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
|
||||
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
|
||||
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
|
||||
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
|
||||
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
|
||||
|
||||
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
|
||||
|
||||
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
|
||||
|
||||
### Scroll awareness
|
||||
|
||||
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
|
||||
The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
|
||||
* `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
|
||||
* the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
|
||||
*/
|
||||
export function promptRowInViewport(terminal: XtermTerminal): boolean {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
if (buf.viewportY === buf.baseY) return true;
|
||||
if (typeof buf.cursorY !== 'number') return false;
|
||||
const cursorRow = buf.baseY + buf.cursorY;
|
||||
return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
FontStyle,
|
||||
} from './types.js';
|
||||
import { getCellDimensions } from './cell-dimensions.js';
|
||||
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
|
||||
import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
|
||||
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
|
||||
|
||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
// Cache font properties
|
||||
this._cacheFont();
|
||||
|
||||
// Scroll detection: hide overlay when scrolled away from bottom
|
||||
// Scroll detection: hide the overlay while the cursor row is scrolled out of view
|
||||
this._scrollHandler = () => {
|
||||
try {
|
||||
const buf = this._terminal!.buffer.active;
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
if (!promptRowInViewport(this._terminal!)) {
|
||||
this._overlay!.style.display = 'none';
|
||||
if (this._scrollTimer) {
|
||||
clearTimeout(this._scrollTimer);
|
||||
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
try {
|
||||
const buf = this._terminal.buffer.active;
|
||||
|
||||
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
// Hide the overlay while the cursor row is scrolled out of view
|
||||
if (!promptRowInViewport(this._terminal)) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
|
||||
import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
|
||||
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||
|
||||
function term(lines: string[]) {
|
||||
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptRowInViewport', () => {
|
||||
const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
|
||||
({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
|
||||
|
||||
it('is true at the bottom regardless of the cursor', () => {
|
||||
expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
|
||||
// scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
|
||||
expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
|
||||
// cursor exactly on the last visible row
|
||||
expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false once the cursor row is scrolled out of the viewport', () => {
|
||||
expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
|
||||
expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
|
||||
});
|
||||
|
||||
it('keeps the bottom-only rule when the buffer has no cursorY', () => {
|
||||
expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -725,3 +725,34 @@ describe('ZerolagInputAddon', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewport scrolled away from the bottom', () => {
|
||||
function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
|
||||
const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
|
||||
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
|
||||
return { addon, mock, overlay };
|
||||
}
|
||||
|
||||
it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
|
||||
// The host parks the viewport to keep trailing blank rows out of view; the
|
||||
// prompt and cursor are still visible, so the user's text must be too.
|
||||
const { addon, overlay } = parked(0, 1, 0);
|
||||
addon.appendText('abc');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
expect(overlay.style.display).not.toBe('none');
|
||||
expect(overlay.textContent).toContain('abc');
|
||||
});
|
||||
|
||||
it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
|
||||
const { addon, overlay } = parked(0, 30, 0);
|
||||
addon.appendText('abc');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
|
||||
*
|
||||
* npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
|
||||
* @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
|
||||
* keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
|
||||
* Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
|
||||
* `files` cannot help: npm-packlist always includes every root `README.*`.
|
||||
*
|
||||
* Renaming the file would break every link to it, so for the length of the publish only it
|
||||
* moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
|
||||
* whatever the publish did. The move happens HERE, inside the publish command, never as a
|
||||
* step before `changesets/action` in release.yml: that action also runs the version path and
|
||||
* commits the working tree into its version PR, which would commit the deletion.
|
||||
*
|
||||
* A previous run killed between the move and the restore leaves the aside copy behind; the
|
||||
* next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
|
||||
*
|
||||
* node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
|
||||
*/
|
||||
import { existsSync, renameSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const HIDDEN_README = 'README.zh-CN.md';
|
||||
export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
|
||||
|
||||
/**
|
||||
* Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
|
||||
* when `publish` fails or throws. Returns the exit code `publish` returned.
|
||||
*
|
||||
* @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
|
||||
* @returns {number}
|
||||
*/
|
||||
export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
|
||||
const original = join(root, HIDDEN_README);
|
||||
const aside = join(root, ASIDE_NAME);
|
||||
if (existsSync(aside) && !existsSync(original)) {
|
||||
renameSync(aside, original);
|
||||
log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
|
||||
}
|
||||
const moved = existsSync(original);
|
||||
if (moved) {
|
||||
renameSync(original, aside);
|
||||
log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
|
||||
}
|
||||
try {
|
||||
return publish();
|
||||
} finally {
|
||||
if (moved) {
|
||||
renameSync(aside, original);
|
||||
log(`npm-release: ${HIDDEN_README} restored`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runChangesetPublish() {
|
||||
const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
||||
if (result.error) {
|
||||
console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
|
||||
return 1;
|
||||
}
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
|
||||
}
|
||||
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
|
||||
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
|
||||
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
|
||||
|
||||
Workers in **every other mode** never receive this preamble, but they have the same
|
||||
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
|
||||
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
|
||||
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
|
||||
URL) enforced in code.
|
||||
|
||||
## 0. Guard and bootstrap
|
||||
|
||||
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
|
||||
@@ -0,0 +1,980 @@
|
||||
/**
|
||||
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
|
||||
* inside a Codeman session, in every CLI mode.
|
||||
*
|
||||
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
|
||||
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
|
||||
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
|
||||
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
|
||||
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
|
||||
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
|
||||
* preamble — has the same verbs from one line in its AGENTS.md.
|
||||
*
|
||||
* Invariants (each asserted in `test/cli-agent.test.ts`):
|
||||
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
|
||||
* never guesses a URL — a server you are not part of is not yours to drive.
|
||||
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
|
||||
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
|
||||
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
|
||||
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
|
||||
* direction refuses. Ids appear in full and 8-char form, so equality alone
|
||||
* misses a real combination — and the miss deletes the caller.
|
||||
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
|
||||
* verb. `9` would resolve to whichever session is alone with that first
|
||||
* character, the user's own interactive tab included.
|
||||
*
|
||||
* Commands live here as functions returning an exit code, not calling
|
||||
* `process.exit`, so the whole surface is unit-testable against a fake server.
|
||||
*
|
||||
* @module cli-agent
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
basicAuthHeader,
|
||||
credentialsFrom,
|
||||
readCodemanEnvFile,
|
||||
type CodemanCredentials,
|
||||
} from './codeman-credentials.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { GLYPH, palette, table } from './cli-style.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Context and guard
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentContext {
|
||||
/** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
|
||||
apiUrl: string;
|
||||
/** This session's id, from `CODEMAN_SESSION_ID`. */
|
||||
selfId: string;
|
||||
/** Basic-auth credentials, when the server has a password. */
|
||||
auth?: CodemanCredentials;
|
||||
}
|
||||
|
||||
/** Thrown when the process is not inside a Codeman-managed session. */
|
||||
export class AgentGuardError extends Error {}
|
||||
|
||||
/** Exit codes shared by every verb; a shell agent can branch on them. */
|
||||
export const EXIT = {
|
||||
ok: 0,
|
||||
error: 1,
|
||||
timeout: 2,
|
||||
dead: 3,
|
||||
refused: 4,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the context from the environment, or throw `AgentGuardError`.
|
||||
*
|
||||
* Credentials in the order every client of the API uses (`credentialsFrom`, shared
|
||||
* with `codeman attach` and the TUI): each field from the environment (a session
|
||||
* inherits the server's), then the data dir's `.env`. No password means the server
|
||||
* is open (single-user) — or it is not, and the 401 says so.
|
||||
*/
|
||||
export function resolveAgentContext(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
envFile: () => Record<string, string> = readCodemanEnvFile
|
||||
): AgentContext {
|
||||
if (env.CODEMAN_MUX !== '1') {
|
||||
throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
|
||||
}
|
||||
const apiUrl = env.CODEMAN_API_URL?.trim();
|
||||
if (!apiUrl) {
|
||||
throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
|
||||
}
|
||||
const selfId = env.CODEMAN_SESSION_ID?.trim();
|
||||
if (!selfId) {
|
||||
throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
|
||||
}
|
||||
const credentials = credentialsFrom(env, envFile());
|
||||
return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pure helpers (the invariants)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Is `id` this session? Prefix in BOTH directions, because ids appear in full and
|
||||
* in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
|
||||
* shorter than 8 characters cannot prove anything and is treated as "maybe me".
|
||||
*/
|
||||
export function isSelfSession(selfId: string, id: string): boolean {
|
||||
if (!id || selfId.length < 8) return true;
|
||||
return id.startsWith(selfId) || selfId.startsWith(id);
|
||||
}
|
||||
|
||||
/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
|
||||
export function deleteRefusal(selfId: string, id: string): string | undefined {
|
||||
if (!id) return 'refusing: empty session id';
|
||||
if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
|
||||
if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
|
||||
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
|
||||
* back into a single line, so the multi-line refusal below would never see it.
|
||||
*/
|
||||
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
|
||||
if (words.length === 1) return { text: words[0] };
|
||||
return {
|
||||
error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `send` refuses this text, or `undefined` when it is printable. The composer
|
||||
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
|
||||
* DEL) reaches the pane as a keypress. None of that is a prompt.
|
||||
*/
|
||||
export function inputRefusal(text: string): string | undefined {
|
||||
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
|
||||
// The composer is one line: the server strips newlines, which silently joins the
|
||||
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
|
||||
if (/[\n\r\t]/.test(text)) {
|
||||
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
|
||||
}
|
||||
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
|
||||
if (control) {
|
||||
const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
|
||||
return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
|
||||
* caller asked to type without submitting. Never anything else.
|
||||
*/
|
||||
export function buildSendBody(
|
||||
text: string,
|
||||
options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
input: options.enter ? `${text}\r` : text,
|
||||
useMux: true,
|
||||
clientId: options.clientId,
|
||||
seq: options.seq,
|
||||
};
|
||||
if (options.wait !== undefined) body.wait = options.wait;
|
||||
if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
|
||||
export function buildInterruptBody(clientId: string, seq: number): Record<string, unknown> {
|
||||
return { input: '\u001b', useMux: true, clientId, seq };
|
||||
}
|
||||
|
||||
/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
|
||||
export function defaultClientId(selfId: string, suffix = ''): string {
|
||||
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
|
||||
* titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
|
||||
*/
|
||||
export function stripAnsi(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
|
||||
}
|
||||
|
||||
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
|
||||
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
|
||||
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
|
||||
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
|
||||
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
|
||||
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
|
||||
*/
|
||||
export function waitExitCode(wait: WaitResult | undefined): number {
|
||||
if (!wait) return EXIT.error;
|
||||
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
|
||||
if (wait.timedOut) return EXIT.timeout;
|
||||
if (wait.matched === false) return EXIT.timeout;
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiEnvelope<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: number;
|
||||
/** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
|
||||
json?: ApiEnvelope<T>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface WaitResult {
|
||||
/** The signal that fired, or null when the wait ended without one. */
|
||||
signal?: string | null;
|
||||
timedOut?: boolean;
|
||||
/** The session went away (deleted / torn down / the write failed) before the wait resolved. */
|
||||
ended?: boolean;
|
||||
timeoutMs?: number;
|
||||
until?: string[];
|
||||
matched?: boolean;
|
||||
match?: string;
|
||||
snippet?: string;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method: 'GET' | 'POST' | 'DELETE';
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
/** Socket timeout; long-polls pass their own timeout plus headroom. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
|
||||
|
||||
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||||
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else.
|
||||
'X-Codeman-Parent-Session': ctx.selfId,
|
||||
};
|
||||
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
|
||||
export const httpRequest: ApiRequest = (ctx, options) => {
|
||||
const url = new URL(options.path, ctx.apiUrl);
|
||||
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||
}
|
||||
const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
const headers: Record<string, string | number> = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
|
||||
if (bodyText !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(bodyText);
|
||||
}
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
method: options.method,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: options.timeoutMs ?? 30_000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf-8');
|
||||
let json: ApiEnvelope | undefined;
|
||||
try {
|
||||
json = JSON.parse(text) as ApiEnvelope;
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, json, text });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
|
||||
req.on('error', reject);
|
||||
if (bodyText !== undefined) req.write(bodyText);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
|
||||
export function describeFailure(res: ApiResponse): string {
|
||||
if (res.json && !res.json.success) {
|
||||
return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
|
||||
}
|
||||
const text = res.text.trim().split('\n')[0] ?? '';
|
||||
if (res.status === 401)
|
||||
return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
|
||||
return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Commands
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentIo {
|
||||
out: (line: string) => void;
|
||||
err: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface AgentDeps {
|
||||
ctx: AgentContext;
|
||||
request: ApiRequest;
|
||||
io: AgentIo;
|
||||
json: boolean;
|
||||
/** Clock for `seq`; injectable so tests are deterministic. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
|
||||
function emitJson(deps: AgentDeps, data: unknown): void {
|
||||
deps.io.out(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
|
||||
if (deps.json) {
|
||||
deps.io.out(JSON.stringify({ success: false, error: message }));
|
||||
} else {
|
||||
deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
workingDir?: string;
|
||||
pid?: number | null;
|
||||
parentSessionId?: string | null;
|
||||
}
|
||||
|
||||
/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
|
||||
const FULL_ID_LENGTH = 36;
|
||||
|
||||
/**
|
||||
* Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
|
||||
* the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
|
||||
*/
|
||||
export const MIN_ID_PREFIX_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
|
||||
* prefixes and the routes answer 404 to those (measured live), so anything shorter
|
||||
* than a full id resolves through the session list; an ambiguous prefix refuses
|
||||
* rather than picking one. Below 8 characters it refuses before the list: "unique"
|
||||
* means nothing for `9` — it names whatever session happens to be alone with that
|
||||
* first character, and `rm`/`send` would act on it.
|
||||
*/
|
||||
export async function resolveSessionId(
|
||||
deps: AgentDeps,
|
||||
id: string
|
||||
): Promise<{ id: string } | { error: string; code: number }> {
|
||||
if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
|
||||
if (id.length < MIN_ID_PREFIX_LENGTH) {
|
||||
return {
|
||||
error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
|
||||
code: EXIT.refused,
|
||||
};
|
||||
}
|
||||
if (id.length >= FULL_ID_LENGTH) return { id };
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
|
||||
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
|
||||
if (matches.length === 1) return { id: matches[0].id };
|
||||
if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
|
||||
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
|
||||
}
|
||||
|
||||
/** `agent ls` — every session the caller can see, self marked. */
|
||||
export async function agentLs(deps: AgentDeps): Promise<number> {
|
||||
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
|
||||
if (deps.json) {
|
||||
emitJson(
|
||||
deps,
|
||||
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
deps.io.out(palette.muted('(no sessions)'));
|
||||
return EXIT.ok;
|
||||
}
|
||||
const rows = sessions.map((s) => [
|
||||
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
|
||||
s.id.slice(0, 8),
|
||||
s.mode ?? '?',
|
||||
s.status ?? '?',
|
||||
s.name || s.workingDir || '',
|
||||
]);
|
||||
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
|
||||
deps.io.out(
|
||||
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
caseName: string;
|
||||
mode: string;
|
||||
name?: string;
|
||||
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
|
||||
ready: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-scratch label for a case directory a spawn CREATES (the server applies it only
|
||||
* when quick-start makes the directory). The Add Case UI offers a recursive delete for
|
||||
* such directories, so this header must never ride any other request: mislabelling a
|
||||
* real repo there is the one failure in this area that costs actual work.
|
||||
*/
|
||||
export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
|
||||
|
||||
/**
|
||||
* What the mode's TUI draws once its composer can take a prompt, from the CLI registry
|
||||
* (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
|
||||
*/
|
||||
export function composerReadyMark(mode: string): string | undefined {
|
||||
return getCli(mode)?.capabilities.composerReadyMark;
|
||||
}
|
||||
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
|
||||
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
|
||||
const body: Record<string, unknown> = {
|
||||
caseName: options.caseName,
|
||||
mode: options.mode,
|
||||
parentSessionId: deps.ctx.selfId,
|
||||
};
|
||||
if (options.name) body.sessionName = options.name;
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: '/api/v1/quick-start',
|
||||
body,
|
||||
headers: { ...AGENT_ORIGIN_HEADER },
|
||||
});
|
||||
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
|
||||
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
|
||||
const sid = data.sessionId;
|
||||
|
||||
let ready: boolean | undefined;
|
||||
let readinessError: string | undefined;
|
||||
let dead = false;
|
||||
const mark = composerReadyMark(options.mode);
|
||||
if (options.ready && mark) {
|
||||
const wait = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
|
||||
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
});
|
||||
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
|
||||
// composer never showed up": report the real reason instead of the trust-dialog hint.
|
||||
if (!wait.json?.success) readinessError = describeFailure(wait);
|
||||
else {
|
||||
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
|
||||
// A worker that died while we waited is exit 3 like every other wait, not a
|
||||
// "composer not seen" timeout that sends the caller looking for a dialog.
|
||||
dead = waitExitCode(result) === EXIT.dead;
|
||||
ready = !dead && Boolean(result?.matched);
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.json) {
|
||||
emitJson(deps, { ...data, ready, readinessError });
|
||||
} else {
|
||||
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
|
||||
const say = (line: string) => deps.io.err(line);
|
||||
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
|
||||
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
|
||||
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
|
||||
if (ready === false && !dead) {
|
||||
say(
|
||||
palette.warn(
|
||||
`${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
|
||||
if (ready === undefined && !readinessError && options.ready) {
|
||||
say(
|
||||
palette.muted(
|
||||
` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
|
||||
)
|
||||
);
|
||||
}
|
||||
deps.io.out(sid);
|
||||
}
|
||||
if (readinessError) return EXIT.error;
|
||||
if (dead) return EXIT.dead;
|
||||
return ready === false ? EXIT.timeout : EXIT.ok;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
id: string;
|
||||
text: string;
|
||||
enter: boolean;
|
||||
/** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
|
||||
wait?: string | true;
|
||||
timeoutMs?: number;
|
||||
clientId?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
|
||||
export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) {
|
||||
return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
|
||||
}
|
||||
const refusal = inputRefusal(options.text);
|
||||
if (refusal) return fail(deps, refusal, EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const body = buildSendBody(options.text, {
|
||||
enter: options.enter,
|
||||
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
|
||||
seq: options.seq ?? (deps.now ?? Date.now)(),
|
||||
wait: options.wait,
|
||||
waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
|
||||
});
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
|
||||
body,
|
||||
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as
|
||||
| { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
|
||||
| undefined;
|
||||
if (deps.json) emitJson(deps, data ?? {});
|
||||
// Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
|
||||
// holds the chunk and types it once the pane is back (`buffered`), or the chunk was
|
||||
// over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
|
||||
// so a retry needs a new one (the default, the clock, gives it that).
|
||||
if (data?.dropped) {
|
||||
if (!deps.json) {
|
||||
deps.io.err(
|
||||
palette.err(
|
||||
`${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
|
||||
)
|
||||
);
|
||||
}
|
||||
return EXIT.error;
|
||||
}
|
||||
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
|
||||
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
|
||||
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
|
||||
if (data?.delivered === false && !data.duplicate) {
|
||||
if (!deps.json) {
|
||||
deps.io.err(
|
||||
palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
|
||||
);
|
||||
}
|
||||
return EXIT.dead;
|
||||
}
|
||||
if (!deps.json) {
|
||||
const noEnter = options.enter ? '' : ' (no Enter)';
|
||||
if (data?.duplicate) {
|
||||
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
|
||||
} else if (data?.buffered) {
|
||||
deps.io.out(
|
||||
palette.ok(
|
||||
`${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
|
||||
)
|
||||
);
|
||||
} else if (data?.delivered === true) {
|
||||
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
|
||||
} else {
|
||||
// Fire-and-forget answers before the write, so there is no delivery report here.
|
||||
deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
|
||||
}
|
||||
if (data?.wait) deps.io.out(describeWait(data.wait));
|
||||
}
|
||||
if (options.wait === undefined) return EXIT.ok;
|
||||
return waitExitCode(data?.wait);
|
||||
}
|
||||
|
||||
function describeWait(wait: WaitResult): string {
|
||||
if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
|
||||
if (wait.ended) {
|
||||
return palette.err(
|
||||
`${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
|
||||
);
|
||||
}
|
||||
// A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
|
||||
// so the line stays neutral instead of looking like an error to whoever reads the log.
|
||||
if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
|
||||
if (wait.matched !== undefined) {
|
||||
return wait.matched
|
||||
? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
|
||||
: palette.muted('not matched (exit 2)');
|
||||
}
|
||||
return palette.ok(
|
||||
`${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface WaitOptions {
|
||||
id: string;
|
||||
until?: string;
|
||||
match?: string;
|
||||
from?: 'buffer' | 'now';
|
||||
fresh?: boolean;
|
||||
nocase?: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
|
||||
export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<number> {
|
||||
if (options.until && options.match)
|
||||
return fail(deps, 'use either --until <signals> or --match <marker>, not both', EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const sid = encodeURIComponent(target.id);
|
||||
const res = options.match
|
||||
? await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${sid}/wait-output`,
|
||||
query: {
|
||||
match: options.match,
|
||||
from: options.from ?? 'buffer',
|
||||
nocase: options.nocase ? 1 : undefined,
|
||||
timeout: options.timeoutMs,
|
||||
},
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
})
|
||||
: await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${sid}/wait`,
|
||||
query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
|
||||
timeoutMs: options.timeoutMs + 10_000,
|
||||
});
|
||||
// A 400 here is the server saying "this mode has no such signal" (until=stop on an
|
||||
// external CLI). Passed through, never papered over: the marker path is the answer.
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
|
||||
if (deps.json) {
|
||||
emitJson(deps, data ?? {});
|
||||
} else if (data?.wait) {
|
||||
deps.io.out(describeWait(data.wait));
|
||||
if (data.limitPaused)
|
||||
deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
|
||||
}
|
||||
return waitExitCode(data?.wait);
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
id: string;
|
||||
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
|
||||
tail?: number;
|
||||
/** Whole conversation (`context=full`) instead of the last assistant message. */
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
|
||||
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<number> {
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const sid = encodeURIComponent(target.id);
|
||||
if (options.tail !== undefined) {
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${sid}/terminal`,
|
||||
query: { tail: options.tail },
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
|
||||
if (deps.json) emitJson(deps, res.json.data);
|
||||
else deps.io.out(stripAnsi(buffer));
|
||||
return EXIT.ok;
|
||||
}
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${sid}/last-response`,
|
||||
query: { context: options.full ? 'full' : undefined },
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
const data = res.json.data as
|
||||
| { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
|
||||
| undefined;
|
||||
if (deps.json) {
|
||||
emitJson(deps, data ?? {});
|
||||
return EXIT.ok;
|
||||
}
|
||||
if (options.full && data?.messages) {
|
||||
for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
const text = data?.text ?? '';
|
||||
if (!text) {
|
||||
deps.io.err(
|
||||
palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
deps.io.out(text);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
|
||||
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise<number> {
|
||||
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
|
||||
body,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
if (deps.json) emitJson(deps, res.json.data ?? {});
|
||||
else
|
||||
deps.io.out(
|
||||
palette.ok(
|
||||
`${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
|
||||
)
|
||||
);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** `agent rm` — delete a session that is provably not this one. */
|
||||
export async function agentRm(deps: AgentDeps, options: { id: string }): Promise<number> {
|
||||
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
|
||||
if (refusal) return fail(deps, refusal, EXIT.refused);
|
||||
const target = await resolveSessionId(deps, options.id);
|
||||
if ('error' in target) return fail(deps, target.error, target.code);
|
||||
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
|
||||
// to me only if the list is lying, but a delete is the one call worth the paranoia.
|
||||
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
|
||||
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
|
||||
const res = await deps.request(deps.ctx, {
|
||||
method: 'DELETE',
|
||||
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
|
||||
});
|
||||
if (!res.json?.success) return fail(deps, describeFailure(res));
|
||||
if (deps.json) emitJson(deps, res.json.data ?? {});
|
||||
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Commander wiring
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WAIT_MS = 60_000;
|
||||
|
||||
/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
|
||||
function liveDeps(json: boolean): AgentDeps | undefined {
|
||||
try {
|
||||
return {
|
||||
ctx: resolveAgentContext(),
|
||||
request: httpRequest,
|
||||
json,
|
||||
io: { out: (line) => console.log(line), err: (line) => console.error(line) },
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AgentGuardError) {
|
||||
console.error(palette.err(`${GLYPH.fail} ${err.message}`));
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a verb with the live transport and turn its exit code into the process exit. */
|
||||
async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): Promise<void> {
|
||||
const deps = liveDeps(json);
|
||||
if (!deps) {
|
||||
process.exitCode = EXIT.refused;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.exitCode = await verb(deps);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
|
||||
process.exitCode = EXIT.error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Register `codeman agent …` on the program. */
|
||||
export function registerAgentCommands(program: Command): Command {
|
||||
const agent = program
|
||||
.command('agent')
|
||||
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
|
||||
|
||||
agent
|
||||
.command('ls')
|
||||
.alias('list')
|
||||
.description('List sessions; * marks this one')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
|
||||
|
||||
agent
|
||||
.command('spawn <case>')
|
||||
.description(
|
||||
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
|
||||
)
|
||||
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
|
||||
.option('-n, --name <name>', 'Session name shown in the UI')
|
||||
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
|
||||
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action(
|
||||
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentSpawn(deps, {
|
||||
caseName,
|
||||
mode: options.mode,
|
||||
name: options.name,
|
||||
ready: options.ready,
|
||||
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('send <id> <text...>')
|
||||
.description(
|
||||
'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
|
||||
)
|
||||
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
|
||||
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
|
||||
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
|
||||
.option('--no-enter', 'Type the text without submitting it')
|
||||
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
|
||||
.option(
|
||||
'--seq <n>',
|
||||
'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
|
||||
)
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action(
|
||||
(
|
||||
id: string,
|
||||
words: string[],
|
||||
options: {
|
||||
wait?: boolean;
|
||||
until?: string;
|
||||
timeout?: string;
|
||||
enter: boolean;
|
||||
clientId?: string;
|
||||
seq?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
) =>
|
||||
run(Boolean(options.json), (deps) => {
|
||||
const prompt = sendPromptFromArgs(words);
|
||||
if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
|
||||
return agentSend(deps, {
|
||||
id,
|
||||
text: prompt.text,
|
||||
enter: options.enter,
|
||||
wait: options.until ?? (options.wait ? true : undefined),
|
||||
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
|
||||
clientId: options.clientId,
|
||||
seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
agent
|
||||
.command('wait <id>')
|
||||
.description(
|
||||
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
|
||||
)
|
||||
.option(
|
||||
'-u, --until <signals>',
|
||||
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
|
||||
)
|
||||
.option(
|
||||
'-m, --match <marker>',
|
||||
'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
|
||||
)
|
||||
.option('--from <where>', 'buffer (scan existing output first, the default) or now', 'buffer')
|
||||
.option('--nocase', 'Case-insensitive --match')
|
||||
.option('--fresh', 'Require an actual transition (--until only)')
|
||||
.option('-t, --timeout <ms>', 'Budget in ms', String(DEFAULT_WAIT_MS))
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action(
|
||||
(
|
||||
id: string,
|
||||
options: {
|
||||
until?: string;
|
||||
match?: string;
|
||||
from: string;
|
||||
nocase?: boolean;
|
||||
fresh?: boolean;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentWait(deps, {
|
||||
id,
|
||||
until: options.until,
|
||||
match: options.match,
|
||||
from: options.from === 'now' ? 'now' : 'buffer',
|
||||
nocase: options.nocase,
|
||||
fresh: options.fresh,
|
||||
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('read <id>')
|
||||
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
|
||||
.option('--tail <bytes>', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
|
||||
.option('--full', 'The whole conversation instead of the last assistant message')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) =>
|
||||
agentRead(deps, {
|
||||
id,
|
||||
tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
|
||||
full: options.full,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
agent
|
||||
.command('interrupt <id>')
|
||||
.description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { json?: boolean }) =>
|
||||
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
|
||||
);
|
||||
|
||||
agent
|
||||
.command('rm <id>')
|
||||
.description('Delete any session except this one (refuses your own id)')
|
||||
.option('--json', 'Machine-readable output')
|
||||
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
|
||||
|
||||
return agent;
|
||||
}
|
||||
+8
-28
@@ -15,9 +15,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readCodemanCredentials } from './codeman-credentials.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -42,32 +44,8 @@ function makeAttachmentMagicLink(filePath: string): string {
|
||||
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
function readCodemanEnv(): Record<string, string> {
|
||||
const envPath = dataPath('.env');
|
||||
try {
|
||||
const text = readFileSync(envPath, 'utf-8');
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[match[1]] = value;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
|
||||
const envFile = readCodemanEnv();
|
||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
|
||||
const body = JSON.stringify({ path: filePath });
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
@@ -252,6 +230,10 @@ skillCmd
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Agent Commands (session-to-session, any CLI mode) ============
|
||||
|
||||
registerAgentCommands(program);
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
@@ -641,9 +623,7 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const envFile = readCodemanEnv();
|
||||
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||
const { username, password } = readCodemanCredentials();
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (password) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Credentials for a client of this Codeman instance's own API.
|
||||
*
|
||||
* Env first, the data dir's `.env` as the fallback — the hand-authored file
|
||||
* `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
|
||||
* three clients cannot drift on quoting, comments or the default username.
|
||||
*
|
||||
* @module codeman-credentials
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
export interface CodemanCredentials {
|
||||
username: string;
|
||||
/** Absent when no password is configured (or only the server's environment has it). */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
|
||||
* prefix tolerated (the file is hand-authored, often sourced by a shell too), one
|
||||
* layer of matching quotes stripped, anything that is not an assignment ignored.
|
||||
*/
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[match[1]] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
|
||||
export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record<string, string> {
|
||||
try {
|
||||
return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lookup order every client uses, per field: the environment, then the `.env`
|
||||
* file, then (username only) `admin`. Pure, so a caller with its own environment
|
||||
* object (`codeman agent`'s guard takes one for testability) gets the same answer.
|
||||
*/
|
||||
export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record<string, string>): CodemanCredentials {
|
||||
const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
|
||||
const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
|
||||
return password ? { username, password } : { username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials for the API. No password means no auth is configured, or the user has
|
||||
* it only in the server's environment, in which case the API answers 401.
|
||||
*/
|
||||
export function readCodemanCredentials(
|
||||
envFilePath: string = dataPath('.env'),
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): CodemanCredentials {
|
||||
return credentialsFrom(env, readCodemanEnvFile(envFilePath));
|
||||
}
|
||||
|
||||
/** `Authorization` header value, or undefined when there is no password to send. */
|
||||
export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
|
||||
if (!credentials.password) return undefined;
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
|
||||
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||
// transcript layout nobody has measured is never touched.
|
||||
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
||||
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
|
||||
composerReadyMark: z.string().min(1).max(64).optional(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
@@ -449,6 +451,7 @@ const capabilitiesSchema = z
|
||||
'codex-toml',
|
||||
'opencode-json',
|
||||
'antigravity-json',
|
||||
'copilot-json',
|
||||
] as const satisfies readonly McpConfigFormat[]),
|
||||
// The env var the CLI reads to move the file, and the path under it (same no-traversal
|
||||
// rule: sync writes there too). Resolved from the server env at call time, never here.
|
||||
|
||||
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
|
||||
// in them, so a copy can drop two and paste flush. Claude and codex are the only
|
||||
// entries that declare this, because theirs are the only gutters that have been measured.
|
||||
transcriptGutter: 2,
|
||||
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
|
||||
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
|
||||
composerReadyMark: 'shift+tab',
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
@@ -1342,6 +1345,8 @@ const DEEPSEEK: CliEntry = {
|
||||
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
|
||||
// the session can disarm the bridge, and docker/remote cannot reach it at all.
|
||||
hooks: 'supervised',
|
||||
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
|
||||
composerReadyMark: '❯',
|
||||
transcript: 'deepseek-zstd',
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
|
||||
@@ -105,7 +105,13 @@ export type ModelConfigResolverName = 'deepseek-route';
|
||||
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
|
||||
|
||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
export type McpConfigFormat =
|
||||
| 'claude-json'
|
||||
| 'gemini-json'
|
||||
| 'codex-toml'
|
||||
| 'opencode-json'
|
||||
| 'antigravity-json'
|
||||
| 'copilot-json';
|
||||
|
||||
export interface CliLaunch {
|
||||
params: Record<string, ParamSpec>;
|
||||
@@ -416,6 +422,17 @@ export interface CliCapabilities {
|
||||
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||
*/
|
||||
transcriptGutter?: number;
|
||||
/**
|
||||
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
|
||||
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
|
||||
*
|
||||
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
|
||||
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
|
||||
* mistake for a composer, so claude declares its composer's own hint text instead.
|
||||
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
|
||||
* the caller synchronizes on `wait-output` markers.
|
||||
*/
|
||||
composerReadyMark?: string;
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
|
||||
@@ -495,6 +495,10 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
|
||||
SSH_ASKPASS_REQUIRE: 'never',
|
||||
DISPLAY: '',
|
||||
GCM_INTERACTIVE: 'never',
|
||||
// classifyGitFailure() matches git's ENGLISH stderr; a German or French locale would
|
||||
// turn a missing ref into a generic FAILED (422 instead of 400).
|
||||
LC_ALL: 'C',
|
||||
LANG: 'C',
|
||||
GIT_SSH_COMMAND:
|
||||
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -157,12 +158,15 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
|
||||
// Ignore common heavy directories for performance
|
||||
ignored: (path: string) => {
|
||||
// Skip node_modules, .git, and other heavy directories
|
||||
// Skip node_modules, .git, and other heavy directories, and Codeman's
|
||||
// own upload folders: a pdf the user handed to the agent is not a file
|
||||
// the agent produced.
|
||||
if (
|
||||
path.includes('/node_modules/') ||
|
||||
path.includes('/.git/') ||
|
||||
path.includes('/dist/') ||
|
||||
path.includes('/.next/')
|
||||
path.includes('/.next/') ||
|
||||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @fileoverview MCP sync targets that are not Codeman run modes.
|
||||
*
|
||||
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
|
||||
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
|
||||
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
|
||||
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
|
||||
* config file already exists (an absent tool is reported `absent`, never created), and sort after
|
||||
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
|
||||
*
|
||||
* @module mcp-sync-targets
|
||||
*/
|
||||
|
||||
import { accessSync, constants as fsConstants } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
import type { McpSyncTarget } from './mcp-sync.js';
|
||||
|
||||
export interface McpSyncOnlyTool {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Home-relative default location of the MCP config file. */
|
||||
path: string;
|
||||
format: McpConfigFormat;
|
||||
/** The env var the tool reads to move its home, and the file under it. */
|
||||
relocation?: { envVar: string; path: string };
|
||||
/** The executable whose presence on this machine means the tool is installed. */
|
||||
binary: string;
|
||||
}
|
||||
|
||||
export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
|
||||
{
|
||||
id: 'copilot',
|
||||
label: 'GitHub Copilot CLI',
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
// COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
binary: 'copilot',
|
||||
},
|
||||
];
|
||||
|
||||
/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
|
||||
export function binaryOnPath(name: string, env: Record<string, string | undefined> = process.env): boolean {
|
||||
const dirs = [
|
||||
...(env.PATH ?? '').split(delimiter).filter(Boolean),
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
];
|
||||
return dirs.some((dir) => {
|
||||
try {
|
||||
accessSync(join(dir, name), fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The sync-only tools as sync targets, skipping any id the registry already provides. */
|
||||
export function mcpSyncOnlyTargets(
|
||||
taken: ReadonlySet<string>,
|
||||
isInstalled: (binary: string) => boolean = binaryOnPath
|
||||
): McpSyncTarget[] {
|
||||
return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
path: t.path,
|
||||
format: t.format,
|
||||
...(t.relocation ? { relocation: t.relocation } : {}),
|
||||
installed: isInstalled(t.binary),
|
||||
}));
|
||||
}
|
||||
+63
-1
@@ -12,7 +12,8 @@
|
||||
* does not understand) is never rewritten and nothing is ever removed. Same name with a
|
||||
* different definition is reported as a conflict and left alone.
|
||||
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
|
||||
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
|
||||
* `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
|
||||
* `settings.json`) is not propagated: copying it would
|
||||
* switch it on in every other CLI.
|
||||
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
|
||||
* duplicate table) is never written, and a write is only made after the NEW text has been
|
||||
@@ -284,6 +285,29 @@ function toOpencode(s: McpServer): Record<string, unknown> {
|
||||
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
|
||||
* `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
|
||||
* this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
|
||||
*/
|
||||
function fromCopilot(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
|
||||
return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toCopilot(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') {
|
||||
return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
|
||||
}
|
||||
return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
|
||||
}
|
||||
|
||||
interface JsonDialect {
|
||||
/** Key holding the server table. */
|
||||
key: string;
|
||||
@@ -291,12 +315,23 @@ interface JsonDialect {
|
||||
to(s: McpServer): Record<string, unknown> | null;
|
||||
/** Top-level keys to seed when creating the file from nothing. */
|
||||
seed?: Record<string, unknown>;
|
||||
/**
|
||||
* A file beside the config that lists the names of servers the user switched off (the switch is
|
||||
* not stored on the server entry). Read, never written.
|
||||
*/
|
||||
disabledIn?: { file: string; key: string };
|
||||
}
|
||||
|
||||
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
|
||||
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
|
||||
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
|
||||
'copilot-json': {
|
||||
key: 'mcpServers',
|
||||
from: fromCopilot,
|
||||
to: toCopilot,
|
||||
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
|
||||
},
|
||||
'opencode-json': {
|
||||
key: 'mcp',
|
||||
from: fromOpencode,
|
||||
@@ -558,6 +593,32 @@ function resolveFile(
|
||||
return { file: join(dir, rel.path) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
|
||||
* disabled, so they are not copied. If that file cannot be read as intended the target is
|
||||
* reported unreadable rather than guessing: a guess could switch a server on everywhere.
|
||||
*/
|
||||
async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise<void> {
|
||||
if (format === 'codex-toml') return;
|
||||
const companion = JSON_DIALECTS[format].disabledIn;
|
||||
if (!companion) return;
|
||||
const text = await readText(join(dirname(file), companion.file));
|
||||
if (text === null || !text.trim()) return;
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = JSON.parse(text);
|
||||
} catch {
|
||||
throw new McpConfigError(
|
||||
`${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
|
||||
);
|
||||
}
|
||||
const list = isRecord(doc) ? doc[companion.key] : undefined;
|
||||
if (list === undefined) return;
|
||||
const names = strArr(list);
|
||||
if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
|
||||
for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
|
||||
}
|
||||
|
||||
let applying = false;
|
||||
|
||||
/**
|
||||
@@ -611,6 +672,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
|
||||
continue;
|
||||
}
|
||||
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
|
||||
s.servers = parsed.servers;
|
||||
s.names = parsed.names;
|
||||
s.res.servers = [...parsed.names];
|
||||
|
||||
+9
-47
@@ -48,6 +48,12 @@ import https from 'node:https';
|
||||
import { hostname as osHostname } from 'node:os';
|
||||
import { promisify } from 'node:util';
|
||||
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
|
||||
import {
|
||||
basicAuthHeader,
|
||||
parseEnvFile,
|
||||
readCodemanCredentials,
|
||||
type CodemanCredentials,
|
||||
} from '../codeman-credentials.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { probeServer } from '../daemon-control.js';
|
||||
import { getErrorMessage } from '../types/api.js';
|
||||
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
|
||||
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
|
||||
* lines and `#` comments skipped, one layer of matching quotes stripped.
|
||||
*/
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[match[1]] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface TuiCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials for the API, env first and the data dir's `.env` as the fallback,
|
||||
* exactly like the `codeman attach` path. No password means no auth is
|
||||
* configured (or the user has it only in the server's environment, in which
|
||||
* case the API answers 401 and `connect()` reports `authRequired`).
|
||||
*/
|
||||
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
|
||||
let fileEnv: Record<string, string> = {};
|
||||
try {
|
||||
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
|
||||
} catch {
|
||||
/* absent or unreadable: env-only */
|
||||
}
|
||||
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
|
||||
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
|
||||
return password ? { username, password } : { username };
|
||||
}
|
||||
|
||||
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
|
||||
if (!credentials.password) return undefined;
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
// One credential reader for every client of the API (attach, tui, agent).
|
||||
export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
|
||||
export type TuiCredentials = CodemanCredentials;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Degraded mode
|
||||
|
||||
+105
-30
@@ -1,25 +1,99 @@
|
||||
/**
|
||||
* @fileoverview Periodic GC for paste-image files.
|
||||
* @fileoverview Periodic GC for prompt-upload files, and the one place that
|
||||
* names the directories they live in.
|
||||
*
|
||||
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
|
||||
* under {workingDir}/.claude-images/. The route only triggers cleanup on
|
||||
* under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
|
||||
* killMux=true session deletion, so long-lived sessions can fill disk under
|
||||
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
|
||||
* older than MAX_AGE_MS from each live session's image dir on an interval.
|
||||
* older than MAX_AGE_MS from each live session's upload dirs on an interval.
|
||||
*
|
||||
* Conservative defaults — only files matching the `paste-` prefix are
|
||||
* considered, and we lstat (not stat) so a planted symlink cannot escape the
|
||||
* image dir.
|
||||
* upload dir.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { getDataDir } from '../config/instance.js';
|
||||
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
|
||||
import type { SessionPort } from './ports/index.js';
|
||||
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
|
||||
|
||||
/**
|
||||
* Where a prompt upload is written, relative to the session's working
|
||||
* directory. IN the workspace, because that is the only path that resolves
|
||||
* identically for a local agent and a container (only the workspace is
|
||||
* bind-mounted, at the same absolute path); hidden, so it stays out of
|
||||
* `git status` and the agent's view of the repository; FLAT, because a nested
|
||||
* `<workspace>/.codeman/` is Codeman's own data dir when the workspace is the
|
||||
* home directory; and self-ignoring, through a `.gitignore` of `*` the route
|
||||
* writes once.
|
||||
*/
|
||||
export const UPLOADS_DIR = '.codeman-uploads';
|
||||
/** Where uploads landed before the move: written to by nothing, readable for one release. */
|
||||
export const LEGACY_UPLOADS_DIR = '.claude-images';
|
||||
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
|
||||
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
|
||||
|
||||
/**
|
||||
* Every directory a session's uploads sit in, current first. Both consumers
|
||||
* act on what this returns, the hourly sweep and the recursive delete in
|
||||
* cleanupSession(), so it lists only REAL directories (a link planted by a
|
||||
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
|
||||
* not one; readdir follows a link to a directory), none that is or contains
|
||||
* this instance's data dir (a home workspace reaches it under a contrived
|
||||
* instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
|
||||
* inside one; a directory strictly below the data dir only ever holds uploads
|
||||
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
|
||||
* never be collected), and nothing for a remote (SSH) session, whose
|
||||
* workingDir is the remote path and would name a same-named LOCAL directory
|
||||
* here. The working directory is a user-chosen path, so it is probed BOUNDED
|
||||
* first (#516): one on a mount that stopped answering reads `unknown` and is
|
||||
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
|
||||
* acting on one path at the user's request, passes `pastCap`. The check is
|
||||
* made when listing: a same-user process that swaps a listed directory for a
|
||||
* link afterwards is accepted, since it already writes anywhere this process
|
||||
* can.
|
||||
*/
|
||||
export async function uploadDirs(
|
||||
session: { workingDir: string; remote?: unknown },
|
||||
probe: PathProbeOptions = {}
|
||||
): Promise<string[]> {
|
||||
if (session.remote) return [];
|
||||
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
|
||||
const dataDir = await realDir(getDataDir());
|
||||
const dirs: string[] = [];
|
||||
for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
|
||||
if (!(await isRealDir(dir))) continue;
|
||||
const real = await realDir(dir);
|
||||
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
|
||||
dirs.push(dir);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** lstat, so a symlink is not a directory, whatever it points at. */
|
||||
async function isRealDir(p: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.lstat(p)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
|
||||
async function realDir(dir: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(dir);
|
||||
} catch {
|
||||
return resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sweepPasteImagesOnce(
|
||||
ctx: Pick<SessionPort, 'sessions'>,
|
||||
now: number = Date.now()
|
||||
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
|
||||
let scanned = 0;
|
||||
let deleted = 0;
|
||||
for (const session of ctx.sessions.values()) {
|
||||
const dir = join(session.workingDir, '.claude-images');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
continue; // dir absent — nothing to do
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.startsWith('paste-')) continue;
|
||||
const p = join(dir, name);
|
||||
scanned += 1;
|
||||
for (const dir of await uploadDirs(session)) {
|
||||
let entries: string[];
|
||||
try {
|
||||
const st = await fs.lstat(p);
|
||||
if (!st.isFile()) continue;
|
||||
if (st.mtimeMs < cutoff) {
|
||||
await fs.unlink(p);
|
||||
deleted += 1;
|
||||
}
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
// best-effort: skip permission/race errors silently
|
||||
continue; // gone since listed — nothing to do
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.startsWith('paste-')) continue;
|
||||
const p = join(dir, name);
|
||||
scanned += 1;
|
||||
try {
|
||||
const st = await fs.lstat(p);
|
||||
if (!st.isFile()) continue;
|
||||
if (st.mtimeMs < cutoff) {
|
||||
await fs.unlink(p);
|
||||
deleted += 1;
|
||||
}
|
||||
} catch {
|
||||
// best-effort: skip permission/race errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +130,7 @@ export async function sweepPasteImagesOnce(
|
||||
}
|
||||
|
||||
/**
|
||||
* The path two sessions must share to share a paste-image dir: the canonical
|
||||
* The path two sessions must share to share an upload dir: the canonical
|
||||
* path when it can be resolved, so a sibling that reaches the same directory
|
||||
* through a symlink matches, and the normalised path otherwise (a directory
|
||||
* that no longer exists has nothing left to protect).
|
||||
@@ -68,7 +143,7 @@ function canonicalDir(dir: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
|
||||
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
|
||||
export interface PasteImageDirUser {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Does another live session still use this working directory's paste-image
|
||||
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
|
||||
* and several sessions routinely share one case directory, so without this
|
||||
* check closing one session deletes the pasted images a sibling in the same
|
||||
* case still refers to.
|
||||
* Does another live session still use this working directory's upload dirs?
|
||||
* Deleting a session removes them (`uploadDirs()`) recursively, and several
|
||||
* sessions routinely share one case directory, so without this check closing
|
||||
* one session deletes the pasted images a sibling in the same case still
|
||||
* refers to.
|
||||
*
|
||||
* Two kinds of sibling count as live:
|
||||
*
|
||||
|
||||
+206
-16
@@ -717,6 +717,7 @@ class CodemanApp {
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
this._lastTabGroupStructureKey = null;
|
||||
this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -1426,6 +1427,13 @@ class CodemanApp {
|
||||
this.closeTileCountMenu({ refocus: true });
|
||||
return;
|
||||
}
|
||||
// And so does the rail's search box while it holds text: that Escape
|
||||
// clears the search and nothing else. This listener runs in the capture
|
||||
// phase, before the box's own onkeydown, so the box cannot claim it there.
|
||||
if (e.target?.id === 'tabRailSearch' && this._tabRailSearch) {
|
||||
this.handleTabRailSearchKeydown(e);
|
||||
return;
|
||||
}
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
@@ -1812,8 +1820,11 @@ class CodemanApp {
|
||||
_markDetached(id, on) {
|
||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||
// A popped-out session's window owns its PTY size now, so it leaves the
|
||||
// tile grid (one place per session in this browser tab).
|
||||
if (on && this._tileGrid?.has(id)) this.removeTile(id);
|
||||
// tile grid (one place per session in this browser tab). `gone`: it left by
|
||||
// itself, not by a tile the user removed, so the grid's count stays and the
|
||||
// ranking fills that cell the next time the grid opens, as when it pops out
|
||||
// with the grid closed.
|
||||
if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true });
|
||||
const container = this.$('sessionTabs');
|
||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (tab) tab.classList.toggle('detached', on);
|
||||
@@ -2737,6 +2748,23 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
|
||||
// `<base href="/">` a bare fragment points at the dashboard's root and would navigate the
|
||||
// app away. Resolve it inside this rendered document and scroll there (constants.js).
|
||||
// A fragment that matches nothing is simply ignored, never a navigation.
|
||||
const fragmentLink = ev.target.closest('a[href^="#"]');
|
||||
if (fragmentLink && body.contains(fragmentLink)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const root = fragmentLink.closest('.rv-text') || body;
|
||||
const target = window.CodemanMarkdownAnchors?.find(root, fragmentLink.getAttribute('href'));
|
||||
if (target) {
|
||||
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A `localhost` URL in the agent's answer: from another device that can
|
||||
// only load through the server, so hand it to a proxied web tab
|
||||
// (webview-tabs.js). Every other link keeps its new-tab default.
|
||||
@@ -5743,27 +5771,163 @@ class CodemanApp {
|
||||
*/
|
||||
applySidebarFilter(query) {
|
||||
this._sidebarFilter = (query ?? '').trim().toLowerCase();
|
||||
this._applyTabListFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE row filter behind both search boxes: the sidebar's filter box and
|
||||
* the vertical rail's search box (only one of the two hosts the list at a
|
||||
* time). Classes only, over whatever the last render drew, so grouping, order,
|
||||
* Alt+N badges and the server layout never move; the matching itself is the
|
||||
* pure CodemanTabSearch (constants.js).
|
||||
*
|
||||
* - Sidebar: name (aria-label) + working directory (title), as it always has.
|
||||
* - Rail: the NAME only, a web tab's title included (it is a row in the same
|
||||
* list, and hiding every web tab would make a dashboard unfindable).
|
||||
*
|
||||
* A session row with a tab alert (red action or yellow idle, whatever
|
||||
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
|
||||
* even when it does not match: a prompt waiting on you is never hidden by a
|
||||
* view filter. Alerts come and go through renderSessionTabs(), and both
|
||||
* render paths end here, so nothing else re-runs this for them.
|
||||
*
|
||||
* A group or case box left with nothing showing hides with its header, its
|
||||
* count shows the rows left showing (a kept row included), and the grouped
|
||||
* tree's roving stop and posinset follow the visible items. A collapsed
|
||||
* group's rows are not in the DOM at all, which is why the rail search also
|
||||
* expands the projection (_projectTabGroups). Rows that appear or disappear
|
||||
* move the rows below them, so the connector lines are redrawn then.
|
||||
*/
|
||||
_applyTabListFilter() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
const rail = this._tabOrientation() === 'vertical';
|
||||
const sidebarReachable =
|
||||
!rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
|
||||
const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
|
||||
key: tab,
|
||||
text: rail
|
||||
? this._tabRowSearchName(tab)
|
||||
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
|
||||
section: tab.closest('.tab-layout-group, .tab-cluster'),
|
||||
// Web tabs carry no alerts; only a session row can be kept.
|
||||
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
|
||||
}));
|
||||
const result = window.CodemanTabSearch?.filter(rows, query);
|
||||
if (!result) return;
|
||||
// Whether anything appeared or disappeared: the rows below it then moved.
|
||||
let moved = false;
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', !!needle);
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
continue;
|
||||
if (container.classList.contains('tabs-filtering') !== result.active) {
|
||||
container.classList.toggle('tabs-filtering', result.active);
|
||||
moved = true;
|
||||
}
|
||||
const setFilteredOut = (el, out) => {
|
||||
if (el.classList.contains('tab-filtered-out') === out) return;
|
||||
el.classList.toggle('tab-filtered-out', out);
|
||||
moved = true;
|
||||
};
|
||||
for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key));
|
||||
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
|
||||
const shown = result.counts.get(section) ?? 0;
|
||||
setFilteredOut(section, result.active && shown === 0);
|
||||
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
|
||||
if (!count) continue;
|
||||
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
|
||||
const text = result.active ? String(shown) : count.dataset.total;
|
||||
if (count.textContent !== text) count.textContent = text;
|
||||
}
|
||||
const empty = document.getElementById('tabRailSearchEmpty');
|
||||
const emptyHidden = !(rail && result.active && result.matchCount === 0);
|
||||
if (empty && empty.hidden !== emptyHidden) {
|
||||
empty.hidden = emptyHidden;
|
||||
moved = true;
|
||||
}
|
||||
// Lineage and subagent/ultracode connectors are anchored to row positions.
|
||||
// A render redraws them itself, but a keystroke in either box only toggles
|
||||
// classes here, so the rows it moved would leave the lines pointing at where
|
||||
// they were. Only when something moved: an unchanged re-apply at every
|
||||
// render tail stays free, and the call coalesces with a render's own.
|
||||
if (moved) this.updateConnectionLines?.();
|
||||
// Both render paths already set posinset and the roving stop over an
|
||||
// unfiltered tree, so this second pass only runs while a search hides
|
||||
// something or right after one changed what shows.
|
||||
if ((moved || result.active) && container.getAttribute('role') === 'tree') {
|
||||
const items = this._applyTabTreePositions(container);
|
||||
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
|
||||
if (items.length && !items.includes(stop)) {
|
||||
this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
|
||||
}
|
||||
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
||||
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
||||
}
|
||||
// The count shows visible rows, so it moves with every filter change —
|
||||
// including keystrokes in the filter box, which call this directly.
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
/** What the rail search matches on a row: a session's name, a web tab's title. */
|
||||
_tabRowSearchName(tab) {
|
||||
if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
|
||||
return tab.querySelector('.tab-name')?.dataset.fullName || '';
|
||||
}
|
||||
|
||||
/** True while the vertical rail's search box is narrowing the list. */
|
||||
_tabRailSearchActive() {
|
||||
return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail search box's input handler. In-memory only: never persisted, never
|
||||
* sent anywhere. Starting or ending a search re-renders once when it changes
|
||||
* what a collapsed group hides (the projection ignores collapse while
|
||||
* searching); every other keystroke only re-applies the row classes.
|
||||
*/
|
||||
setTabRailSearch(value) {
|
||||
this._tabRailSearch = typeof value === 'string' ? value : '';
|
||||
const clear = document.getElementById('tabRailSearchClear');
|
||||
if (clear) clear.hidden = this._tabRailSearch.length === 0;
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
else this._applyTabListFilter();
|
||||
}
|
||||
|
||||
/** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
|
||||
clearTabRailSearch() {
|
||||
const input = document.getElementById('tabRailSearch');
|
||||
if (input) input.value = '';
|
||||
this.setTabRailSearch('');
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape in a box that holds text clears the search and nothing else. The
|
||||
* global key handler (setupEventListeners) runs in the CAPTURE phase, before
|
||||
* the box's inline onkeydown, so it is the one that routes the key here and
|
||||
* returns before its close-every-panel branch; stopping propagation from the
|
||||
* inline handler would come too late. An empty box leaves Escape to it, and
|
||||
* an Escape that cancels an IME composition is the IME's.
|
||||
*/
|
||||
handleTabRailSearchKeydown(event) {
|
||||
if (event.key !== 'Escape' || event.isComposing || !this._tabRailSearch) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearTabRailSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the search without rendering: the list is leaving the rail
|
||||
* (applyTabOrientation), and the render that follows draws it unfiltered.
|
||||
*/
|
||||
_resetTabRailSearch() {
|
||||
this._tabRailSearch = '';
|
||||
const input = document.getElementById('tabRailSearch');
|
||||
if (input) input.value = '';
|
||||
const clear = document.getElementById('tabRailSearchClear');
|
||||
if (clear) clear.hidden = true;
|
||||
const empty = document.getElementById('tabRailSearchEmpty');
|
||||
if (empty) empty.hidden = true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -6136,6 +6300,20 @@ class CodemanApp {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's tab row when it is painted, else null: not rendered (a
|
||||
* collapsed group) or hidden by the rail search or the sidebar filter. A
|
||||
* display:none row still answers getBoundingClientRect() with an all-zero
|
||||
* rect, which is truthy, so a connector, a spawn or a genie measured from it
|
||||
* would start at the viewport's top-left corner. Every floating window that
|
||||
* anchors to its parent tab measures through this.
|
||||
*/
|
||||
_paintedSessionTab(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const tab = document.querySelector(`.session-tab[data-id="${sessionId}"]`);
|
||||
return tab && tab.getClientRects().length > 0 ? tab : null;
|
||||
}
|
||||
|
||||
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
|
||||
_tabConnectorPath(anchor, winRect) {
|
||||
if (anchor.vertical) {
|
||||
@@ -6724,7 +6902,9 @@ class CodemanApp {
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
|
||||
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
|
||||
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
@@ -6980,6 +7160,8 @@ class CodemanApp {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
const items = [];
|
||||
for (const section of container.querySelectorAll('.tab-layout-group')) {
|
||||
// A group the search emptied is hidden whole, header included.
|
||||
if (section.classList.contains('tab-filtered-out')) continue;
|
||||
const header = section.querySelector(':scope > [role="treeitem"]');
|
||||
if (header) items.push(header);
|
||||
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
|
||||
@@ -7383,7 +7565,10 @@ class CodemanApp {
|
||||
// session the layout has not placed yet lands where the flat strip has it.
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
collapsedGroupIds: [...this.collapsedTabGroupIds],
|
||||
// A rail search shows matches inside collapsed groups too, so it projects
|
||||
// every group open. The stored per-device collapse state is untouched and
|
||||
// applies again as soon as the search is cleared.
|
||||
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
});
|
||||
@@ -7404,6 +7589,9 @@ class CodemanApp {
|
||||
* A storage failure leaves every group expanded rather than half-remembered.
|
||||
*/
|
||||
toggleTabGroupCollapsed(groupId, forceCollapsed) {
|
||||
// Every group is drawn open while the rail search runs; a toggle then would
|
||||
// change what the user sees only after the search is cleared.
|
||||
if (this._tabRailSearchActive()) return false;
|
||||
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
|
||||
const next = new Set(this.collapsedTabGroupIds);
|
||||
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
|
||||
@@ -9880,8 +10068,10 @@ class CodemanApp {
|
||||
|
||||
try {
|
||||
await this._apiDelete('/api/sessions');
|
||||
// Every tiled session is gone: nothing left to remember or reselect.
|
||||
this.closeTileGrid?.({ keepStored: false, reselect: false });
|
||||
// Every tiled session is gone: nothing to reselect. The stored grid is
|
||||
// kept like every other close; it now names only gone sessions, so the
|
||||
// next Tiles click ranks the open sessions from scratch.
|
||||
this.closeTileGrid?.({ keepStored: true, reselect: false });
|
||||
this.sessions.clear();
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
|
||||
+292
-28
@@ -1020,6 +1020,56 @@ function tabClusterNameSplit(name, label) {
|
||||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-list search: the vertical rail's search box and the sidebar's filter
|
||||
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
|
||||
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
|
||||
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
|
||||
* @param {unknown} query
|
||||
* @returns {string} the needle, '' when there is nothing to search for
|
||||
*/
|
||||
function tabSearchNeedle(query) {
|
||||
return typeof query === 'string' ? query.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows a search hides. Pure: the caller reads the rows off the list it
|
||||
* rendered and applies the result as classes, so the list itself (grouping,
|
||||
* order, Alt+N badges) is never rebuilt or reordered by a search.
|
||||
*
|
||||
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
|
||||
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
|
||||
* view filter). It counts toward its section, so its group stays on screen with
|
||||
* it, but not toward `matchCount`.
|
||||
*
|
||||
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
|
||||
* in list order; `section` is the row's group or case box, null/undefined for none.
|
||||
* @param {unknown} query
|
||||
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
|
||||
* `counts` is the rows left showing per section (kept rows included), every
|
||||
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
|
||||
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
|
||||
*/
|
||||
function filterTabSearchRows(rows, query) {
|
||||
const needle = tabSearchNeedle(query);
|
||||
const hidden = new Set();
|
||||
const counts = new Map();
|
||||
let matchCount = 0;
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
const hasSection = row.section !== null && row.section !== undefined;
|
||||
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
|
||||
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
|
||||
const matches = !needle || text.includes(needle);
|
||||
if (!matches && row.keep !== true) {
|
||||
hidden.add(row.key);
|
||||
continue;
|
||||
}
|
||||
if (matches) matchCount++;
|
||||
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
|
||||
}
|
||||
return { active: needle.length > 0, hidden, counts, matchCount };
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -1301,6 +1351,63 @@ function cleanCopiedSelection(text, options) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Markdown heading anchors ────────────────────────────────────────────────
|
||||
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
|
||||
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
|
||||
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
|
||||
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
|
||||
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
|
||||
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
|
||||
|
||||
/**
|
||||
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
|
||||
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
|
||||
*/
|
||||
function markdownHeadingSlug(text) {
|
||||
return String(text ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
|
||||
.replace(/ /g, '-');
|
||||
}
|
||||
|
||||
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
|
||||
function assignMarkdownHeadingAnchors(root) {
|
||||
const used = new Set();
|
||||
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const base = markdownHeadingSlug(heading.textContent);
|
||||
let slug = base;
|
||||
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
|
||||
used.add(slug);
|
||||
heading.dataset.mdAnchor = slug;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
|
||||
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
|
||||
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
|
||||
* only (never `document.getElementById`, which could find an app element of the same name).
|
||||
*/
|
||||
function findMarkdownAnchorTarget(root, href) {
|
||||
let fragment = String(href ?? '').replace(/^#/, '');
|
||||
try {
|
||||
fragment = decodeURIComponent(fragment);
|
||||
} catch {
|
||||
/* a malformed escape: use it as written */
|
||||
}
|
||||
if (!fragment) return root;
|
||||
assignMarkdownHeadingAnchors(root);
|
||||
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
|
||||
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
|
||||
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
|
||||
}
|
||||
for (const el of root.querySelectorAll('[id]')) {
|
||||
if (el.id === fragment) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -1358,6 +1465,10 @@ if (typeof window !== 'undefined') {
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanTabSearch = {
|
||||
needle: tabSearchNeedle,
|
||||
filter: filterTabSearchRows,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
@@ -1370,6 +1481,11 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanCopySelection = {
|
||||
clean: cleanCopiedSelection,
|
||||
};
|
||||
window.CodemanMarkdownAnchors = {
|
||||
slug: markdownHeadingSlug,
|
||||
assign: assignMarkdownHeadingAnchors,
|
||||
find: findMarkdownAnchorTarget,
|
||||
};
|
||||
window.CodemanTerminalFont = {
|
||||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||
resolve: resolveTerminalFontFamily,
|
||||
@@ -2086,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
|
||||
* deleted, detached and duplicate ids are dropped, the list is capped at
|
||||
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
|
||||
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
|
||||
* (or its JSON) gives null.
|
||||
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
|
||||
* content) made safe to apply: unknown, deleted, detached and duplicate ids
|
||||
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
|
||||
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
|
||||
* Anything that is not a v1 object (or its JSON) gives null.
|
||||
*
|
||||
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
|
||||
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
|
||||
@@ -2098,11 +2214,20 @@ function tileGridCapacity({ width, height }) {
|
||||
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
|
||||
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
|
||||
*
|
||||
* `freed` names the cells whose session no longer exists (or was popped out
|
||||
* to its own window) since the grid was stored: the ranking fills those first
|
||||
* when the grid comes back (restoreTileGridCells). A hole the user left empty
|
||||
* is not freed. `count` is how many tiles the grid had after the user's own
|
||||
* last change (a session that went away by itself does not lower it), so the
|
||||
* grid comes back to that many when there are sessions to fill it; a value
|
||||
* stored before it existed, or a malformed one, reads as the number of
|
||||
* sessions the stored cells name.
|
||||
*
|
||||
* @param {unknown} raw - the parsed value, or the stored JSON string
|
||||
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
|
||||
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
|
||||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
|
||||
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
|
||||
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
|
||||
*/
|
||||
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
let value = raw;
|
||||
@@ -2113,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
|
||||
const ids = [];
|
||||
const cells = [];
|
||||
const freed = [];
|
||||
const named = [];
|
||||
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
|
||||
const keep =
|
||||
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
|
||||
ids.length < TILE_GRID_MAX;
|
||||
const isId = typeof id === 'string' && id !== '';
|
||||
const present = isId && live.has(id) && !detachedIds?.has?.(id);
|
||||
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
|
||||
if (keep) ids.push(id);
|
||||
// Its session went away since: the cell is freed for the ranking to fill.
|
||||
if (isId && !present && !named.includes(id)) freed.push(cells.length);
|
||||
if (isId && !named.includes(id)) named.push(id);
|
||||
// A malformed entry (not a string, not null) is a hole too.
|
||||
cells.push(keep ? id : null);
|
||||
}
|
||||
@@ -2125,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
|
||||
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
|
||||
};
|
||||
const count =
|
||||
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
|
||||
? value.count
|
||||
: Math.min(named.length, TILE_GRID_MAX);
|
||||
return {
|
||||
v: 1,
|
||||
open: value.open === true && ids.length > 0,
|
||||
ids,
|
||||
cells,
|
||||
freed,
|
||||
count,
|
||||
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
|
||||
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
|
||||
colFr: fractions(value.colFr),
|
||||
@@ -2137,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored grid as it comes back (the Tiles button, a page reload): its cells
|
||||
* exactly as stored, holes the user left included, and its focus (or the tile
|
||||
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
|
||||
* that went away since, or by themselves while it was open) does it fill, from
|
||||
* `ranked` (best first, never a session already in it): the freed cells first,
|
||||
* then the other empty cells, in reading order; more than the cells hold join
|
||||
* after them (the shape grows when the grid lays them out, reformTileCells).
|
||||
* A cell stays empty only when no other session is left to place. Never
|
||||
* trimmed to the window: a grid larger than the window fits shows its focused
|
||||
* tile alone until the window fits it again, and the arrangement stays.
|
||||
*
|
||||
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
|
||||
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
|
||||
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
|
||||
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
|
||||
*/
|
||||
function restoreTileGridCells(stored, ranked) {
|
||||
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
|
||||
const cells = [];
|
||||
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
|
||||
const tiles = cells.filter(Boolean);
|
||||
if (tiles.length === 0) return null;
|
||||
const focusedId =
|
||||
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
|
||||
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
|
||||
const fillers = [];
|
||||
for (const id of ranked || []) {
|
||||
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
|
||||
}
|
||||
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
|
||||
const empty = [];
|
||||
cells.forEach((id, k) => {
|
||||
if (id === null) empty.push(k);
|
||||
});
|
||||
// Freed cells first, each group in reading order.
|
||||
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
|
||||
let count = tiles.length;
|
||||
for (const k of empty) {
|
||||
if (count >= target || fillers.length === 0) break;
|
||||
cells[k] = fillers.shift();
|
||||
count++;
|
||||
}
|
||||
const extra = fillers.slice(0, Math.max(0, target - count));
|
||||
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
|
||||
}
|
||||
|
||||
/**
|
||||
* New track fractions after a divider drag (grid-template `fr` values): the two
|
||||
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
|
||||
@@ -2169,7 +2352,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
|
||||
|
||||
/**
|
||||
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
|
||||
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
|
||||
* ones a count fills a grid with), in the order given (tab order, or the
|
||||
* ranking): live ones only, never a session popped out to
|
||||
* its own window (that window owns its PTY size). A session with no PTY
|
||||
* attached IS offered: its tile shows the Attach overlay.
|
||||
*
|
||||
@@ -2183,44 +2367,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
|
||||
for (const id of sessionOrder) {
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
if (!session || result.some((r) => r.id === id)) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ranking groups (rankTileSessions): working first, then the sessions waiting
|
||||
// on the user, then everything else.
|
||||
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
|
||||
|
||||
/**
|
||||
* The stamp a session is ranked by inside its group. A WORKING session keys
|
||||
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
|
||||
* about once a second, so its last-activity stamp is always "now", and one
|
||||
* that never submitted would otherwise claim the head of the group. 0 means
|
||||
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
|
||||
* the last byte the pane printed, i.e. when it went quiet).
|
||||
*/
|
||||
function tileRankStamp(row) {
|
||||
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
|
||||
return sessionActivityAnchor(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which open sessions the tile grid shows when nobody said which (the Tiles
|
||||
* button with no stored grid to bring back, and every place the grid fills a
|
||||
* tile on its own: a count picked in its menu, a freed cell), best first
|
||||
* (owner request: "prefer to load in tiles that are working and then the most
|
||||
* recent, so the oldest don't get opened"):
|
||||
* 1. WORKING, the most recently started turn first;
|
||||
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
|
||||
* permission or question dialog; `waiting`: a finished turn not seen
|
||||
* yet), most recent first;
|
||||
* 3. then every other one (idle, done, error), most recently active first.
|
||||
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
|
||||
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
|
||||
* the result never shuffles. The states are the home screens' own
|
||||
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
|
||||
* order differs: the home screens put the longest-running turn first, the grid
|
||||
* the most recent.
|
||||
*
|
||||
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
|
||||
*
|
||||
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
|
||||
* @returns {string[]} the ids, best first, each once
|
||||
*/
|
||||
function rankTileSessions(rows) {
|
||||
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
|
||||
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
|
||||
list.sort((a, b) => {
|
||||
const byGroup = group(a) - group(b);
|
||||
if (byGroup !== 0) return byGroup;
|
||||
const atA = tileRankStamp(a);
|
||||
const atB = tileRankStamp(b);
|
||||
if (atA !== atB) {
|
||||
if (!atA) return 1;
|
||||
if (!atB) return -1;
|
||||
return atB - atA;
|
||||
}
|
||||
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
const ids = [];
|
||||
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
|
||||
* (owner decision 8). In order:
|
||||
* a. the grid this tab last had (`stored`, already sanitized: live, not
|
||||
* detached, at most the cap), if any of its sessions survive;
|
||||
* detached, at most the cap), if any of its sessions survive, exactly
|
||||
* as it was (restoreTileGridCells: its cells and holes, a cell its
|
||||
* session freed filled from the ranking);
|
||||
* b. else an open split's two sessions, Pane A focused;
|
||||
* c. else the open sessions in tab order (buildTilePickerSessions: no
|
||||
* detached ones), up to `limit`, the active session always among them and focused
|
||||
* (when it sits past the limit, the first `limit - 1` others come with it).
|
||||
* c. else the open sessions in `ranked` order (rankTileSessions: working,
|
||||
* then needing input, then the most recent; tab order when no ranking is
|
||||
* given), detached ones never, up to `limit`, the active session always
|
||||
* among them and focused (when it ranks past the limit, the first
|
||||
* `limit - 1` others come with it).
|
||||
* Null when there is nothing to open.
|
||||
*
|
||||
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
|
||||
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
|
||||
* focused: string|null, zoomed: string|null}|null,
|
||||
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
|
||||
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
|
||||
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
|
||||
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
|
||||
* focusedId: string|null}|null}
|
||||
*/
|
||||
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
|
||||
if (stored?.ids?.length) {
|
||||
const focus = stored.zoomed || stored.focused;
|
||||
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
|
||||
}
|
||||
function tileGridOpenSet({
|
||||
stored = null,
|
||||
split = null,
|
||||
ranked = null,
|
||||
sessions,
|
||||
sessionOrder,
|
||||
detachedIds,
|
||||
activeId = null,
|
||||
limit,
|
||||
}) {
|
||||
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
|
||||
(c) => c.id
|
||||
);
|
||||
const restored = stored ? restoreTileGridCells(stored, all) : null;
|
||||
if (restored) return { source: 'stored', ...restored };
|
||||
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
|
||||
const pair = (split || []).filter(usable);
|
||||
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
|
||||
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
|
||||
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
|
||||
if (all.length === 0) return null;
|
||||
let ids = all.slice(0, max);
|
||||
if (all.includes(activeId) && !ids.includes(activeId)) {
|
||||
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
|
||||
}
|
||||
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2240,8 +2501,9 @@ function sanitizeTileCount(raw) {
|
||||
* `base` (what the grid would open, or what an open grid shows, in its order)
|
||||
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
|
||||
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
|
||||
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
|
||||
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
|
||||
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
|
||||
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
|
||||
* Fewer sessions than `n` give fewer tiles.
|
||||
*
|
||||
* @param {string[]} base
|
||||
* @param {string[]} all
|
||||
@@ -2750,6 +3012,8 @@ if (typeof window !== 'undefined') {
|
||||
fitTileCells,
|
||||
cycleTile,
|
||||
tileGridOpenSet,
|
||||
restoreTileGridCells,
|
||||
rankTileSessions,
|
||||
sanitizeTileCount,
|
||||
tileGridSetForCount,
|
||||
tileCellCols,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Entrance animations for the four things that appear when work
|
||||
* @fileoverview Entrance animations for the things that appear when work
|
||||
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
|
||||
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
|
||||
* One picker per surface, plus themes that set all four to a matching look.
|
||||
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
|
||||
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
|
||||
* that set all five to a matching look.
|
||||
*
|
||||
* Everything is OFF by default (the `legacy` theme), so an untouched install
|
||||
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||
* → Appearance → Entrance Animations.
|
||||
* → Animations.
|
||||
*
|
||||
* Four constraints shape the design:
|
||||
*
|
||||
@@ -34,13 +35,28 @@
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
* `_onSessionCreated`.
|
||||
*
|
||||
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
|
||||
* before) and switched on in App Settings → Animations → Tile Animations, or
|
||||
* preset by a theme. A styled tile plays in two beats that combine two
|
||||
* surfaces. The FRAME enters as it mounts, in its own style
|
||||
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
|
||||
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
|
||||
* pane). The load queue serves one capture at a time, so
|
||||
* the screens light up one after another, the focused tile first. The frame
|
||||
* styles move transform and opacity only (six tiles animate at once; a blur
|
||||
* belongs to the serialized screen beat), and each has its own way out on the
|
||||
* closing grid's still copy. A reload restores with `settle` whatever the
|
||||
* setting.
|
||||
*
|
||||
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
||||
* so styles can be compared without spawning real sessions or agents.
|
||||
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
|
||||
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
|
||||
* replay, a window and a line, and replays the tile grid in place, so styles
|
||||
* can be compared without spawning real sessions or agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
|
||||
* tile-grid.js (tile mount, reveal and still-copy hooks)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
||||
*/
|
||||
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
|
||||
*/
|
||||
const TERM_ANIM_STYLES = [
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
|
||||
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
|
||||
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
|
||||
* still fits once at its final size (#464). `stagger` is the default gap
|
||||
* between tiles and `order` the default cascade: `reading` (row by row),
|
||||
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
|
||||
* tile). `from`: the tile is measured against a source after layout (its tab,
|
||||
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
|
||||
*/
|
||||
// prettier-ignore
|
||||
const TILE_ANIM_STYLES = [
|
||||
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
|
||||
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
|
||||
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
|
||||
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
|
||||
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
|
||||
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
|
||||
];
|
||||
|
||||
/** Cascade orders for the tile grid; `auto` is each style's own. */
|
||||
const TILE_ANIM_ORDERS = [
|
||||
{ key: 'auto', label: 'Style default' },
|
||||
{ key: 'reading', label: 'Reading order' },
|
||||
{ key: 'wave', label: 'Diagonal wave' },
|
||||
{ key: 'ripple', label: 'Ripple from focus' },
|
||||
];
|
||||
|
||||
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||
const BEAM_HOLD_MS = 360;
|
||||
|
||||
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
|
||||
const TILE_EXIT_STAGGER_MS = 35;
|
||||
|
||||
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
|
||||
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
|
||||
|
||||
/** One-click combinations that read as a single look. */
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
|
||||
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
|
||||
* when it is picked, but the tile style is its own setting (App Settings →
|
||||
* Animations → Tile Animations, off by default), so changing it afterwards
|
||||
* does not turn the theme into "Custom".
|
||||
*/
|
||||
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
|
||||
|
||||
/**
|
||||
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
|
||||
* `fly` behaviour Codeman already had before this module existed. So a user who
|
||||
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
|
||||
* Entrance Animations, which persists to the localStorage keys below.
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Animations,
|
||||
* which persists to the localStorage keys below.
|
||||
*/
|
||||
const TAB_ANIM_DEFAULT = 'off';
|
||||
const WIN_ANIM_DEFAULT = 'fly';
|
||||
const LINE_ANIM_DEFAULT = 'off';
|
||||
const TERM_ANIM_DEFAULT = 'off';
|
||||
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
|
||||
const TILE_ANIM_DEFAULT = 'settle';
|
||||
const TILE_ANIM_ORDER_DEFAULT = 'auto';
|
||||
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
|
||||
win: 'codeman:winAnim',
|
||||
line: 'codeman:lineAnim',
|
||||
term: 'codeman:termAnim',
|
||||
tile: 'codeman:tileAnim',
|
||||
tileOrder: 'codeman:tileAnimOrder',
|
||||
termSwitch: 'codeman:termAnimOnSwitch',
|
||||
stagger: 'codeman:tabAnimStagger',
|
||||
speed: 'codeman:tabAnimSpeed',
|
||||
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
|
||||
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
|
||||
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
|
||||
// were a surface gives them nothing new.
|
||||
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
|
||||
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||
|
||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
|
||||
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
|
||||
},
|
||||
|
||||
setTileAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
|
||||
},
|
||||
|
||||
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
|
||||
setTileAnimOrder(key, { persist = true } = {}) {
|
||||
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
|
||||
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||
this._termAnimOnSwitch = !!on;
|
||||
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setWinAnimStyle(theme.win);
|
||||
this.setLineAnimStyle(theme.line);
|
||||
this.setTermAnimStyle(theme.term);
|
||||
this.setTileAnimStyle(theme.tile);
|
||||
this._syncEntranceAnimSetting?.();
|
||||
},
|
||||
|
||||
/** The theme matching the four current styles, or 'custom' for a lab mix. */
|
||||
/** The current style of each surface, keyed as ANIM_SURFACES. */
|
||||
_currentAnimStyles() {
|
||||
return {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
tile: this._tileAnimStyle,
|
||||
};
|
||||
},
|
||||
|
||||
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
|
||||
currentAnimTheme() {
|
||||
const match = ANIM_THEMES.find(
|
||||
(t) =>
|
||||
t.tab === this._tabAnimStyle &&
|
||||
t.win === this._winAnimStyle &&
|
||||
t.line === this._lineAnimStyle &&
|
||||
t.term === this._termAnimStyle
|
||||
);
|
||||
const current = this._currentAnimStyles();
|
||||
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||
return match ? match.key : 'custom';
|
||||
},
|
||||
|
||||
// ── App Settings picker ───────────────────────────────────────────────────
|
||||
// ── App Settings → Animations ─────────────────────────────────────────────
|
||||
//
|
||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||
// styles live in their own localStorage keys, so they stay per-device and never
|
||||
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_syncEntranceAnimSetting() {
|
||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||
if (!sel) return;
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
if (sel) {
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
|
||||
// tracking, so this closes it as Cancel does (the row says so).
|
||||
const labBtn = document.getElementById('appSettingsOpenAnimLab');
|
||||
if (labBtn && !labBtn.dataset.bound) {
|
||||
labBtn.dataset.bound = '1';
|
||||
labBtn.addEventListener('click', () => {
|
||||
this.closeAppSettings?.();
|
||||
this.openAnimLab();
|
||||
});
|
||||
}
|
||||
// Tile Animations: its own row, off (`settle`) by default. A theme picked
|
||||
// above presets it; picked here, it applies to tiles alone.
|
||||
const tileSel = document.getElementById('appSettingsTileAnim');
|
||||
if (tileSel) {
|
||||
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
|
||||
if (!tileSel.dataset.bound) {
|
||||
tileSel.dataset.bound = '1';
|
||||
tileSel.addEventListener('change', () => {
|
||||
this.setTileAnimStyle(tileSel.value);
|
||||
this._syncEntranceAnimSetting();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||
},
|
||||
|
||||
_tileAnimDuration() {
|
||||
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
|
||||
},
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
||||
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tile grid ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
|
||||
tileEntranceStyle() {
|
||||
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
|
||||
},
|
||||
|
||||
/**
|
||||
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
|
||||
* until the next frame, when its cell is final and _runTileEntrances can
|
||||
* order it and measure it against its source. `setBackstop(ms)` arms the
|
||||
* mount's own timer that ends the entrance should animationend never come
|
||||
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
|
||||
* so a frame that never comes cannot strand a tile invisible.
|
||||
*/
|
||||
_stageTileEntrance(el, sessionId, setBackstop) {
|
||||
el.classList.add('tile--enter-themed', 'tile--enter-hold');
|
||||
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
|
||||
setBackstop(4000);
|
||||
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
|
||||
},
|
||||
|
||||
/**
|
||||
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
|
||||
* then a stored grid moves its tiles back). Each held tile gets its delay
|
||||
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
|
||||
* its tab or the Tiles button (FLIP: transform only, so the fit it already
|
||||
* did at its real size stands). `beam` draws its lines instead.
|
||||
*/
|
||||
_runTileEntrances() {
|
||||
this._tileEnterRaf = 0;
|
||||
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
|
||||
this._tileEnterQueue = [];
|
||||
if (queue.length === 0) return;
|
||||
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
|
||||
const speed = this._animSpeed || 1;
|
||||
const stagger = def.stagger / speed;
|
||||
const duration = this._tileAnimDuration();
|
||||
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
|
||||
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
|
||||
const ranks = this._tileEnterRanks(
|
||||
queue.map((q) => q.sessionId),
|
||||
order
|
||||
);
|
||||
const beams = [];
|
||||
queue.forEach((item, k) => {
|
||||
const { el, sessionId } = item;
|
||||
const delay = ranks[k] * stagger;
|
||||
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
|
||||
if (def.from) {
|
||||
const to = el.getBoundingClientRect();
|
||||
const from = this._tileSourceRect(def.from, sessionId);
|
||||
if (from && to.width > 0 && to.height > 0) {
|
||||
if (def.key === 'beam') beams.push({ from, to, delay });
|
||||
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
|
||||
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
|
||||
}
|
||||
}
|
||||
el.classList.remove('tile--enter-hold');
|
||||
// When the frame lands, for a screen whose content arrives earlier.
|
||||
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
|
||||
item.setBackstop(delay + hold + duration + 900);
|
||||
});
|
||||
if (beams.length > 0) this._drawTileBeams(beams);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
|
||||
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
|
||||
* the focused tile. Equal keys share a step, so a diagonal lands together.
|
||||
*/
|
||||
_tileEnterRanks(ids, order) {
|
||||
const grid = this._tileGrid;
|
||||
const cols = Math.max(1, grid?.cols || 1);
|
||||
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
|
||||
const pos = ids.map((id, k) => {
|
||||
const c = cellOf(id);
|
||||
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
|
||||
});
|
||||
let keys;
|
||||
if (order === 'wave') {
|
||||
keys = pos.map((p) => p.row + p.col);
|
||||
} else if (order === 'ripple') {
|
||||
const f = cellOf(grid?.focusedId);
|
||||
const fr = f < 0 ? 0 : Math.floor(f / cols);
|
||||
const fc = f < 0 ? 0 : f % cols;
|
||||
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
|
||||
} else {
|
||||
keys = pos.map((p) => p.cell);
|
||||
}
|
||||
const steps = [...new Set(keys)].sort((a, b) => a - b);
|
||||
return keys.map((v) => steps.indexOf(v));
|
||||
},
|
||||
|
||||
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
|
||||
_tileSourceRect(kind, sessionId) {
|
||||
const visible = (node) => {
|
||||
const r = node?.getBoundingClientRect?.();
|
||||
if (!r || !(r.width > 0 && r.height > 0)) return null;
|
||||
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
|
||||
};
|
||||
if (kind === 'tab') {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
|
||||
const r = visible(tab);
|
||||
if (r) return r;
|
||||
}
|
||||
return visible(document.querySelector('.btn-tile-grid'));
|
||||
},
|
||||
|
||||
/**
|
||||
* The transform that puts a tile laid out at `to` onto `from`, as custom
|
||||
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
|
||||
* size for `fly` (it grows out of it), a small card turned a little for
|
||||
* `deal`.
|
||||
*/
|
||||
_setTileFlight(el, from, to, kind, rank, prefix) {
|
||||
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
|
||||
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
let sx;
|
||||
let sy;
|
||||
let rot = 0;
|
||||
if (kind === 'deal') {
|
||||
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
|
||||
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
|
||||
} else {
|
||||
sx = clamp(from.width / to.width, 0.02, 1);
|
||||
sy = clamp(from.height / to.height, 0.02, 1);
|
||||
}
|
||||
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
|
||||
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
|
||||
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
|
||||
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
|
||||
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
|
||||
},
|
||||
|
||||
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
|
||||
_flashTileSourceTab(sessionId, delay) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
|
||||
if (!tab) return;
|
||||
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
|
||||
tab.classList.remove('tab-launch');
|
||||
void tab.offsetWidth;
|
||||
tab.classList.add('tab-launch');
|
||||
const onEnd = (e) => {
|
||||
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
|
||||
};
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
tab.removeEventListener('animationend', onEnd);
|
||||
tab.classList.remove('tab-launch');
|
||||
tab.style.removeProperty('--tab-launch-delay');
|
||||
};
|
||||
const timer = setTimeout(done, delay + 900);
|
||||
tab.addEventListener('animationend', onEnd);
|
||||
},
|
||||
|
||||
/**
|
||||
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
|
||||
* the middle of its tile, in the connection-line look, with a packet riding it
|
||||
* when the line style is `packet`; the tile materializes as it lands. Its
|
||||
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
|
||||
* The overlay goes once every line has faded.
|
||||
*/
|
||||
_drawTileBeams(beams) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
let svg = document.getElementById('tileBeamLines');
|
||||
if (!svg) {
|
||||
svg = document.createElementNS(ns, 'svg');
|
||||
svg.id = 'tileBeamLines';
|
||||
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
const packet = this._lineAnimStyle === 'packet';
|
||||
let last = 0;
|
||||
for (const { from, to, delay } of beams) {
|
||||
const x1 = from.left + from.width / 2;
|
||||
const y1 = from.bottom;
|
||||
// Into the tile's middle: its top edge sits right under the tab strip,
|
||||
// so a beam aimed there ran sideways along the strip instead of down.
|
||||
const x2 = to.left + to.width / 2;
|
||||
const y2 = to.top + to.height / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = document.createElementNS(ns, 'path');
|
||||
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
path.setAttribute('class', 'connection-line tile-beam-line');
|
||||
svg.appendChild(path);
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
|
||||
if (packet) {
|
||||
const dot = path.cloneNode(false);
|
||||
dot.setAttribute('class', 'connection-line-packet');
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
last = Math.max(last, delay);
|
||||
}
|
||||
clearTimeout(this._tileBeamTimer);
|
||||
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
|
||||
},
|
||||
|
||||
/**
|
||||
* A tile's screen lights up when its first capture lands (tile-grid.js load
|
||||
* queue), in the terminal pane's style: the same keyframes as the main pane,
|
||||
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
|
||||
* tile at a time, since the queue serves one capture at a time), so the
|
||||
* xterm inside keeps its size and its fit.
|
||||
*/
|
||||
playTileScreenEntrance(body) {
|
||||
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
|
||||
body._codemanScreenDone?.();
|
||||
// Content that lands while the frame is still flying in waits (held at
|
||||
// its first keyframe, so hidden) until the frame has nearly landed: two
|
||||
// beats, frame then screen, rather than both at once.
|
||||
const frame = body.closest?.('.tile');
|
||||
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
|
||||
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
|
||||
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
|
||||
body.classList.remove('term-enter');
|
||||
void body.offsetWidth;
|
||||
body.classList.add('term-enter');
|
||||
let timer = null;
|
||||
const done = (e) => {
|
||||
if (e && (e.target !== body || e.pseudoElement)) return;
|
||||
clearTimeout(timer);
|
||||
body.removeEventListener('animationend', done);
|
||||
body.removeEventListener('animationcancel', done);
|
||||
body.classList.remove('term-enter');
|
||||
body.style.removeProperty('--tile-screen-delay');
|
||||
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
|
||||
};
|
||||
body._codemanScreenDone = done;
|
||||
body.addEventListener('animationend', done);
|
||||
body.addEventListener('animationcancel', done);
|
||||
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
|
||||
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
|
||||
},
|
||||
|
||||
/**
|
||||
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
|
||||
* frame style's own way: `fly` back into each tab, `deal` gathered into the
|
||||
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
|
||||
* reading order, so the last one ends last (the layer goes on its
|
||||
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
|
||||
* covers tiles that stay. Returns how long the slowest copy takes, for the
|
||||
* layer's fallback timer, or 0 for the default fade.
|
||||
*/
|
||||
_stageTileExit(copies, { now = false } = {}) {
|
||||
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
|
||||
const ms = TILE_EXIT_MS[style];
|
||||
if (now || !ms || copies.length === 0) return 0;
|
||||
const speed = this._animSpeed || 1;
|
||||
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
|
||||
copies.forEach(({ ghost, el, sessionId }, k) => {
|
||||
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
|
||||
if (style !== 'fly' && style !== 'deal') return;
|
||||
const at = el.getBoundingClientRect();
|
||||
const target = this._tileSourceRect(def.from, sessionId);
|
||||
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
|
||||
});
|
||||
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
|
||||
* light up again in queue order): no remount, reconnect or resize. With the
|
||||
* grid closed it opens it, the real path.
|
||||
*/
|
||||
_demoTiles() {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) {
|
||||
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
|
||||
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
|
||||
return;
|
||||
}
|
||||
if (!this._tileMotionAllowed?.()) return;
|
||||
const ids = grid.ids.slice();
|
||||
ids.forEach((id, k) => {
|
||||
const entry = grid.tiles.get(id);
|
||||
if (entry) this._replayTileEntrance?.(entry.el, id, k);
|
||||
});
|
||||
// The screens, as the load queue would land them: focused first, then reading order.
|
||||
const speed = this._animSpeed || 1;
|
||||
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
|
||||
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
|
||||
clearTimeout(this._tileDemoTimer);
|
||||
const timers = order.map((id, k) =>
|
||||
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
|
||||
);
|
||||
this._tileDemoTimers?.forEach(clearTimeout);
|
||||
this._tileDemoTimers = timers;
|
||||
},
|
||||
|
||||
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
|
||||
_demoTilesRoundTrip() {
|
||||
if (!this._tileGrid?.open) {
|
||||
this._demoTiles();
|
||||
return;
|
||||
}
|
||||
this.toggleTileGrid?.();
|
||||
clearTimeout(this._tileDemoTimer);
|
||||
this._tileDemoTimer = setTimeout(
|
||||
() => {
|
||||
if (!this._tileGrid?.open) this.toggleTileGrid?.();
|
||||
},
|
||||
1400 / (this._animSpeed || 1)
|
||||
);
|
||||
},
|
||||
|
||||
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||
|
||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
</label>
|
||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
|
||||
<label class="anim-lab-select">Tile order
|
||||
<select data-select="tileOrder">
|
||||
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
<button type="button" data-demo="window">Window</button>
|
||||
<button type="button" data-demo="all">All</button>
|
||||
</div>
|
||||
<div class="anim-lab-demo">
|
||||
<span>Tiles</span>
|
||||
<button type="button" data-demo="tiles">Replay</button>
|
||||
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
|
||||
</div>
|
||||
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||
else if (attr === 'tile') this.setTileAnimStyle(style);
|
||||
else this.setLineAnimStyle(style);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
|
||||
this._syncEntranceAnimSetting?.();
|
||||
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
|
||||
});
|
||||
});
|
||||
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
|
||||
this.setTileAnimOrder(e.target.value);
|
||||
this.demoEntrance('tiles');
|
||||
});
|
||||
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||
this.setTermAnimOnSwitch(e.target.checked);
|
||||
});
|
||||
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
_syncAnimLab() {
|
||||
const panel = document.getElementById('animLab');
|
||||
if (!panel) return;
|
||||
const current = {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
};
|
||||
const current = this._currentAnimStyles();
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||
});
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
||||
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
|
||||
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||
});
|
||||
const order = panel.querySelector('select[data-select="tileOrder"]');
|
||||
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
|
||||
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
return svg;
|
||||
},
|
||||
|
||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
||||
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
|
||||
demoEntrance(what = 'all') {
|
||||
this._clearEntranceDemo();
|
||||
|
||||
if (what === 'tiles') return this._demoTiles();
|
||||
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
|
||||
// With the grid open, the "pane" is every tile's screen.
|
||||
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
|
||||
|
||||
// The pane is a real, shared element rather than a throwaway, so replay it
|
||||
// through the same entry point a real launch uses (bypassing the owed-id
|
||||
// check, which only exists to keep background sessions from hijacking it).
|
||||
|
||||
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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).
|
||||
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
|
||||
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
'.response-viewer-content',
|
||||
'.file-preview-content',
|
||||
'.session-tab-name',
|
||||
'.tab-name',
|
||||
'.session-name',
|
||||
'.case-name',
|
||||
'.notif-item-message',
|
||||
@@ -61,6 +62,8 @@
|
||||
'Collapse session sidebar': '收起会话侧边栏',
|
||||
'Expand session sidebar': '展开会话侧边栏',
|
||||
'Filter sessions': '筛选会话',
|
||||
'Search sessions': '搜索会话',
|
||||
'No sessions match': '没有匹配的会话',
|
||||
'Admin Panel': '管理面板',
|
||||
'Open admin panel': '打开管理面板',
|
||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||
@@ -340,6 +343,36 @@
|
||||
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
|
||||
English: 'English',
|
||||
Appearance: '外观',
|
||||
// App Settings > Animations (#571). 平铺 is the grid, 窗格 one tile in it.
|
||||
Animations: '动画',
|
||||
'How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.':
|
||||
'标签页、终端面板、智能体窗口和平铺窗格如何出现。默认全部关闭,选择后立即生效。',
|
||||
Entrances: '入场',
|
||||
'Entrance Theme': '入场主题',
|
||||
'One look for how new tabs, terminal panes, agent windows and their lines arrive.':
|
||||
'为新标签页、终端面板、智能体窗口及其连线的出现方式选择统一的风格。',
|
||||
'Off (default)': '关闭(默认)',
|
||||
'Terminal (CRT)': '终端(CRT)',
|
||||
'Beam down': '光束降临',
|
||||
'Launch (tiles fly from tabs)': '发射(窗格从标签页飞出)',
|
||||
'Soft focus (blur)': '柔焦(模糊)',
|
||||
Quiet: '安静',
|
||||
Playful: '活泼',
|
||||
'Custom (set in the lab)': '自定义(在实验室中设置)',
|
||||
'Tile Animations': '平铺动画',
|
||||
'How tiles arrive when the grid opens and leave when it closes. A theme above presets it.':
|
||||
'平铺打开时窗格如何出现、关闭时如何离开。上方的主题会预设此项。',
|
||||
'Fly from tab': '从标签页飞出',
|
||||
Deal: '发牌',
|
||||
Cascade: '级联',
|
||||
Pop: '弹出',
|
||||
Soft: '柔和',
|
||||
'None (tiles just appear)': '无(窗格直接出现)',
|
||||
Lab: '实验室',
|
||||
'Animation Lab': '动画实验室',
|
||||
'Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.':
|
||||
'关闭设置,并按界面并排打开所有样式,可重放和调速。等同于在网址后添加 ?animlab=1。',
|
||||
'Open lab': '打开实验室',
|
||||
Skin: '皮肤',
|
||||
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
|
||||
'Daylight Blue': '日光蓝',
|
||||
@@ -369,6 +402,9 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
// 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.
|
||||
@@ -592,6 +628,11 @@
|
||||
'Audio Alerts': '声音提醒',
|
||||
'Push Notifications': '推送通知',
|
||||
'Notification Levels': '通知级别',
|
||||
'Toast display time': '弹出提示显示时长',
|
||||
'How long the corner pop-ups stay on screen.': '角落弹出提示在屏幕上停留的时长。',
|
||||
'Browser notification display time': '浏览器通知显示时长',
|
||||
'How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.':
|
||||
'桌面通知在 Codeman 关闭它之前保持显示的时长。系统可能会更早关闭它。',
|
||||
Critical: '严重',
|
||||
'Per-Event Settings': '按事件设置',
|
||||
'Permission prompts': '权限提示',
|
||||
@@ -718,6 +759,8 @@
|
||||
|
||||
// Dynamic common status / toasts
|
||||
'Settings saved': '设置已保存',
|
||||
'Settings applied': '设置已应用',
|
||||
'Save and keep Settings open': '保存并保持设置打开',
|
||||
'Settings saved locally': '设置已保存到本机',
|
||||
'Tunnel active': '隧道已启用',
|
||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||
|
||||
@@ -150,6 +150,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
@@ -173,6 +174,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
if (!failReason && err && err.message) failReason = err.message;
|
||||
console.warn('Image upload failed:', err);
|
||||
results[i] = null;
|
||||
} finally {
|
||||
@@ -196,7 +198,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
|
||||
+95
-20
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var 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>
|
||||
<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';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';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';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -394,6 +394,19 @@
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
|
||||
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
|
||||
rows below, in memory only. Shown with the rail, never elsewhere. -->
|
||||
<div class="session-sidebar-filter tab-rail-search">
|
||||
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
|
||||
placeholder="Search sessions" aria-label="Search sessions"
|
||||
autocomplete="off" spellcheck="false"
|
||||
oninput="app.setTabRailSearch(this.value)"
|
||||
onkeydown="app.handleTabRailSearchKeydown(event)">
|
||||
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
|
||||
aria-label="Clear search" title="Clear search"
|
||||
onclick="app.clearTabRailSearch()" hidden>×</button>
|
||||
</div>
|
||||
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
|
||||
<div
|
||||
id="tabRailResizeHandle"
|
||||
class="tab-rail-resize-handle"
|
||||
@@ -1635,6 +1648,7 @@
|
||||
Save; row-reverse keeps Save to the left of it on phones. -->
|
||||
<div class="set-head-actions">
|
||||
<button class="modal-close" onclick="app.closeAppSettings()" aria-label="Close app settings">×</button>
|
||||
<button class="set-head-save set-head-apply" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
|
||||
<button class="set-head-save" onclick="app.saveAppSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1663,6 +1677,10 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<span>Appearance</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-animations">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<span>Animations</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-models">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
|
||||
<span>Models</span>
|
||||
@@ -2053,7 +2071,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<h2>Appearance</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
|
||||
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
|
||||
@@ -2077,21 +2095,6 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Animations</span>
|
||||
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2216,6 +2219,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
@@ -2248,6 +2258,70 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Animations ═══════════════════════════════════════════════
|
||||
Every entrance animation in one place (entrance-animations.js).
|
||||
All per-device localStorage keys, applied as they are picked,
|
||||
never part of PUT /api/settings. -->
|
||||
<section class="set-section" id="settings-animations" data-label="Animations">
|
||||
<div class="set-section-head">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<h2>Animations</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Theme</span>
|
||||
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="launch">Launch (tiles fly from tabs)</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tile Animations</span>
|
||||
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
|
||||
</div>
|
||||
<select id="appSettingsTileAnim" class="set-select">
|
||||
<option value="settle">Off (default)</option>
|
||||
<option value="fly">Fly from tab</option>
|
||||
<option value="deal">Deal</option>
|
||||
<option value="crt">CRT</option>
|
||||
<option value="beam">Beam down</option>
|
||||
<option value="cascade">Cascade</option>
|
||||
<option value="pop">Pop</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="off">None (tiles just appear)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Lab</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Animation Lab</span>
|
||||
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
|
||||
</div>
|
||||
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Models ═══════════════════════════════════════════════════ -->
|
||||
<section class="set-section" id="settings-models" data-label="Models">
|
||||
<div class="set-section-head">
|
||||
@@ -2644,17 +2718,17 @@
|
||||
<div class="set-group" id="mcpSyncGroup">
|
||||
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
|
||||
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity copilot github">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable MCP server sync</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Sync MCP servers across CLIs</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
|
||||
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>, <code>COPILOT_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
|
||||
@@ -3057,6 +3131,7 @@
|
||||
</div>
|
||||
<div class="form-actions set-foot">
|
||||
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
||||
<button class="btn-toolbar" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveAppSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3552,7 +3552,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
}
|
||||
|
||||
/* Save + Close are the two ways out of the sheet (save-and-close vs
|
||||
discard-and-close), hit in the same corner with the same thumb, so here —
|
||||
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
|
||||
and only here, since Save is header-only below 860px — they share a
|
||||
recessed tray and matching pill geometry instead of reading as a fat
|
||||
accent pill parked beside a stray × glyph. Tray colors come from skin
|
||||
|
||||
@@ -483,7 +483,7 @@ class NotificationManager {
|
||||
notif.read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to session if available
|
||||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||||
|
||||
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -1226,15 +1227,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html) => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; }
|
||||
const show = (html, hint = '') => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
|
||||
};
|
||||
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
|
||||
if (!this._mcpSyncSavedOn) {
|
||||
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
|
||||
show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
|
||||
return;
|
||||
}
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
|
||||
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file (and GitHub Copilot CLI\'s, when it is installed)? Env values and headers on those servers are copied too.')) return;
|
||||
show('Working…');
|
||||
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
|
||||
let body = null;
|
||||
@@ -2512,7 +2513,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
|
||||
* Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
|
||||
* turning either on needs no close-and-reopen. It is a wrapper rather than an option on
|
||||
* saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
|
||||
*
|
||||
* `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
|
||||
* saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
|
||||
* is still in flight is an ordinary Save and closes the modal.
|
||||
*/
|
||||
async applyAppSettings() {
|
||||
if (this._applyInFlight) return;
|
||||
this._applyInFlight = true;
|
||||
this._keepSettingsOpenOnce = true;
|
||||
try {
|
||||
await this.saveAppSettings();
|
||||
} finally {
|
||||
this._applyInFlight = false;
|
||||
this._keepSettingsOpenOnce = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveAppSettings() {
|
||||
const keepOpen = this._keepSettingsOpenOnce === true;
|
||||
this._keepSettingsOpenOnce = false;
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
@@ -2583,6 +2608,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -2857,6 +2883,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
let serverSaved = false;
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2874,10 +2901,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const cb = document.getElementById('appSettingsTunnelEnabled');
|
||||
if (cb) cb.checked = false;
|
||||
this.closeAppSettings();
|
||||
if (!keepOpen) this.closeAppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
|
||||
// evidence the server kept the flags the Apply refresh below reads.
|
||||
serverSaved = !!res?.ok;
|
||||
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
@@ -2889,7 +2920,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else {
|
||||
this.showToast('Settings saved', 'success');
|
||||
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
|
||||
}
|
||||
|
||||
// Show tunnel-specific feedback if toggled on
|
||||
@@ -2901,9 +2932,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
// Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
|
||||
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
|
||||
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
|
||||
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else {
|
||||
} else if (!keepOpen) {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
@@ -2924,6 +2959,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
|
||||
* reopening the modal. openAppSettings does the same on open; this is the part of it that
|
||||
* a save can change, without touching what the user is editing or the scroll position.
|
||||
*/
|
||||
_refreshSettingsAfterApply(settings) {
|
||||
// The MCP routes read the saved flag, so switching it on is only usable from now.
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
const out = this.$('mcpSyncResult');
|
||||
if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
|
||||
out.style.display = 'none';
|
||||
out.innerHTML = '';
|
||||
}
|
||||
this.applyMcpSyncVisibility();
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
this.applyCliManagementVisibility();
|
||||
this._applyDoctorAdminGate();
|
||||
},
|
||||
|
||||
// Load model configuration from server for the settings modal
|
||||
async loadModelConfigForSettings() {
|
||||
try {
|
||||
@@ -3541,6 +3595,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3677,6 +3732,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3932,6 +3997,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
})
|
||||
: 'horizontal';
|
||||
|
||||
// The search box lives in the rail: a search left applied after the list
|
||||
// moves out would hide tabs with no box to clear it from.
|
||||
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
|
||||
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
||||
root.setAttribute('data-tab-orientation', orientation);
|
||||
@@ -3964,6 +4033,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4013,6 +4090,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4261,7 +4346,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
+681
-53
@@ -1270,6 +1270,26 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
|
||||
}
|
||||
|
||||
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.anim-lab-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: -2px 0 10px;
|
||||
padding: 0 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.anim-lab-select select {
|
||||
background: var(--bg-dark, #111);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.66rem;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
|
||||
|
||||
.anim-lab-demo {
|
||||
@@ -1456,11 +1476,20 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
|
||||
frame. `will-change` is deliberately not set, the base rule needs its
|
||||
`will-change: contents` for terminal compositing. */
|
||||
|
||||
.terminal-container.term-enter {
|
||||
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
|
||||
first capture lands: the same rules, so a tile combines its frame's own
|
||||
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
|
||||
.terminal-container.term-enter,
|
||||
.tile-body.term-enter {
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before {
|
||||
.tile-body.term-enter .xterm {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before,
|
||||
.tile-body.term-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* A screen whose content lands while its frame is still entering waits for
|
||||
it (--tile-screen-delay, entrance-animations.js): the body holds its first
|
||||
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
|
||||
static block). */
|
||||
.tile-body.term-enter {
|
||||
animation-delay: var(--tile-screen-delay, 0ms);
|
||||
}
|
||||
|
||||
.tile-body.term-enter::before {
|
||||
animation-delay: var(--tile-screen-delay, 0ms);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
/* CRT, power-on: a hot line that expands to full height. */
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter {
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter,
|
||||
html[data-term-anim="crt"] .tile-body.term-enter {
|
||||
animation-name: term-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter::before,
|
||||
html[data-term-anim="crt"] .tile-body.term-enter::before {
|
||||
animation-name: term-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
||||
100% { opacity: 0; background: transparent; }
|
||||
}
|
||||
|
||||
/* Boot, flickers on under a green scan sweep. */
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-boot;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(0, 255, 102, 0.05) 40%,
|
||||
rgba(200, 255, 220, 0.28) 50%,
|
||||
rgba(0, 255, 102, 0.05) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
animation-name: term-enter-boot-scan;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot {
|
||||
0% { opacity: 0; transform: scale(0.995); }
|
||||
8% { opacity: 0.85; }
|
||||
15% { opacity: 0.1; }
|
||||
24% { opacity: 1; }
|
||||
33% { opacity: 0.35; }
|
||||
45% { opacity: 1; transform: none; }
|
||||
58% { opacity: 0.7; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot-scan {
|
||||
0% { opacity: 0; background-position: 0 -150%; }
|
||||
12% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; background-position: 0 150%; }
|
||||
}
|
||||
|
||||
/* Wipe, reveals top-to-bottom behind a bright edge. */
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter {
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter,
|
||||
html[data-term-anim="wipe"] .tile-body.term-enter {
|
||||
animation-name: term-enter-wipe;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
|
||||
html[data-term-anim="wipe"] .tile-body.term-enter::before {
|
||||
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
|
||||
animation-name: term-enter-wipe-edge;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
@@ -1565,7 +1570,8 @@ html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
||||
}
|
||||
|
||||
/* Slide up, rises into place from below. */
|
||||
html[data-term-anim="slide"] .terminal-container.term-enter {
|
||||
html[data-term-anim="slide"] .terminal-container.term-enter,
|
||||
html[data-term-anim="slide"] .tile-body.term-enter {
|
||||
animation-name: term-enter-slide;
|
||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -1577,7 +1583,8 @@ html[data-term-anim="slide"] .terminal-container.term-enter {
|
||||
}
|
||||
|
||||
/* Fade, quiet, with a touch of scale. */
|
||||
html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
html[data-term-anim="fade"] .terminal-container.term-enter,
|
||||
html[data-term-anim="fade"] .tile-body.term-enter {
|
||||
animation-name: term-enter-fade;
|
||||
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
@@ -1615,7 +1622,8 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
|
||||
before, during and after a run), and the property allowlist for every one of
|
||||
these keyframes is pinned by test/entrance-animations.test.ts. */
|
||||
html[data-term-anim="blur"] .terminal-container.term-enter {
|
||||
html[data-term-anim="blur"] .terminal-container.term-enter,
|
||||
html[data-term-anim="blur"] .tile-body.term-enter {
|
||||
animation-name: term-enter-blur;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
@@ -1738,10 +1746,20 @@ html[data-line-anim="blur"] .connection-line.line-enter {
|
||||
.ultracode-window.win-enter::before,
|
||||
.terminal-container.term-enter,
|
||||
.terminal-container.term-enter::before,
|
||||
.tile-body.term-enter,
|
||||
.tile-body.term-enter::before,
|
||||
.tile.tile--entering,
|
||||
.tile.tile--entering::before,
|
||||
.connection-line.line-enter,
|
||||
.connection-line-packet {
|
||||
.connection-line-packet,
|
||||
.connection-line.tile-beam-line,
|
||||
.session-tab.tab-launch::after {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.tile-beam-lines {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
@@ -3010,6 +3028,19 @@ body.solo-mode .btn-lifecycle-log {
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
|
||||
applyTabOrientation() and the pre-paint script in index.html stamp the
|
||||
attribute). The logo leaves the session tabs (header strip, vertical rail,
|
||||
sidebar, grouped rail, phone chips) and the desktop home rail; the status
|
||||
dot and the shell's SH pill stay. Every row it sits in spaces its children
|
||||
with flex `gap`, which skips a display:none item, so no empty slot is left.
|
||||
Tile and split headers (.tile-harness, .split-harness) and the Run menus
|
||||
keep their logos: only these two classes are named here. */
|
||||
html[data-tab-logos='off'] .session-tab .tab-harness,
|
||||
html[data-tab-logos='off'] .home-sessions-harness {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -16974,6 +17005,12 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save.set-head-apply {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
@@ -18777,11 +18814,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
|
||||
Scoped to the sidebar layout on purpose: applySidebarFilter() already strips
|
||||
the class whenever the filter box is off screen, and this prefix is the
|
||||
second lock — a leaked class must never be able to hide tabs from the header
|
||||
strip, which has no filter control to clear it with. */
|
||||
html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
||||
strip, which has no filter control to clear it with. The shared filter
|
||||
(_applyTabListFilter) also marks a case box (tabArrangement 'case') the
|
||||
filter emptied, which hides with its label and count rather than staying
|
||||
on screen reading 0. */
|
||||
html[data-session-list="sidebar"] .session-tab.tab-filtered-out,
|
||||
html[data-session-list="sidebar"] .tab-cluster.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The vertical rail's session search (app.js _applyTabListFilter) uses the same
|
||||
class, and also hides a group or case box the search emptied. Rail-scoped for
|
||||
the same reason as the sidebar rule above: the header strip has no box. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.tab-filtered-out,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group.tab-filtered-out,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-cluster.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The box reuses .session-sidebar-filter / -input; this only adds the clear
|
||||
button over its right edge and the "No sessions match" line. */
|
||||
.tab-rail-search {
|
||||
position: relative;
|
||||
/* Clear of the 16px resize handle on the rail's right edge. */
|
||||
padding-right: calc(0.25rem + 16px);
|
||||
}
|
||||
|
||||
.tab-rail-search .session-sidebar-filter-input {
|
||||
padding-right: 1.6rem;
|
||||
}
|
||||
|
||||
/* The native WebKit clear glyph would sit under ours. */
|
||||
.tab-rail-search .session-sidebar-filter-input::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.tab-rail-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: calc(0.45rem + 16px);
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-rail-search-clear:hover,
|
||||
.tab-rail-search-clear:focus-visible {
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-rail-search-clear[hidden],
|
||||
.tab-rail-search-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-rail-search-empty {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Every group is drawn open while searching and its header will not toggle,
|
||||
so the chevron dims to say so. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-filtering .tab-layout-group-chevron {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
|
||||
/* The detailed variant of the SAME sidebar: identical column, identical
|
||||
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
|
||||
@@ -20062,6 +20171,314 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
|
||||
Picked in App Settings → Animations (Tile Animations, or preset by the
|
||||
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
|
||||
and what a reload restores with. Any other style is held one frame
|
||||
(.tile--enter-hold: invisible, not animating) while the entrance module
|
||||
orders the cascade and measures the tile's source on the final layout, then
|
||||
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
|
||||
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
|
||||
FRAME keyframes animate transform and opacity only: FitAddon reads the
|
||||
untransformed layout box (#464), and six tiles run at once, so no filter
|
||||
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
|
||||
::before wash. Each style also has its own way out on the closing grid's
|
||||
still copy (tile-leave-*, below). */
|
||||
html[data-tile-anim="settle"] .tile.tile--entering {
|
||||
animation-name: tile-enter;
|
||||
}
|
||||
|
||||
.tile.tile--entering.tile--enter-themed {
|
||||
animation-delay: var(--tile-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tile.tile--entering.tile--enter-themed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-delay: var(--tile-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.tile.tile--enter-hold {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tile.tile--enter-hold,
|
||||
.tile.tile--enter-hold::before {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
|
||||
overshoots a hair and lands. */
|
||||
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-fly;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||
}
|
||||
|
||||
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
|
||||
animation-name: tile-wash-launch;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tile-enter-fly {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
|
||||
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
|
||||
}
|
||||
16% {
|
||||
opacity: 1;
|
||||
}
|
||||
74% {
|
||||
transform: translate(0, 0) scale(1.01);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-wash-launch {
|
||||
0% {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
|
||||
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
|
||||
}
|
||||
55% {
|
||||
opacity: 0.55;
|
||||
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
|
||||
squared up in its cell. */
|
||||
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-deal;
|
||||
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes tile-enter-deal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
|
||||
rotate(var(--tile-from-rot, -10deg));
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
|
||||
}
|
||||
88% {
|
||||
transform: scale(0.996) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
|
||||
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
|
||||
animation-name: win-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tile-enter-crt {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.5, 0.006, 1);
|
||||
}
|
||||
22% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 0.01, 1);
|
||||
}
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1.04, 1);
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 0.985, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Beam down: holds still while a beam draws down from its tab (the entrance
|
||||
module's own overlay, .tile-beam-line), then materializes out of the agent
|
||||
windows' green wash. No transform, so the beam lands where the tile is. */
|
||||
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-beam;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
|
||||
animation-name: win-enter-beam-wash;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tile-enter-beam {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
32% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
48% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cascade: swings down from its top edge, a diagonal at a time. */
|
||||
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-cascade;
|
||||
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes tile-enter-cascade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: perspective(1400px) rotateX(-80deg);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: perspective(1400px) rotateX(8deg);
|
||||
}
|
||||
80% {
|
||||
transform: perspective(1400px) rotateX(-2.5deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: perspective(1400px) rotateX(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pop: springs open from its centre, rippling out from the focused tile. */
|
||||
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-pop;
|
||||
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tile-enter-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
80% {
|
||||
transform: scale(0.993);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft: a slow drift up into place; with the Soft focus theme each screen
|
||||
then focus-pulls in (the pane's `blur` style on .tile-body). */
|
||||
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
|
||||
animation-name: tile-enter-soft;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes tile-enter-soft {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.94);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The tab a tile flies (or beams) out of glows as it goes. */
|
||||
.session-tab.tab-launch:not(.tab-loading)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tab-launch-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
|
||||
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
|
||||
tile, held while the tile materializes, then faded. Their own overlay
|
||||
(#tileBeamLines), removed by the entrance module once they are done. */
|
||||
.connection-line.tile-beam-line {
|
||||
stroke-dasharray: var(--line-len);
|
||||
animation:
|
||||
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
|
||||
var(--line-enter-delay, 0ms) both,
|
||||
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
|
||||
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
|
||||
}
|
||||
|
||||
@keyframes tile-beam-out {
|
||||
from {
|
||||
opacity: 0.9;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-body .xterm {
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
@@ -20136,9 +20553,220 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
}
|
||||
|
||||
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
|
||||
on the still copy only and only for the Tiles toggle's close: a re-form's
|
||||
copy (--now) covers tiles that stay, so it keeps the plain fade above.
|
||||
Copies leave in reading order (--tile-exit-delay), so the last one ends
|
||||
last and takes the layer with it. */
|
||||
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
|
||||
var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-fly {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
|
||||
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-wash-return {
|
||||
0% {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
|
||||
var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-deal {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
22% {
|
||||
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
|
||||
}
|
||||
85% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
|
||||
rotate(var(--tile-to-rot, -10deg));
|
||||
}
|
||||
}
|
||||
|
||||
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
|
||||
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
|
||||
var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-crt {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
42% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 0.012, 1);
|
||||
}
|
||||
72% {
|
||||
opacity: 1;
|
||||
transform: scale3d(0.03, 0.012, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-wash-crt-off {
|
||||
0% {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
}
|
||||
38% {
|
||||
opacity: 1;
|
||||
background: rgba(220, 255, 235, 0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Beam down: beamed back up, flickering out under a rising wash. */
|
||||
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-beam {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
40% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
55% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-wash-beam-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
/* Cascade: folds back up on its top edge. */
|
||||
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
|
||||
var(--tile-exit-delay, 0ms) both;
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-cascade {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: perspective(1400px) rotateX(0deg) scale(0.985);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: perspective(1400px) rotateX(82deg) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pop: a last swell, then it pops away. */
|
||||
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-pop {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
35% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft: sinks away slowly. */
|
||||
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
|
||||
var(--tile-exit-delay, 0ms) both;
|
||||
}
|
||||
|
||||
@keyframes tile-leave-soft {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile-count-menu,
|
||||
.tile.tile--entering,
|
||||
.tile.tile--entering::before,
|
||||
.tile.tile--needs::after,
|
||||
.tile.tile--loading .tile-body::after,
|
||||
.tile-grid-ghosts .tile.tile--leaving,
|
||||
|
||||
@@ -283,12 +283,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
wizardRect = wizardContent.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Read tab rects for normal mode (only tabs that are actually needed)
|
||||
// Read tab rects for normal mode (only tabs that are actually needed).
|
||||
// Only a painted row is cached: a row a search or filter hid has no line
|
||||
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
|
||||
if (!wizardOpen) {
|
||||
for (const { agentId } of visibleSubagentWindows) {
|
||||
const parentSessionId = this.subagentParentMap.get(agentId);
|
||||
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
const tab = this._paintedSessionTab(parentSessionId);
|
||||
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
|
||||
}
|
||||
}
|
||||
@@ -397,7 +399,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const tabRect = rects.get('tab:' + parentSessionId);
|
||||
if (!tabRect) {
|
||||
// Tab not in DOM (might be scrolled out or session closed)
|
||||
// Tab not painted (session closed, collapsed group, or hidden by a search)
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -691,8 +693,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Get parent TAB element for spawn animation
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
// Get parent TAB element for spawn animation (a hidden row spawns normally)
|
||||
const parentTab = this._paintedSessionTab(parentSessionId);
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
|
||||
@@ -11,14 +11,20 @@
|
||||
*
|
||||
* Deliberately plainer than the primary pane (this.terminal/this._ws in
|
||||
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
|
||||
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
|
||||
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
|
||||
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
|
||||
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
|
||||
* handlers (a swipe on a touch screen pages nothing), and no keyboard
|
||||
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
|
||||
* and docs/tile-grid-plan.md.
|
||||
*
|
||||
* What it does carry over from the primary pane, through the primary pane's
|
||||
* own code aimed at THIS pane (its terminal, its session, never the active
|
||||
* one):
|
||||
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
|
||||
* renderer scrolls its own transcript on SGR wheel reports, while this
|
||||
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
|
||||
* as reports at the pointer's cell in this pane, through the primary
|
||||
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
|
||||
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
|
||||
* primary pane does, since xterm turns it into a horizontal no-op.
|
||||
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
|
||||
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
|
||||
* so the wheel pages the CLI's own transcript with PageUp/PageDown
|
||||
@@ -43,7 +49,7 @@
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -201,6 +207,9 @@
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
|
||||
// whole page, carried to the next wheel event.
|
||||
this._pageKeyPending = 0;
|
||||
// Shift+wheel travel short of a whole line, carried to the next wheel
|
||||
// event (_maybeScrollLocalOnShift).
|
||||
this._shiftScrollPending = 0;
|
||||
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
|
||||
this._scrollBytes = '';
|
||||
this._scrollFlushTimer = null;
|
||||
@@ -1027,16 +1036,18 @@
|
||||
|
||||
// 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. Not passive: the one route
|
||||
// this pane takes over, paging a hollow buffer's CLI transcript
|
||||
// (_maybePageCliTranscript), is consumed right here (preventDefault plus
|
||||
// while the pane still has scrollback to scroll. Not passive: the three
|
||||
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
|
||||
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
|
||||
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
|
||||
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
|
||||
// stopPropagation in the capture phase, the primary pane's technique), so
|
||||
// xterm's viewport, a descendant, never sees it. Every other wheel is left
|
||||
// xterm's viewport, a descendant, never sees them. Every other wheel is left
|
||||
// to xterm, which keeps doing the scrolling, and only observed for the
|
||||
// shell history pull.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (this._maybePageCliTranscript(ev)) {
|
||||
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
@@ -1046,6 +1057,71 @@
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
|
||||
}
|
||||
|
||||
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
|
||||
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
|
||||
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
|
||||
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
|
||||
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
|
||||
// no history for such a pane). Left to xterm, the wheel dragged those stale
|
||||
// frames, Claude's pinned input box with them, up the tile, or scrolled
|
||||
// nothing at all. The gate is the primary pane's own, asked for THIS pane
|
||||
// (its terminal, its session, never the active one), so the CLI rules stay
|
||||
// in terminal-ui.js and this file names no CLI; the reports go to this
|
||||
// pane's session through its own coalescer. Returns true when the wheel
|
||||
// belongs to the CLI: a gesture with no whole line or no measurable cell is
|
||||
// consumed too, as in the primary pane, so xterm never scrolls the stale
|
||||
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
|
||||
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
|
||||
_maybeForwardWheelToCli(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
if (this.terminal.buffer?.active?.type === 'alternate') return false;
|
||||
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
|
||||
// SGR coordinates address the live screen, so a report from a scrolled-up
|
||||
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
|
||||
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
|
||||
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
|
||||
const bytes = input.sgrWheelReports(lines, pos);
|
||||
if (bytes) this._queueScrollBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
|
||||
// history" gesture, here as in the primary pane (whose capture-phase wheel
|
||||
// handler scrolls with terminal.scrollLines() for the same reason). Left to
|
||||
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
|
||||
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
|
||||
// wheel into a horizontal one, so the viewport never moved. Reads the
|
||||
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
|
||||
// for the next event (a trackpad's small deltas), and on the way up still
|
||||
// asks a shell pane for more history. Returns true when the wheel was
|
||||
// consumed here.
|
||||
_maybeScrollLocalOnShift(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!input?.wheelDeltaLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
if (this.terminal.buffer?.active?.type === 'alternate') return false;
|
||||
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
const lines = Math.trunc(total);
|
||||
this._shiftScrollPending = total - lines;
|
||||
if (lines) {
|
||||
this.terminal.scrollLines(lines);
|
||||
if (lines < 0) this._maybeLoadMoreHistory();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A plain left-click reported to the CLI, the primary pane's desktop click
|
||||
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
|
||||
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
|
||||
@@ -1087,9 +1163,9 @@
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// The primary pane would forward this wheel to Claude's fullscreen
|
||||
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
|
||||
// follow-up 4), so the wheel stays with xterm, as before.
|
||||
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
|
||||
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
|
||||
// forwarding session is never paged.
|
||||
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
|
||||
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
|
||||
// Only from the live screen. The one gate the primary pane never needs: a
|
||||
@@ -1111,8 +1187,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
|
||||
// fling cannot build a backlog that keeps paging after it stops. A narrow
|
||||
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
|
||||
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
|
||||
// that keeps scrolling after it stops. A narrow
|
||||
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
|
||||
// (terminal-ui.js; keep the two in step), which flushes to the active
|
||||
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
|
||||
|
||||
@@ -107,6 +107,32 @@
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
}
|
||||
|
||||
// The same travel rounded to whole lines for the SGR wheel reports: a pure
|
||||
// horizontal swipe is 0 (nothing to send), and anything else moves at least
|
||||
// one line, so the small pixel deltas of a precision touchpad still scroll.
|
||||
// The body of the primary pane's _wheelScrollLines.
|
||||
function wheelDeltaWholeLines(ev, rows) {
|
||||
const lines = wheelDeltaLines(ev, rows);
|
||||
if (!lines) return 0;
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Ticks one gesture batch may report: Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top, so a bigger batch only overshoots.
|
||||
const SGR_WHEEL_MAX_TICKS = 5;
|
||||
|
||||
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
|
||||
// live-screen relative): button 64 per line up, 65 per line down, capped at
|
||||
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
|
||||
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
|
||||
// byte-identical reports to its own session.
|
||||
function sgrWheelReports(lines, pos) {
|
||||
if (!lines || !pos) return '';
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
|
||||
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
}
|
||||
|
||||
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
|
||||
// `lines` to the sub-page travel already `pending`, and returns the travel
|
||||
// left over plus the keys to send ('' below one page). The arithmetic of the
|
||||
@@ -177,6 +203,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is
|
||||
// baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen.
|
||||
function cursorViewportRow(terminal) {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.baseY + (buf.cursorY || 0) - buf.viewportY;
|
||||
return row >= 0 && row < terminal.rows ? row : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalQueryResponse(data) {
|
||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||
}
|
||||
@@ -250,6 +288,7 @@
|
||||
isComposerNavKey,
|
||||
classifyPredictInput,
|
||||
isCodexComposerRow,
|
||||
cursorViewportRow,
|
||||
CODEX_COMPOSER_ROW_RE,
|
||||
BRACKETED_PASTE_START,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
@@ -260,6 +299,9 @@
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
wheelDeltaLines,
|
||||
wheelDeltaWholeLines,
|
||||
SGR_WHEEL_MAX_TICKS,
|
||||
sgrWheelReports,
|
||||
pageKeysForTravel,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
@@ -2004,7 +2046,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
|
||||
// included so pasted-attachment paths (`.claude-images/paste-*.png`) and
|
||||
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
|
||||
// screenshots an agent just wrote are clickable; those open the file
|
||||
// preview rather than the log viewer (see addLink).
|
||||
//
|
||||
@@ -3991,14 +4033,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (session.mode === 'opencode') {
|
||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
||||
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
|
||||
// We use the cursor's screen row to find the right line, then scan for ┃.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 3,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
const row = buf.cursorY;
|
||||
const row = window.CodemanTerminalInput.cursorViewportRow(terminal);
|
||||
if (row === null) return null;
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) return null;
|
||||
const text = line.translateToString(true);
|
||||
@@ -4028,23 +4071,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the viewport, while xterm's cursor still marks the editable input
|
||||
// position. Fall back to cursor coordinates so phone typing appears at
|
||||
// the terminal cursor instead of disappearing into pending state.
|
||||
// The glyph is looked for from the cursor's screen row up to the top of the
|
||||
// live screen only: rows above that are parked scrollback with old composer glyphs.
|
||||
this._localEchoOverlay.setPrompt({
|
||||
type: 'custom',
|
||||
offset: 0,
|
||||
find: (terminal) => {
|
||||
try {
|
||||
const buf = terminal.buffer.active;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const cursorRow = window.CodemanTerminalInput.cursorViewportRow(terminal);
|
||||
if (cursorRow === null) return null;
|
||||
const lowest = Math.max(0, buf.baseY - buf.viewportY);
|
||||
for (let row = cursorRow; row >= lowest; row--) {
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf('\u276f');
|
||||
if (idx >= 0) return { row, col: idx + 2 };
|
||||
}
|
||||
return {
|
||||
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
|
||||
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
|
||||
};
|
||||
return { row: cursorRow, col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -5563,9 +5608,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
|
||||
// Claude mode the same value also capped the forwarded SGR report at one tick.
|
||||
_wheelScrollLines(ev) {
|
||||
const lines = this._wheelScrollLinesFloat(ev);
|
||||
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
|
||||
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
@@ -5639,13 +5683,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// scroll-speed multiplier and acceleration on top), and the queue is bounded
|
||||
// so a wild scroll can't build a backlog that keeps scrolling after the finger
|
||||
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
|
||||
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
|
||||
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
|
||||
_sendSyntheticSgrWheel(clientX, clientY, lines) {
|
||||
if (!this.activeSessionId || !lines) return;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return;
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), 5);
|
||||
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
|
||||
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+334
-123
@@ -23,14 +23,18 @@
|
||||
|
||||
// Per-device tile font size (a tile is a fraction of the screen).
|
||||
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
|
||||
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
|
||||
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
|
||||
// in reading order with `null` for an empty one. `open: false` keeps it
|
||||
// remembered for one-click return; restored on reload inside handleInit.
|
||||
// The grid this browser last had, as the user left it (sanitizeTileGridState,
|
||||
// constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
|
||||
// `ids` being the cells in reading order with `null` for an empty one, `count`
|
||||
// how many tiles the user's own last change left (a session that went away by
|
||||
// itself does not lower it). Session ids and layout only, never content.
|
||||
// Written on every change while the grid is open; `open: false` keeps it for
|
||||
// the Tiles toggle, which brings it back exactly; a grid stored open is
|
||||
// restored on reload inside handleInit. Never sent to the server.
|
||||
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
|
||||
// How many tiles a click on Tiles opens: the count last picked in its
|
||||
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
|
||||
// the stored grid above stays ids only.
|
||||
// The count last picked in the Tiles button's right-click menu (2, 4 or 6;
|
||||
// owner decision 10), per device: what a click opens when there is no stored
|
||||
// grid to bring back, and what a pick re-forms the grid to.
|
||||
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
|
||||
// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
|
||||
// copy of a closing grid goes even if animationend never comes (a hidden tab,
|
||||
@@ -147,6 +151,11 @@ class TileGridModel {
|
||||
// session id, or null for an empty cell. THE source of truth for where
|
||||
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
|
||||
this.cells = [];
|
||||
// How many tiles the user's own last change left (open, add, remove, a
|
||||
// count picked): a session that goes away by itself (deleted, popped out,
|
||||
// refused) does not lower it, so the next time the grid opens the ranking
|
||||
// fills that place. Stored with the grid (_persistTileGrid).
|
||||
this.count = 0;
|
||||
// id -> { tile: TerminalTile, el: HTMLElement }
|
||||
this.tiles = new Map();
|
||||
this.focusedId = null;
|
||||
@@ -237,8 +246,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||
// The first capture has landed (or failed): the terminal fades in,
|
||||
// whole, instead of showing its replay scroll by.
|
||||
if (state === 'idle') entry.el.classList.remove('tile--revealing');
|
||||
// whole, instead of showing its replay scroll by, or plays the
|
||||
// terminal pane's entrance style (entrance-animations.js).
|
||||
if (state === 'idle') {
|
||||
entry.el.classList.remove('tile--revealing');
|
||||
if (entry.screenOwed) {
|
||||
entry.screenOwed = false;
|
||||
this.playTileScreenEntrance?.(entry.body);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -389,6 +405,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
|
||||
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
|
||||
grid.cells = wanted.filter((id) => grid.tiles.has(id));
|
||||
// What the user opened is the grid they want (_openStoredTileGrid keeps a
|
||||
// stored count instead).
|
||||
grid.count = grid.ids.length;
|
||||
this._applyTileLayout();
|
||||
// The tiles' frames paint first; their terminals are built one per frame
|
||||
// after it, the focused tile's first, so its capture is the one the queue
|
||||
@@ -412,11 +431,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
|
||||
* queued loads dropped), the main terminal unparked.
|
||||
*
|
||||
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
|
||||
* `reselect` shows the focused session in the single view through a forced
|
||||
* reload; pass false when the caller selects something itself. `animate`
|
||||
* (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles
|
||||
* over the stage until the single view has its content (_ghostTileGrid).
|
||||
* `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
|
||||
* focus and zoom, closed, for the Tiles toggle to bring back (a reload
|
||||
* restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
|
||||
* view through a forced reload; pass false when the caller selects
|
||||
* something itself. `animate` (the Tiles toggle only, owner answer 4)
|
||||
* leaves a still copy of the tiles over the stage until the single view has
|
||||
* its content (_ghostTileGrid).
|
||||
*
|
||||
* The main terminal's cached content for EVERY tiled id is invalidated: it was
|
||||
* written before the grid opened, possibly hours ago, and selectSession paints
|
||||
@@ -457,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
grid.colFr = [];
|
||||
grid.rowFr = [];
|
||||
grid.cells = [];
|
||||
grid.count = 0;
|
||||
grid.cols = 0;
|
||||
grid.rows = 0;
|
||||
grid.focusedId = null;
|
||||
@@ -536,14 +558,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Zoomed, only the zoomed tile is on screen (the others are display: none
|
||||
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
|
||||
const zoomed = section.classList.contains('tile-grid--zoomed');
|
||||
const copies = [];
|
||||
for (const id of grid.ids) {
|
||||
const el = grid.tiles.get(id)?.el;
|
||||
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
|
||||
const ghost = el.cloneNode(true);
|
||||
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
|
||||
ghost.classList.remove(
|
||||
'tile--entering',
|
||||
'tile--enter-themed',
|
||||
'tile--enter-hold',
|
||||
'tile--needs',
|
||||
'tile--loading',
|
||||
'tile--drop-target',
|
||||
'tile--dragging'
|
||||
);
|
||||
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
|
||||
ghost.classList.add('tile--leaving');
|
||||
layer.appendChild(ghost);
|
||||
copies.push({ ghost, el, sessionId: id });
|
||||
}
|
||||
// The entrance style's own way out (entrance-animations.js): back into the
|
||||
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
|
||||
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
|
||||
main.appendChild(layer);
|
||||
let fallback = null;
|
||||
let holdCap = null;
|
||||
@@ -557,10 +593,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (fallback !== null || !layer.isConnected) return;
|
||||
clearTimeout(holdCap);
|
||||
layer.classList.add('tile-grid-ghosts--release');
|
||||
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
|
||||
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
|
||||
};
|
||||
layer.addEventListener('animationend', (e) => {
|
||||
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
|
||||
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
|
||||
if (/^tile-leave/.test(e.animationName)) done();
|
||||
});
|
||||
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
|
||||
else release();
|
||||
@@ -695,8 +732,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* The card's text, from the remembered count, the grid's state and what the
|
||||
* window fits. Compared with the last English text set, never the DOM (in
|
||||
* zh-CN the DOM holds the translation; see _renderTileOverlay).
|
||||
* window fits. With the grid closed and a stored grid to bring back, the
|
||||
* click opens that grid as it was (never trimmed to the window), so the
|
||||
* window line only says what fits. Compared with the last English text set,
|
||||
* never the DOM (in zh-CN the DOM holds the translation; see
|
||||
* _renderTileOverlay).
|
||||
*/
|
||||
_renderTileHint() {
|
||||
const hint = this._tileHint;
|
||||
@@ -712,10 +752,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
|
||||
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
|
||||
const plural = capacity === 1 ? '' : 's';
|
||||
const restoring = open ? null : this._storedTileGridSet();
|
||||
const opens = restoring ? restoring.ids.length : count;
|
||||
const fitsText =
|
||||
count <= capacity
|
||||
opens <= capacity
|
||||
? ''
|
||||
: open
|
||||
: open || restoring
|
||||
? `This window fits ${capacity} tile${plural}`
|
||||
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
|
||||
set(hint.fits.text, 'fitsText', fitsText);
|
||||
@@ -976,22 +1018,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* A count picked in the menu: remembered for the click, then the grid opens
|
||||
* with that many tiles (what the click opens), or the open grid is re-formed
|
||||
* to it (_reformTileGrid). Never more than the window fits; fewer open
|
||||
* sessions than the count give fewer tiles.
|
||||
* with that many tiles (a stored grid re-formed to it, its tiles first in
|
||||
* their cells), or the open grid is re-formed to it (_reformTileGrid).
|
||||
* Never more than the window fits; fewer open sessions than the count give
|
||||
* fewer tiles. Either way the grid's count is what the pick left.
|
||||
*/
|
||||
_pickTileCount(count) {
|
||||
this.closeTileCountMenu({ refocus: false });
|
||||
this._rememberTileGridCount(count);
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) {
|
||||
this.toggleTileGrid();
|
||||
this._activateTileGrid({ count });
|
||||
return;
|
||||
}
|
||||
const T = window.CodemanTileGrid;
|
||||
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
|
||||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
||||
this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId));
|
||||
// The tiles that join are the ranking's best (_tileGridRanking).
|
||||
this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
|
||||
grid.count = grid.ids.length;
|
||||
this._persistTileGrid();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1126,14 +1171,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
|
||||
* drift (owner decision 8): opens the grid at once, no menu in the way, or
|
||||
* closes it to the single view of the focused session. What opens is the
|
||||
* remembered count of tiles (the right-click menu's last pick, default 6,
|
||||
* at most what the window fits; owner decision 10), chosen by
|
||||
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
|
||||
* split's two sessions, else the open sessions in tab order, the active one
|
||||
* focused; then trimmed or filled to the count (the focused one kept). A
|
||||
* remembered grid comes back with its tiles in their cells, the ones the
|
||||
* count adds filling its empty cells first (_openStoredTileGrid).
|
||||
* closes it to the single view of the focused session. The grid this
|
||||
* browser last had comes back EXACTLY as the user left it (owner request:
|
||||
* "when I turn tiles off and on, always keep what the last setting was"):
|
||||
* its tiles in their cells, holes included, its count, divider sizes, focus
|
||||
* and zoom; a cell whose session no longer exists is filled from the
|
||||
* ranking (_openStoredTileGrid). With none of its sessions left (or nothing
|
||||
* stored), `tileGridOpenSet` (constants.js) takes an open split's two
|
||||
* sessions, else the open sessions as the ranking orders them
|
||||
* (_tileGridRanking: working, then needing input, then the most recent),
|
||||
* the active one always among them and focused, filled to the remembered
|
||||
* count (the right-click menu's last pick, default 6, at most what the
|
||||
* window fits; owner decision 10).
|
||||
*/
|
||||
toggleTileGrid() {
|
||||
this.closeTileCountMenu();
|
||||
@@ -1141,28 +1190,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
|
||||
return;
|
||||
}
|
||||
if (!this.canOpenTileGrid()) return;
|
||||
const stored = this._readStoredTileGrid();
|
||||
const set = this._tileGridOpenSet(stored);
|
||||
if (!set) {
|
||||
this.showToast?.('No sessions to show as tiles', 'info');
|
||||
return;
|
||||
}
|
||||
if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
|
||||
else this.openTileGrid(set.ids, { focusedId: set.focusedId });
|
||||
this._activateTileGrid();
|
||||
},
|
||||
|
||||
/**
|
||||
* What the toggle would open now (see toggleTileGrid): `count` tiles (the
|
||||
* remembered count), or as many as the window fits; `stored` saves a
|
||||
* second read.
|
||||
* Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
|
||||
* pick in the count menu while the grid is closed), with that many tiles: a
|
||||
* stored grid re-formed to it, its tiles first in their cells, the ones the
|
||||
* count adds filling its empty cells first, the rest from the ranking.
|
||||
*
|
||||
* @returns {boolean} whether the grid opened
|
||||
*/
|
||||
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
|
||||
_activateTileGrid({ count = null } = {}) {
|
||||
if (!this.canOpenTileGrid()) return false;
|
||||
const stored = this._readStoredTileGrid();
|
||||
const set = this._tileGridOpenSet(stored, count);
|
||||
if (!set) {
|
||||
this.showToast?.('No sessions to show as tiles', 'info');
|
||||
return false;
|
||||
}
|
||||
if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
|
||||
return this.openTileGrid(set.ids, { focusedId: set.focusedId });
|
||||
},
|
||||
|
||||
/**
|
||||
* What the toggle would open now (see toggleTileGrid): a stored grid as it
|
||||
* was, or `count` tiles (the remembered count by default), at most what the
|
||||
* window fits. With an explicit `count`, a stored grid is trimmed or filled
|
||||
* to it too. `stored` saves a second read.
|
||||
*/
|
||||
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
|
||||
const T = window.CodemanTileGrid;
|
||||
const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
|
||||
const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
|
||||
const ranked = this._tileGridRanking();
|
||||
const set = T.tileGridOpenSet({
|
||||
stored,
|
||||
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
|
||||
ranked,
|
||||
sessions: this.sessions,
|
||||
sessionOrder: this.sessionOrder,
|
||||
detachedIds: this.detachedSessions,
|
||||
@@ -1170,8 +1234,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
limit: n,
|
||||
});
|
||||
if (!set) return null;
|
||||
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
|
||||
return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) };
|
||||
// A stored grid comes back as it was: the count does not apply to it
|
||||
// unless one was asked for.
|
||||
if (set.source === 'stored' && count === null) return set;
|
||||
return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
|
||||
},
|
||||
|
||||
/**
|
||||
* The open sessions the grid takes when nobody said which (the Tiles button
|
||||
* with nothing stored, and every tile it fills on its own), best first:
|
||||
* working (the most recently started turn first), then the ones needing
|
||||
* input (the red and yellow tab alerts), then the rest by most recent
|
||||
* activity, tab order breaking ties (rankTileSessions, constants.js). The
|
||||
* states and stamps are the home screens' own (`_mobileOverviewState()`,
|
||||
* mobile-overview.js). Detached sessions are never in it. Guarded like
|
||||
* the sorted rail: without the classifier (a stale cached
|
||||
* mobile-overview.js) it is plain tab order.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_tileGridRanking() {
|
||||
const T = window.CodemanTileGrid;
|
||||
const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
|
||||
if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
|
||||
return open.map((c) => c.id);
|
||||
}
|
||||
const rows = open.map(({ id }, orderIndex) => {
|
||||
const session = this.sessions.get(id);
|
||||
return {
|
||||
id,
|
||||
state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
orderIndex,
|
||||
};
|
||||
});
|
||||
return T.rankTileSessions(rows);
|
||||
},
|
||||
|
||||
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
|
||||
@@ -1242,6 +1340,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
|
||||
if (!this._mountTile(sessionId, { enterIndex })) return false;
|
||||
this._placeTile(sessionId, cell);
|
||||
grid.count = grid.ids.length;
|
||||
// A tile added while one is zoomed by hand is meant to be seen.
|
||||
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
|
||||
this._applyTileLayout();
|
||||
@@ -1286,17 +1385,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||||
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
|
||||
* DOM focus where it is (an app-driven removal: a socket the server closed),
|
||||
* so keystrokes never land in the neighbour's PTY unasked. The last tile
|
||||
* leaving closes the grid: with `refocus` the single view then shows that
|
||||
* session (or, popped out, the next one: _selectAfterTileGrid), without it
|
||||
* the caller decides what comes next.
|
||||
* so keystrokes never land in the neighbour's PTY unasked. `gone`: the
|
||||
* session went away by itself (deleted, popped out, its socket refused), not
|
||||
* by the user's hand, so the grid's count stays and the next time it opens
|
||||
* the ranking fills that place. The last tile leaving closes the grid, kept
|
||||
* as it was: with `refocus` the single view then shows that session (or,
|
||||
* popped out, the next one: _selectAfterTileGrid), without it the caller
|
||||
* decides what comes next.
|
||||
*/
|
||||
removeTile(sessionId, { refocus = true, focus = true } = {}) {
|
||||
removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||
if (!entry) return false;
|
||||
if (grid.ids.length === 1) {
|
||||
this.closeTileGrid({ keepStored: false, reselect: refocus });
|
||||
this.closeTileGrid({ keepStored: true, reselect: refocus });
|
||||
return true;
|
||||
}
|
||||
const wasFocused = grid.focusedId === sessionId;
|
||||
@@ -1312,6 +1414,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
entry.el.remove();
|
||||
grid.tiles.delete(sessionId);
|
||||
grid.cells[grid.cells.indexOf(sessionId)] = null;
|
||||
if (!gone) grid.count = grid.ids.length;
|
||||
// Before the layout, which may zoom the focused tile on a small window.
|
||||
if (wasFocused) grid.focusedId = null;
|
||||
this._applyTileLayout();
|
||||
@@ -1323,11 +1426,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Builds one tile (header, body, TerminalTile); where it goes is the
|
||||
* caller's (grid.cells). It enters with a short fade and settle
|
||||
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
|
||||
* and its terminal stays transparent until its first capture lands
|
||||
* (.tile--revealing, cleared by the load queue, with a backstop timer).
|
||||
* Opacity and transform only, never anything its fit reads.
|
||||
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
|
||||
* group (_beginTileEntrance), and its terminal stays transparent until its
|
||||
* first capture lands (.tile--revealing, cleared by the load queue, with a
|
||||
* backstop timer). Opacity and transform only, never anything its fit reads.
|
||||
*/
|
||||
_mountTile(sessionId, { enterIndex = 0 } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
@@ -1336,16 +1438,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile tile--revealing';
|
||||
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
|
||||
if (this._tileMotionAllowed()) {
|
||||
el.classList.add('tile--entering');
|
||||
el.style.setProperty('--tile-enter-index', String(enterIndex));
|
||||
const onEnd = (e) => {
|
||||
if (e.target !== el || e.animationName !== 'tile-enter') return;
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
el.classList.remove('tile--entering');
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
// Its screen plays the terminal pane's entrance once its first capture
|
||||
// lands, when its frame entered in a tile style (Tile Animations): never
|
||||
// with the default `settle`, nor on a reload's restore, which settles.
|
||||
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
|
||||
const screenOwed = !!entered && entered !== 'settle';
|
||||
el.dataset.sessionId = sessionId;
|
||||
// Header and body are siblings: the chrome is refreshed in place
|
||||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||||
@@ -1391,12 +1488,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
renaming: false,
|
||||
// The pid this tile last saw, so a pane that starts later is noticed.
|
||||
pid: this.sessions.get(sessionId)?.pid ?? null,
|
||||
// Its screen plays the terminal entrance once its first capture lands.
|
||||
screenOwed,
|
||||
});
|
||||
// Where it goes is the caller's (grid.cells).
|
||||
this._renderTileHeader(sessionId);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
|
||||
* entrance style (entrance-animations.js, App Settings → Entrance
|
||||
* Animations). `settle` is the grid's own fade and settle (styles.css
|
||||
* .tile--entering), and what a reload restores with whatever the theme:
|
||||
* nothing animates on page load. Any other style is timed by the entrance
|
||||
* module a frame later, once the layout is final (_stageTileEntrance).
|
||||
* Opacity and transform only, never anything the fit reads.
|
||||
*
|
||||
* @returns {string|null} the style it enters with, or null when it does not animate
|
||||
*/
|
||||
_beginTileEntrance(el, sessionId, enterIndex = 0) {
|
||||
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
|
||||
if (!this._tileMotionAllowed() || style === 'off') return null;
|
||||
el.classList.add('tile--entering');
|
||||
el.style.setProperty('--tile-enter-index', String(enterIndex));
|
||||
let backstop = null;
|
||||
const finish = () => {
|
||||
clearTimeout(backstop);
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
|
||||
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
|
||||
};
|
||||
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
|
||||
// never a child's animation or a wash on its ::before.
|
||||
const onEnd = (e) => {
|
||||
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
|
||||
finish();
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
el._tileEnterFinish = finish;
|
||||
if (style !== 'settle') {
|
||||
this._stageTileEntrance?.(el, sessionId, (ms) => {
|
||||
clearTimeout(backstop);
|
||||
backstop = setTimeout(finish, ms);
|
||||
});
|
||||
}
|
||||
return style;
|
||||
},
|
||||
|
||||
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
|
||||
_replayTileEntrance(el, sessionId, enterIndex = 0) {
|
||||
el._tileEnterFinish?.();
|
||||
void el.offsetWidth;
|
||||
return this._beginTileEntrance(el, sessionId, enterIndex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes `el` a drop target for a session tab dragged from the strip (the
|
||||
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
|
||||
@@ -1575,10 +1721,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
|
||||
* human selection: the user clicked its tab). With the grid closed it opens
|
||||
* what the Tiles toggle would, with this session among the tiles and
|
||||
* focused: the remembered count in total (owner answer to decision 10's
|
||||
* questions), never one more. Returns false
|
||||
* when the grid cannot open here (narrow or solo window), so the click is an
|
||||
* ordinary one.
|
||||
* focused, never past the remembered count (owner answer to decision 10's
|
||||
* questions: N, not N+1): it joins while the grid holds fewer than the
|
||||
* count (a stored grid's first empty cell), else it takes the last tile's
|
||||
* place. A stored grid keeps its cells, sizes and holes around it.
|
||||
* Returns false when the grid cannot open here (narrow or solo window), so
|
||||
* the click is an ordinary one.
|
||||
*/
|
||||
addSessionToTiles(sessionId) {
|
||||
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
|
||||
@@ -1599,7 +1747,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
}
|
||||
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
|
||||
const base = this._tileGridOpenSet()?.ids || [];
|
||||
const stored = this._readStoredTileGrid();
|
||||
const plan = this._tileGridOpenSet(stored);
|
||||
if (plan?.source === 'stored') {
|
||||
let { ids, cells } = plan;
|
||||
if (!ids.includes(sessionId)) {
|
||||
if (ids.length < n) {
|
||||
ids = [...ids, sessionId];
|
||||
} else {
|
||||
const last = ids.at(-1);
|
||||
ids = ids.map((id) => (id === last ? sessionId : id));
|
||||
cells = cells.map((id) => (id === last ? sessionId : id));
|
||||
}
|
||||
}
|
||||
return this._openStoredTileGrid(
|
||||
stored,
|
||||
{ ids, cells, focusedId: sessionId },
|
||||
{ focusedId: sessionId, auto: false, keepCount: false }
|
||||
);
|
||||
}
|
||||
const base = plan?.ids || [];
|
||||
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
|
||||
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
|
||||
// it), and merging it again went past the count (N+1) and the window.
|
||||
@@ -1642,7 +1809,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_replaceTileGrid(ids) {
|
||||
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
|
||||
if (this._tilesOwnTerminal()) {
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
// Kept until the group's grid, opened next, takes its place.
|
||||
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
// Exactly the group: an open split closes without joining it (merged, its
|
||||
@@ -2127,7 +2295,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderTileOverlay(sessionId);
|
||||
return;
|
||||
}
|
||||
this.removeTile(sessionId, { focus: false });
|
||||
this.removeTile(sessionId, { focus: false, gone: true });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2481,17 +2649,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._persistTileGrid();
|
||||
},
|
||||
|
||||
// ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
|
||||
// ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
|
||||
|
||||
/**
|
||||
* Writes the open grid: ids, focus, a zoom the user chose (an automatic one
|
||||
* is worked out again from the window) and the divider fractions. Never
|
||||
* content. `open: false` is the closed-but-remembered state. Never in a solo
|
||||
* window; a storage failure only costs the convenience.
|
||||
* Writes the open grid as it is, on every change (a move, a divider drag, a
|
||||
* tile added or removed, a count picked, a focus, a zoom): its cells, its
|
||||
* count, focus, a zoom the user chose (an automatic one is worked out again
|
||||
* from the window) and the divider fractions. Never content, never to the
|
||||
* server. `open: false` is the closed-but-remembered state. Never in a solo
|
||||
* window, nor while a stored grid is being put back (_openStoredTileGrid
|
||||
* writes once it is); a storage failure only costs the convenience.
|
||||
*/
|
||||
_persistTileGrid({ open = true } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
|
||||
if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
|
||||
if (open && !grid.open) return;
|
||||
const state = {
|
||||
v: 1,
|
||||
@@ -2500,6 +2671,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (sanitizeTileGridState; a build before cells drops the nulls and reads
|
||||
// the tiles packed, as it always did).
|
||||
ids: grid.cells.slice(),
|
||||
// Never fewer than the tiles shown (a build before `count` derives it
|
||||
// from `ids`, and ignores it).
|
||||
count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
|
||||
focused: grid.focusedId,
|
||||
zoomed: grid.autoZoom ? null : grid.zoomedId,
|
||||
colFr: grid.colFr.slice(),
|
||||
@@ -2534,47 +2708,74 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a stored grid: its tiles and focus, then the fractions it had (only
|
||||
* if they still match the layout) and a zoom the user chose. `auto`: the app
|
||||
* is putting it back, so no idle alert is spent. `ids` is the set to open,
|
||||
* the stored one by default (a reload); the Tiles button passes it trimmed
|
||||
* or filled to the remembered count, a shape change the cell model's rule
|
||||
* handles (reformTileCells).
|
||||
* The stored grid as it would come back now (restoreTileGridCells): its
|
||||
* cells as stored, a cell whose session no longer exists filled from the
|
||||
* ranking while the grid holds fewer than its count. Null when nothing is
|
||||
* stored or none of its sessions survive.
|
||||
*/
|
||||
_openStoredTileGrid(stored, ids = stored.ids) {
|
||||
const focus = stored.zoomed || stored.focused;
|
||||
// The stored set (an open split closes without joining it, decision 8,
|
||||
// case a), trimmed or filled to the count by the caller.
|
||||
if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
|
||||
const grid = this._tileGrid;
|
||||
_storedTileGridSet(stored = this._readStoredTileGrid()) {
|
||||
if (!stored) return null;
|
||||
return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a stored grid as the user left it: `set` (the stored cells with any
|
||||
* freed cell filled, _storedTileGridSet, by default; the Tiles button may
|
||||
* pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
|
||||
* session in it) goes back into its cells, holes included, a shape change
|
||||
* following the cell model's rule (reformTileCells: the tiles keep their row
|
||||
* and column when all fit, the ones that join fill the empty cells in
|
||||
* reading order); then the fractions it had (only if they still match the
|
||||
* layout) and a zoom the user chose on the focused tile. A grid larger than
|
||||
* the window fits is never trimmed: the focused tile shows alone until the
|
||||
* window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
|
||||
* app is putting it back, so no idle alert is spent. `keepCount`: the
|
||||
* stored count stays the grid's (a session that went away may be filled the
|
||||
* next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
|
||||
* is written until the grid is back, so a stored grid is never overwritten
|
||||
* by a half-built one.
|
||||
*/
|
||||
_openStoredTileGrid(
|
||||
stored,
|
||||
set = this._storedTileGridSet(stored),
|
||||
{ focusedId = null, auto = true, keepCount = true } = {}
|
||||
) {
|
||||
if (!set?.ids?.length) return false;
|
||||
const T = window.CodemanTileGrid;
|
||||
// openTileGrid packed the tiles from the first cell. The stored tiles go
|
||||
// back to their cells (a session gone since leaves its cell empty): as
|
||||
// they were when this is the stored set in the shape it was stored with,
|
||||
// otherwise by the cell model's rule, the tiles the count adds filling the
|
||||
// empty cells in reading order, holes first.
|
||||
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||||
const kept = storedCells.filter(Boolean);
|
||||
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
|
||||
const cells = sameSet
|
||||
? storedCells
|
||||
: T.reformTileCells(
|
||||
storedCells,
|
||||
T.tileCellCols(storedCells.length),
|
||||
kept,
|
||||
grid.ids.filter((id) => !kept.includes(id)),
|
||||
grid.cols,
|
||||
grid.rows
|
||||
);
|
||||
const focus = focusedId || set.focusedId;
|
||||
// An open split closes without joining it (decision 8, case a).
|
||||
this._tilePersistHold = true;
|
||||
let opened = false;
|
||||
try {
|
||||
opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
|
||||
} finally {
|
||||
this._tilePersistHold = false;
|
||||
}
|
||||
if (!opened) return false;
|
||||
const grid = this._tileGrid;
|
||||
// openTileGrid packed the tiles from the first cell. They go back to their
|
||||
// cells (a session gone since leaves its cell empty, unless the ranking
|
||||
// filled it).
|
||||
const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
|
||||
const kept = base.filter(Boolean);
|
||||
const cells = T.reformTileCells(
|
||||
base,
|
||||
T.tileCellCols(base.length),
|
||||
kept,
|
||||
grid.ids.filter((id) => !kept.includes(id)),
|
||||
grid.cols,
|
||||
grid.rows
|
||||
);
|
||||
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
|
||||
grid.cells = cells;
|
||||
}
|
||||
grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
|
||||
// openTileGrid laid the grid out with equal tracks. The stored ones go back
|
||||
// on; _applyTileLayout drops them again if they do not match the column or
|
||||
// row count (the window may have changed the layout since).
|
||||
if (stored.colFr) grid.colFr = stored.colFr.slice();
|
||||
if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
|
||||
if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
|
||||
if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
|
||||
grid.zoomedId = stored.zoomed;
|
||||
grid.autoZoom = false;
|
||||
}
|
||||
@@ -2598,17 +2799,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._tilesOwnTerminal()) return false;
|
||||
const stored = this._readStoredTileGrid();
|
||||
if (!stored?.open || stored.ids.length === 0) return false;
|
||||
return this._openStoredTileGrid(stored);
|
||||
// Put back, not opened: the tiles settle in whatever the entrance theme.
|
||||
this._tileEnterQuiet = true;
|
||||
try {
|
||||
return this._openStoredTileGrid(stored);
|
||||
} finally {
|
||||
this._tileEnterQuiet = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
|
||||
/**
|
||||
* A followed `#session=` link took the screen on load: the stored grid stays
|
||||
* remembered, closed. Only `open` changes: the value is written back as it
|
||||
* was stored, so a session gone since still frees its cell for the ranking
|
||||
* the next time the grid opens.
|
||||
*/
|
||||
_closeStoredTileGrid() {
|
||||
const stored = this._readStoredTileGrid();
|
||||
if (!stored?.open) return;
|
||||
// Stored as it was read, holes included (`ids` carries the cells).
|
||||
const { cells, ...rest } = stored;
|
||||
try {
|
||||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
|
||||
const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
|
||||
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
|
||||
} catch {
|
||||
/* Per-device convenience only. */
|
||||
}
|
||||
@@ -2637,7 +2848,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return false;
|
||||
for (const id of grid.ids.slice()) {
|
||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
|
||||
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
|
||||
}
|
||||
if (!grid.open) return false;
|
||||
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
||||
@@ -2665,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (grid?.has(data.id)) {
|
||||
const wasFocused = grid.focusedId === data.id;
|
||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||
this.removeTile(data.id, { refocus: false });
|
||||
this.removeTile(data.id, { refocus: false, gone: true });
|
||||
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
||||
}
|
||||
|
||||
@@ -194,8 +194,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position: spawn from the parent tab if we can find it, else cascade.
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
// Position: spawn from the parent tab if it is painted, else cascade.
|
||||
const parentTab = this._paintedSessionTab(parentSessionId);
|
||||
if (parentTab) {
|
||||
// _tabAnchor() puts the spawn point below the tab in header layout and to
|
||||
// the RIGHT of it in sidebar layout, so the window never lands on the
|
||||
@@ -312,9 +312,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
|
||||
/**
|
||||
* Genie the window toward the center of its tab, then invoke `done` to tear it
|
||||
* down. A tab that is not painted (hidden by a search) tears down at once.
|
||||
*/
|
||||
_animateUltracodeWindowToTab(element, sessionId, done) {
|
||||
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
|
||||
const tab = this._paintedSessionTab(sessionId);
|
||||
if (!tab || !element) {
|
||||
done();
|
||||
return;
|
||||
@@ -781,7 +784,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!parentSessionId) continue;
|
||||
const tabKey = 'tab:' + parentSessionId;
|
||||
if (!rects.has(tabKey)) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
const tab = this._paintedSessionTab(parentSessionId);
|
||||
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||
}
|
||||
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
|
||||
@@ -830,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!parentSessionId) continue;
|
||||
const tabKey = 'tab:' + parentSessionId;
|
||||
if (!rects.has(tabKey)) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
const tab = this._paintedSessionTab(parentSessionId);
|
||||
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||
}
|
||||
const tabRect = rects.get(tabKey);
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
@@ -207,14 +211,65 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
|
||||
*/
|
||||
const installsInFlight = new Set<string>();
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
|
||||
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
|
||||
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
|
||||
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
|
||||
*
|
||||
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
|
||||
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
|
||||
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
|
||||
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
|
||||
*/
|
||||
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
|
||||
env: source,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
const prefix = stdout.trim();
|
||||
if (!prefix) return true;
|
||||
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
|
||||
let dir = join(prefix, 'lib', 'node_modules');
|
||||
for (;;) {
|
||||
try {
|
||||
await access(dir, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return false;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
|
||||
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
|
||||
* so an `npm install -g` lands on the persistent home mount instead of the image.
|
||||
* data dir, the tmux socket), none of which an installer needs.
|
||||
*
|
||||
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
|
||||
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
|
||||
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
|
||||
* update (the image's own prefix is image content);
|
||||
* - on a native install whose npm global prefix is not writable by the server user (a system node
|
||||
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
|
||||
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
|
||||
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
|
||||
*/
|
||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
export function installEnv(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
@@ -224,11 +279,40 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
|
||||
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
|
||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
||||
redirectNpmPrefix(env, source.HOME);
|
||||
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
|
||||
redirectNpmPrefix(env, source.HOME);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
|
||||
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
|
||||
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
|
||||
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
|
||||
*/
|
||||
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
|
||||
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
|
||||
env.NPM_CONFIG_PREFIX = `${home}/.local`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
|
||||
* command runs npm at all: a `curl | bash` installer never pays for it.
|
||||
*/
|
||||
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
|
||||
const usesNpm = /\bnpm\b/.test(command);
|
||||
const probeNeeded =
|
||||
usesNpm &&
|
||||
source.CODEMAN_IN_CONTAINER !== '1' &&
|
||||
process.platform !== 'win32' &&
|
||||
!!source.HOME &&
|
||||
!source.NPM_CONFIG_PREFIX;
|
||||
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
|
||||
return installEnv(source, () => writable);
|
||||
}
|
||||
|
||||
interface InstallResult {
|
||||
code: number | null;
|
||||
output: string;
|
||||
@@ -244,6 +328,7 @@ interface InstallResult {
|
||||
* CUSTOM entry can never reach this function at all — see the route's own guard below.
|
||||
*/
|
||||
async function runInstallCommand(command: string): Promise<InstallResult> {
|
||||
const env = await installEnvFor(command);
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
@@ -255,7 +340,7 @@ async function runInstallCommand(command: string): Promise<InstallResult> {
|
||||
// out into package-manager children, and spawn's own `timeout` option signals
|
||||
// only the direct child, leaving survivors holding the pipes open forever.
|
||||
detached: true,
|
||||
env: installEnv(),
|
||||
env,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* only, never env values, headers or file content (a parse failure is reported by position).
|
||||
*
|
||||
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
||||
* or already has its config file; one that is enabled but absent from the machine is reported
|
||||
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
|
||||
* or when its config file exists); one that is enabled but absent from the machine is reported
|
||||
* `absent` and never created. Its file is located with this process's env (the env the CLIs
|
||||
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
||||
*/
|
||||
@@ -28,6 +29,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { enabledClis } from '../../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
||||
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
|
||||
import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
|
||||
|
||||
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
|
||||
export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
@@ -35,9 +37,13 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
return settings.mcpSyncEnabled === true;
|
||||
}
|
||||
|
||||
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
|
||||
/**
|
||||
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
|
||||
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
|
||||
* definition wins a same-name difference.
|
||||
*/
|
||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||
return enabledClis()
|
||||
const registry = enabledClis()
|
||||
.filter((e) => e.capabilities.mcpConfig)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((e) => ({
|
||||
@@ -46,6 +52,7 @@ export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTa
|
||||
...e.capabilities.mcpConfig!,
|
||||
installed: isCliEntryInstalled(e, availability),
|
||||
}));
|
||||
return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,7 +275,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
// count this session's historical tokens into the lifetime totals, demote
|
||||
// a pinned record to `stopped` (which this pass reads as an intentional
|
||||
// kill, making the session permanently unrestorable) and delete the
|
||||
// workspace's `.claude-images`. This undoes only the construction.
|
||||
// workspace's `.codeman-uploads`. This undoes only the construction.
|
||||
await ctx
|
||||
.discardPartiallyBuiltSession(entry.sessionId)
|
||||
.catch((discardErr: unknown) =>
|
||||
|
||||
@@ -188,6 +188,7 @@ import {
|
||||
} from '../response-viewer-transcript.js';
|
||||
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
||||
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
|
||||
import { UPLOADS_DIR } from '../paste-image-gc.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -361,6 +362,51 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
|
||||
* null when something other than a regular directory sits there. An agent or
|
||||
* postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
|
||||
* future writes outside workingDir: lstat (not stat) sees the symlink itself,
|
||||
* and mkdir without `recursive` does not follow one for the leaf either;
|
||||
* O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
|
||||
* The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
|
||||
* so the user's repository never sees uploads, their own ignore file is never
|
||||
* touched, and a file already there is theirs and stays as it is.
|
||||
*/
|
||||
async function ensureUploadDir(workingDir: string): Promise<string | null> {
|
||||
// workingDir is guaranteed to exist (live session).
|
||||
const uploadDir = join(workingDir, UPLOADS_DIR);
|
||||
try {
|
||||
const dirStat = await fs.lstat(uploadDir);
|
||||
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
try {
|
||||
await fs.mkdir(uploadDir);
|
||||
} catch (mkErr: unknown) {
|
||||
// Concurrent uploads (a batch of photos) race to create the dir — the
|
||||
// losers get EEXIST. Treat an already-present REAL directory as success,
|
||||
// but re-verify it isn't a symlink a racing actor planted.
|
||||
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
|
||||
const raceStat = await fs.lstat(uploadDir);
|
||||
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) return null;
|
||||
}
|
||||
}
|
||||
const ignoreFile = join(uploadDir, '.gitignore');
|
||||
try {
|
||||
// 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
|
||||
await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir;
|
||||
// The create can succeed before the write fails (ENOSPC): an empty ignore
|
||||
// file would read as the user's on the next upload, so take it back; its own
|
||||
// failure must not replace the cause.
|
||||
await fs.rm(ignoreFile, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||
// against a flood of unique IP keys.
|
||||
@@ -5198,6 +5244,20 @@ export function registerSessionRoutes(
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// The file lands on THIS host under the session's working directory, which
|
||||
// for a remote (SSH) session is the remote path: the agent there could never
|
||||
// read it, and the write would land in a same-named local directory or fail.
|
||||
// An owned Docker case is fine, its workspace is bind-mounted at the same
|
||||
// absolute path; an adopted container (owned: false) mounts nothing, so its
|
||||
// agent reads the file only if the container exposes that host path.
|
||||
if (session.remote) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Prompt uploads are not supported for remote (SSH) sessions'
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.isMultipart()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||
@@ -5294,37 +5354,11 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||
}
|
||||
|
||||
// Save to {workingDir}/.claude-images/
|
||||
// Refuse symlinks at imageDir — an agent or postinstall script could plant
|
||||
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
|
||||
// We lstat (not stat) so we see the symlink itself. Use mkdir without
|
||||
// `recursive` so the leaf creation does not follow a symlink either, and
|
||||
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
|
||||
const imageDir = join(session.workingDir, '.claude-images');
|
||||
try {
|
||||
const dirStat = await fs.lstat(imageDir);
|
||||
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// Non-recursive mkdir: does not follow symlinks for the leaf.
|
||||
// session.workingDir is guaranteed to exist (live session).
|
||||
try {
|
||||
await fs.mkdir(imageDir);
|
||||
} catch (mkErr: unknown) {
|
||||
// Concurrent uploads (a batch of photos) race to create .claude-images —
|
||||
// the losers get EEXIST. Treat an already-present REAL directory as
|
||||
// success, but re-verify it isn't a symlink a racing actor planted
|
||||
// (preserve the symlink-safety guarantee above).
|
||||
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
|
||||
const raceStat = await fs.lstat(imageDir);
|
||||
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||||
}
|
||||
}
|
||||
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
|
||||
const imageDir = await ensureUploadDir(session.workingDir);
|
||||
if (!imageDir) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||
|
||||
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
|
||||
* false hides the agent logo on session tabs and the desktop home rail
|
||||
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
|
||||
* and the Run menus keep their logos.
|
||||
*/
|
||||
showTabCliLogos: z.boolean().optional(),
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
|
||||
+14
-9
@@ -33,11 +33,11 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname, uptime as osUptime } from 'node:os';
|
||||
@@ -1546,11 +1546,16 @@ export class WebServer extends EventEmitter {
|
||||
killing: this.killingSessions,
|
||||
})
|
||||
) {
|
||||
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||
try {
|
||||
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
// Both upload dirs, the pre-move one too; uploadDirs() lists only real
|
||||
// directories that are not the data dir and do not contain it, none for a
|
||||
// remote session, and nothing on a workspace whose bounded probe did not
|
||||
// answer (pastCap: this acts on one path at the user's request).
|
||||
for (const uploadDir of await uploadDirs(session, { pastCap: true })) {
|
||||
try {
|
||||
await fs.rm(uploadDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop the agent skill's preamble cache for this session (seeded at create).
|
||||
@@ -2911,7 +2916,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
|
||||
// older than 7 days from each live session's .claude-images/ hourly.
|
||||
// older than 7 days from each live session's upload dirs hourly.
|
||||
if (!this.testMode) {
|
||||
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
|
||||
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
|
||||
@@ -3363,7 +3368,7 @@ export class WebServer extends EventEmitter {
|
||||
* path adds the session's token totals to the lifetime figures, demotes a
|
||||
* pinned record to `stopped` (the durable marker of an intentional kill, which
|
||||
* would make the session permanently ineligible for a reboot restore), drops
|
||||
* the persisted Ralph state, and recursively removes `.claude-images` from the
|
||||
* the persisted Ralph state, and recursively removes the upload dirs from the
|
||||
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
|
||||
* and may hold another live session's pasted images.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
||||
* @fileoverview Phase 5 admin API tests (live server, ephemeral port).
|
||||
*
|
||||
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
||||
* create (one-time password), patch + last-admin invariant, reset-password,
|
||||
@@ -16,9 +16,8 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3173;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
||||
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
||||
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
||||
@@ -48,7 +47,7 @@ beforeAll(async () => {
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
|
||||
@@ -80,6 +80,31 @@ describe('App Settings modal structure', () => {
|
||||
expect(system).toContain('id="appSettingsTunnelEnabled"');
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner decision (2026-10-09): every animation setting has its own
|
||||
* Animations section, right after Appearance, so it is easy to find. The
|
||||
* selects are wired by id in entrance-animations.js, not by the load/save
|
||||
* path above, so they get their own check here.
|
||||
*/
|
||||
it('keeps every animation setting in its own Animations section, after Appearance', () => {
|
||||
const modal = settingsModal();
|
||||
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
for (const list of [rail, order]) {
|
||||
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
|
||||
}
|
||||
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
|
||||
}
|
||||
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
|
||||
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(anim).toContain(`document.getElementById('${id}')`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Local Echo the first row of the second section', () => {
|
||||
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
|
||||
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3197;
|
||||
|
||||
describe('reverse-proxy base path: server wiring', () => {
|
||||
let server: WebServer;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let app: any;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman');
|
||||
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
|
||||
await server.start();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app = (server as any).app;
|
||||
@@ -66,7 +64,9 @@ describe('reverse-proxy base path: server wiring', () => {
|
||||
const { WebSocket } = await import('ws');
|
||||
const close = (path: string) =>
|
||||
new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } });
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
|
||||
headers: { origin: `http://127.0.0.1:${server.boundPort}` },
|
||||
});
|
||||
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
ws.on('error', (e) => resolve({ code: -1, reason: String(e) }));
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* because the mismatch itself needs two viewports to stage against live tmux.
|
||||
* Without the fix the first assertion below sees one fetch instead of two.
|
||||
*
|
||||
* Port: 3252 (capture geometry retry)
|
||||
* Port: ephemeral
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
|
||||
*/
|
||||
@@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3252;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url
|
||||
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it `app.terminal.rows` reads 0 and every height comparison below
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* own `json()` call, which is the one place guaranteed to land after the
|
||||
* headers and before the chunked write.
|
||||
*
|
||||
* Port: 3256 (capture load window)
|
||||
* Port: ephemeral
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
|
||||
*/
|
||||
@@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3256;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise<n
|
||||
}
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm loads from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it every buffer assertion below would throw rather than compare.
|
||||
|
||||
@@ -8,8 +8,6 @@ import { WebServer } from '../src/web/server.js';
|
||||
|
||||
declare const PathPicker: any; // evaluated inside the page, where it is a global
|
||||
|
||||
const PORT = 3193;
|
||||
|
||||
describe('Create a case in a custom folder', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
@@ -18,11 +16,11 @@ describe('Create a case in a custom folder', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
parent = mkdtempSync(join(homedir(), 'custom-case-'));
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
|
||||
* plus every verb against a recording fake transport, and the real HTTP transport
|
||||
* against a local server (headers, auth, query encoding).
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentGuardError,
|
||||
EXIT,
|
||||
agentInterrupt,
|
||||
agentLs,
|
||||
agentRead,
|
||||
agentRm,
|
||||
agentSend,
|
||||
agentSpawn,
|
||||
agentWait,
|
||||
baseHeaders,
|
||||
sendPromptFromArgs,
|
||||
composerReadyMark,
|
||||
buildInterruptBody,
|
||||
buildSendBody,
|
||||
deleteRefusal,
|
||||
describeFailure,
|
||||
httpRequest,
|
||||
inputRefusal,
|
||||
isSelfSession,
|
||||
MIN_ID_PREFIX_LENGTH,
|
||||
parsePositiveInt,
|
||||
registerAgentCommands,
|
||||
resolveAgentContext,
|
||||
stripAnsi,
|
||||
waitExitCode,
|
||||
type AgentContext,
|
||||
type AgentDeps,
|
||||
type ApiResponse,
|
||||
type RequestOptions,
|
||||
} from '../src/cli-agent.js';
|
||||
import { readCodemanEnvFile } from '../src/codeman-credentials.js';
|
||||
|
||||
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
|
||||
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
|
||||
|
||||
function ctx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
|
||||
}
|
||||
|
||||
/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
|
||||
function fakeDeps(
|
||||
answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
|
||||
json = false
|
||||
): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
|
||||
const calls: RequestOptions[] = [];
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const queue = Array.isArray(answer) ? [...answer] : undefined;
|
||||
return {
|
||||
ctx: ctx(),
|
||||
calls,
|
||||
out,
|
||||
err,
|
||||
json,
|
||||
now: () => 1_700_000_000_000,
|
||||
io: { out: (l) => out.push(l), err: (l) => err.push(l) },
|
||||
request: async (_c, options) => {
|
||||
calls.push(options);
|
||||
if (queue) {
|
||||
const next = queue.shift();
|
||||
if (!next) throw new Error('fake transport: no answer queued');
|
||||
return next;
|
||||
}
|
||||
return (answer as (o: RequestOptions) => ApiResponse)(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ok(data: unknown): ApiResponse {
|
||||
return { status: 200, json: { success: true, data }, text: '' };
|
||||
}
|
||||
|
||||
function apiError(status: number, errorCode: string, error: string): ApiResponse {
|
||||
return { status, json: { success: false, errorCode, error }, text: '' };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 1: the guard
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveAgentContext (guard)', () => {
|
||||
const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
|
||||
|
||||
it('refuses outside a Codeman session', () => {
|
||||
expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
|
||||
});
|
||||
|
||||
it('never guesses an API URL', () => {
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
|
||||
});
|
||||
|
||||
it('needs its own session id to tell self from others', () => {
|
||||
expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
|
||||
});
|
||||
|
||||
it('takes the password from the environment first, the .env file second, and none means open', () => {
|
||||
expect(
|
||||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
|
||||
).toEqual({
|
||||
username: 'admin',
|
||||
password: 'pw',
|
||||
});
|
||||
expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
|
||||
username: 'joe',
|
||||
password: 'file',
|
||||
});
|
||||
expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves each field the way attach and the TUI do (one shared order)', () => {
|
||||
// Password from the environment, username from the file: joe, not admin.
|
||||
expect(
|
||||
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
|
||||
).toEqual({ username: 'joe', password: 'pw' });
|
||||
});
|
||||
|
||||
it('reads a hand-authored .env with quotes and export prefixes', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
|
||||
try {
|
||||
const file = join(dir, '.env');
|
||||
writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
|
||||
expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
|
||||
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 2: send is printable text + \r; ESC lives only in interrupt
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('send transmits printable text only', () => {
|
||||
it('refuses every control byte and DEL, naming it', () => {
|
||||
expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
|
||||
expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
|
||||
expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
|
||||
expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
|
||||
expect(inputRefusal('a\tb')).toMatch(/single line/);
|
||||
expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
|
||||
expect(inputRefusal('')).toMatch(/empty/);
|
||||
});
|
||||
|
||||
it('accepts ordinary prompts, including unicode', () => {
|
||||
expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
|
||||
expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
|
||||
const base = { clientId: 'c', seq: 1 };
|
||||
expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
|
||||
expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
|
||||
const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
|
||||
expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
|
||||
expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
|
||||
});
|
||||
|
||||
it('interrupt is a bare ESC with no Enter', () => {
|
||||
const body = buildInterruptBody('c-interrupt', 7);
|
||||
expect(body.input).toBe('\u001b');
|
||||
expect(String(body.input)).not.toContain('\r');
|
||||
expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
|
||||
});
|
||||
|
||||
it('agentSend refuses control bytes BEFORE touching the transport', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Invariant 3: rm fails closed
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rm fails closed', () => {
|
||||
it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
|
||||
expect(deleteRefusal(SELF, '')).toMatch(/empty/);
|
||||
expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
|
||||
expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
|
||||
expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
|
||||
expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
|
||||
expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
|
||||
expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isSelfSession treats an unprovable self as "maybe me"', () => {
|
||||
expect(isSelfSession('short', OTHER)).toBe(true);
|
||||
expect(isSelfSession(SELF, '')).toBe(true);
|
||||
expect(isSelfSession(SELF, OTHER)).toBe(false);
|
||||
});
|
||||
|
||||
it('agentRm never calls DELETE on a refusal', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
|
||||
const deps = fakeDeps([ok({})]);
|
||||
expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
|
||||
expect(deps.calls[0].query).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Verbs against the fake transport
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('agent ls', () => {
|
||||
const sessions = [
|
||||
{ id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
|
||||
{ id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
|
||||
];
|
||||
|
||||
it('marks this session and falls back to workingDir for the name', async () => {
|
||||
const deps = fakeDeps([ok(sessions)]);
|
||||
expect(await agentLs(deps)).toBe(EXIT.ok);
|
||||
const text = deps.out.join('\n');
|
||||
expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
|
||||
expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
|
||||
});
|
||||
|
||||
it('--json is the envelope data plus a self flag', async () => {
|
||||
const deps = fakeDeps([ok(sessions)], true);
|
||||
await agentLs(deps);
|
||||
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
|
||||
expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
|
||||
['058ee7b5', true],
|
||||
['94990c6d', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
|
||||
const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
|
||||
expect(await agentLs(deps)).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/401.*password/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent send', () => {
|
||||
it('refuses to type into its own composer', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: true })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${OTHER}/input`,
|
||||
body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
|
||||
});
|
||||
expect(deps.calls[0].body).not.toHaveProperty('wait');
|
||||
});
|
||||
|
||||
it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
|
||||
const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
|
||||
expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
|
||||
EXIT.ok
|
||||
);
|
||||
expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
|
||||
|
||||
const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
|
||||
expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
|
||||
EXIT.timeout
|
||||
);
|
||||
|
||||
const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
|
||||
expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||||
});
|
||||
|
||||
it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
|
||||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||||
expect(deps.err.join('')).toMatch(/not delivered.*restart/);
|
||||
});
|
||||
|
||||
it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
|
||||
const deps = fakeDeps([ok({})]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
|
||||
expect(deps.out.join('')).toMatch(/accepted for/);
|
||||
expect(deps.out.join('')).not.toMatch(/delivered to/);
|
||||
});
|
||||
|
||||
it('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
|
||||
const deps = fakeDeps([ok({ buffered: true })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
|
||||
expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
|
||||
expect(deps.out.join('')).not.toMatch(/accepted for/);
|
||||
});
|
||||
|
||||
it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
|
||||
const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
|
||||
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
|
||||
expect(deps.out).toEqual([]);
|
||||
|
||||
const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
|
||||
expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
|
||||
expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
|
||||
});
|
||||
|
||||
it('reports a tagged duplicate instead of claiming delivery', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
|
||||
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
|
||||
expect(deps.out.join('')).toMatch(/duplicate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent wait', () => {
|
||||
it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
|
||||
const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${OTHER}/wait`,
|
||||
query: { until: 'stop', timeout: 1000 },
|
||||
});
|
||||
expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
|
||||
});
|
||||
|
||||
it('--match goes to /wait-output with from=buffer by default', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||||
query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses --until together with --match', async () => {
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
|
||||
expect(deps.out.join('')).toMatch(/went away/);
|
||||
expect(deps.out.join('')).not.toMatch(/signal: null/);
|
||||
});
|
||||
|
||||
it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
|
||||
expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
|
||||
expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
|
||||
expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
|
||||
expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
|
||||
expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
|
||||
// A worker that dies during --until stop: the registry only satisfies waiters that
|
||||
// listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
|
||||
expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
|
||||
expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
|
||||
expect(waitExitCode(undefined)).toBe(EXIT.error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent read', () => {
|
||||
it('defaults to last-response and prints the text', async () => {
|
||||
const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
|
||||
expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
|
||||
expect(deps.out).toEqual(['the answer']);
|
||||
});
|
||||
|
||||
it('says why an empty transcript is empty instead of printing nothing', async () => {
|
||||
const deps = fakeDeps([ok({ text: '' })]);
|
||||
await agentRead(deps, { id: OTHER });
|
||||
expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
|
||||
});
|
||||
|
||||
it('--tail fetches the terminal and strips ANSI', async () => {
|
||||
const deps = fakeDeps([ok({ terminalBuffer: '\u001b]0;w1 title\u0007\u001b[32m❯\u001b[0m ready \u001b(B' })]);
|
||||
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
|
||||
expect(deps.out).toEqual(['❯ ready ']);
|
||||
});
|
||||
|
||||
it('--full prints every message with its role', async () => {
|
||||
const deps = fakeDeps([
|
||||
ok({
|
||||
text: 'b',
|
||||
messages: [
|
||||
{ role: 'user', text: 'a' },
|
||||
{ role: 'assistant', text: 'b' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await agentRead(deps, { id: OTHER, full: true });
|
||||
expect(deps.calls[0].query).toMatchObject({ context: 'full' });
|
||||
expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent interrupt', () => {
|
||||
it('sends the bare ESC body under its own clientId, never to itself', async () => {
|
||||
const deps = fakeDeps([ok({ delivered: true })]);
|
||||
expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0].body).toEqual({
|
||||
input: '\u001b',
|
||||
useMux: true,
|
||||
clientId: 'codeman-agent-cli-058ee7b5-interrupt',
|
||||
seq: 1_700_000_000_000,
|
||||
});
|
||||
const self = fakeDeps([]);
|
||||
expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
|
||||
expect(self.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send takes the prompt as ONE argument', () => {
|
||||
it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
|
||||
expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
|
||||
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
|
||||
error: expect.stringMatching(/ONE argument, got 4/),
|
||||
});
|
||||
// A leading "-" is commander's option syntax, so the hint names the escape.
|
||||
expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send <id> -- "- fix') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn: a worker that dies during the readiness wait', () => {
|
||||
it('is exit 3 with its own line, not the composer-timeout hint', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
|
||||
expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
|
||||
expect(deps.err.join('')).not.toMatch(/composer not seen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent wait: a timeout is an answer, not a failure', () => {
|
||||
it('prints a neutral line and exits 2', async () => {
|
||||
const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
|
||||
expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||||
const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
||||
expect(line).toBe('timed out after 1000 ms (exit 2)');
|
||||
expect(deps.err).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn', () => {
|
||||
it('quick-starts with lineage and waits for the claude composer', async () => {
|
||||
const deps = fakeDeps([
|
||||
ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
|
||||
ok({ wait: { matched: true } }),
|
||||
]);
|
||||
expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
|
||||
EXIT.ok
|
||||
);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/api/v1/quick-start',
|
||||
body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
|
||||
});
|
||||
expect(deps.calls[1]).toMatchObject({
|
||||
path: `/api/v1/sessions/${OTHER}/wait-output`,
|
||||
query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
|
||||
});
|
||||
expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
|
||||
expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
|
||||
});
|
||||
|
||||
it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
|
||||
// The label drives a recursive-delete affordance in the Add Case UI: it may only ride
|
||||
// the request that can CREATE a case directory (quick-start), never anything else.
|
||||
const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
|
||||
await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
|
||||
expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
|
||||
expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
|
||||
|
||||
const everyOther = fakeDeps((o) =>
|
||||
o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
|
||||
);
|
||||
await agentLs(everyOther);
|
||||
await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
|
||||
await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
|
||||
await agentRead(everyOther, { id: OTHER });
|
||||
await agentInterrupt(everyOther, { id: OTHER });
|
||||
await agentRm(everyOther, { id: OTHER });
|
||||
expect(everyOther.calls.length).toBeGreaterThan(5);
|
||||
for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
|
||||
expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
|
||||
});
|
||||
|
||||
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
||||
expect(composerReadyMark('claude')).toBe('shift+tab');
|
||||
expect(composerReadyMark('deepseek')).toBe('❯');
|
||||
expect(composerReadyMark('pi')).toBeUndefined();
|
||||
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
|
||||
expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
|
||||
expect(deps.err.join('')).toMatch(/startup dialog/);
|
||||
});
|
||||
|
||||
it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
|
||||
const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||||
expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(pi.calls).toHaveLength(1);
|
||||
const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
|
||||
expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
|
||||
expect(noReady.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
|
||||
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
|
||||
expect(deps.err.join('')).not.toMatch(/trust dialog/);
|
||||
expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
|
||||
});
|
||||
|
||||
it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
|
||||
const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.calls).toHaveLength(1);
|
||||
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session id prefixes', () => {
|
||||
const THIRD = '94990c6d-ffff-4000-8000-000000000000';
|
||||
const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
|
||||
|
||||
it('a full id goes straight to the route, no list call', async () => {
|
||||
const deps = fakeDeps([ok({ text: 'x' })]);
|
||||
await agentRead(deps, { id: OTHER });
|
||||
expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
|
||||
});
|
||||
|
||||
it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
|
||||
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
|
||||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
|
||||
expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
|
||||
});
|
||||
|
||||
it('an ambiguous prefix refuses instead of picking one', async () => {
|
||||
const deps = fakeDeps([list]);
|
||||
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
|
||||
expect(deps.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an unknown prefix names the problem', async () => {
|
||||
const deps = fakeDeps([list]);
|
||||
expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
|
||||
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
|
||||
});
|
||||
|
||||
it('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
|
||||
expect(MIN_ID_PREFIX_LENGTH).toBe(8);
|
||||
// The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
|
||||
const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
|
||||
expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
|
||||
expect(rm.calls).toEqual([]);
|
||||
expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
|
||||
|
||||
const short = OTHER.slice(0, 7);
|
||||
const verbs: Array<[string, (deps: AgentDeps) => Promise<number>]> = [
|
||||
['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
|
||||
['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
|
||||
['read', (d) => agentRead(d, { id: short })],
|
||||
['interrupt', (d) => agentInterrupt(d, { id: short })],
|
||||
['rm', (d) => agentRm(d, { id: short })],
|
||||
];
|
||||
for (const [verb, call] of verbs) {
|
||||
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
|
||||
expect(await call(deps), verb).toBe(EXIT.refused);
|
||||
expect(deps.calls, verb).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rm runs the self guard before the list and again on the resolved id', async () => {
|
||||
const first = fakeDeps([ok([{ id: SELF }])]);
|
||||
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
|
||||
expect(first.calls).toEqual([]); // refused before any request
|
||||
const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
|
||||
expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
|
||||
expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('help texts carry the traps the skill documents', () => {
|
||||
const agent = registerAgentCommands(new Command());
|
||||
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
|
||||
|
||||
it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
|
||||
const match = sub('wait').options.find((o) => o.long === '--match')!;
|
||||
expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
|
||||
expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
|
||||
});
|
||||
|
||||
it('send names the -- escape for a prompt that starts with "-"', () => {
|
||||
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
|
||||
});
|
||||
|
||||
it('rm does not claim a lineage check it does not make', () => {
|
||||
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('option parsing', () => {
|
||||
it('positive integers only, the server rejects the rest', () => {
|
||||
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
|
||||
expect(parsePositiveInt('1500', 1)).toBe(1500);
|
||||
for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
it('describeFailure prefers the envelope and falls back to the status line', () => {
|
||||
expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
|
||||
'NOT_FOUND: no such session (HTTP 404)'
|
||||
);
|
||||
expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
|
||||
'HTTP 403 Forbidden: host not allowed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The real transport against a local server
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('httpRequest', () => {
|
||||
let server: http.Server;
|
||||
let apiUrl: string;
|
||||
const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
|
||||
if (req.url?.startsWith('/plain')) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: true, data: { echo: true } }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address() as { port: number };
|
||||
apiUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
|
||||
const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
|
||||
method: 'GET',
|
||||
path: '/api/v1/sessions/x/wait-output',
|
||||
query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json).toEqual({ success: true, data: { echo: true } });
|
||||
const last = seen.at(-1)!;
|
||||
expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
|
||||
expect(last.headers['x-codeman-parent-session']).toBe(SELF);
|
||||
expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
|
||||
expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
|
||||
});
|
||||
|
||||
it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
|
||||
await httpRequest(ctx({ apiUrl }), {
|
||||
method: 'POST',
|
||||
path: '/api/v1/sessions/x/input',
|
||||
body: { input: 'hi\r', seq: 1 },
|
||||
});
|
||||
const last = seen.at(-1)!;
|
||||
expect(last.method).toBe('POST');
|
||||
expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
|
||||
expect(last.headers['content-type']).toBe('application/json');
|
||||
expect(last.headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a plain-text body when the answer is not JSON', async () => {
|
||||
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.json).toBeUndefined();
|
||||
expect(res.text).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
|
||||
const TOP_LEVEL: Record<string, string[]> = {
|
||||
attach: [],
|
||||
skill: [],
|
||||
agent: [],
|
||||
session: ['s'],
|
||||
task: ['t'],
|
||||
ralph: ['r'],
|
||||
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
|
||||
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
|
||||
ralph: { start: [], stop: [], status: [] },
|
||||
skill: { install: [], uninstall: [] },
|
||||
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
|
||||
service: { install: [], uninstall: [], status: [] },
|
||||
users: { add: [], passwd: [], list: ['ls'], rm: [] },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @fileoverview The one credential reader `codeman attach`, `codeman tui` and
|
||||
* `codeman agent` share: env first, the data dir's `.env` as the fallback.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
|
||||
|
||||
describe('readCodemanCredentials', () => {
|
||||
let dir: string;
|
||||
const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
|
||||
else process.env.CODEMAN_USERNAME = saved.user;
|
||||
if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = saved.pass;
|
||||
});
|
||||
|
||||
it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
|
||||
expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
|
||||
});
|
||||
|
||||
it('prefers the environment over the file', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
|
||||
process.env.CODEMAN_USERNAME = 'envuser';
|
||||
process.env.CODEMAN_PASSWORD = 'env-pass';
|
||||
expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
|
||||
});
|
||||
|
||||
it('an absent file means no password, and no header to send', () => {
|
||||
const creds = readCodemanCredentials(join(dir, 'missing'));
|
||||
expect(creds).toEqual({ username: 'admin' });
|
||||
expect(basicAuthHeader(creds)).toBeUndefined();
|
||||
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
|
||||
});
|
||||
|
||||
it('takes an explicit environment, field by field', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
|
||||
expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
|
||||
});
|
||||
|
||||
it('builds a Basic header from a password', () => {
|
||||
expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
|
||||
`Basic ${Buffer.from('joe:pw').toString('base64')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1342,8 +1342,8 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
expect(modal.classList.contains('active')).toBe(true);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
|
||||
expect(message).toContain('16,384');
|
||||
expect(message).toContain('40,000');
|
||||
expect(message).toContain((16384).toLocaleString()); // the modal formats for the user's locale
|
||||
expect(message).toContain((40000).toLocaleString());
|
||||
expect(message).toMatch(/llama-swap/i);
|
||||
expect(message).toMatch(/fit-ctx/i);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
@@ -16,7 +17,17 @@ import {
|
||||
probeServer,
|
||||
} from '../src/daemon-control.js';
|
||||
|
||||
const PORT = 3216;
|
||||
/**
|
||||
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
|
||||
* moment of use, unlike "the server's port + 1", which anything may hold.
|
||||
*/
|
||||
async function closedPort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
||||
const { port: free } = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return free;
|
||||
}
|
||||
|
||||
describe('buildWebArgs', () => {
|
||||
it('always passes host and port through explicitly', () => {
|
||||
@@ -143,6 +154,7 @@ describe('isProcessAlive', () => {
|
||||
|
||||
describe('probeServer', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
@@ -157,7 +169,8 @@ describe('probeServer', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -165,23 +178,23 @@ describe('probeServer', () => {
|
||||
});
|
||||
|
||||
it('reports up and reads the version back', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
|
||||
expect(result.up).toBe(true);
|
||||
expect(result.version).toBe('9.9.9');
|
||||
});
|
||||
|
||||
it('counts a 401 as up, because auth being active proves a server is there', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
|
||||
expect(result.up).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down when nothing is listening', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
|
||||
const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
@@ -25,8 +26,6 @@ import {
|
||||
DEEPSEEK_STATE_TO_HOOK_EVENT,
|
||||
} from '../src/deepseek-status-shim.js';
|
||||
|
||||
const PORT = 3251;
|
||||
|
||||
describe('DeepSeek status shim: provisioning', () => {
|
||||
beforeEach(() => {
|
||||
resetDeepSeekStatusShimForTest();
|
||||
@@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => {
|
||||
|
||||
describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
let server: Server | undefined;
|
||||
let port: number;
|
||||
const received: Array<{ body: unknown; secret: string | undefined }> = [];
|
||||
let status = 200;
|
||||
|
||||
@@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
res.end('{}');
|
||||
});
|
||||
});
|
||||
server.listen(PORT, '127.0.0.1', resolve);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
port = (server!.address() as AddressInfo).port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => listen());
|
||||
@@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
const child = spawn(process.execPath, [path, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
|
||||
CODEMAN_SESSION_ID: 'sess-from-env',
|
||||
...env,
|
||||
},
|
||||
|
||||
@@ -3,8 +3,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3196;
|
||||
|
||||
const REPORT = {
|
||||
platform: { environment: 'linux' },
|
||||
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
|
||||
@@ -46,13 +44,13 @@ describe('Diagnostics panel in a real browser', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
// A controlling service worker can swallow requests before page.route() sees them, letting the
|
||||
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
|
||||
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
}, 90000);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const TEST_PORT = 3110;
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
@@ -13,9 +12,9 @@ describe('Edge Cases and Error Handling', () => {
|
||||
const createdCases: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -263,9 +262,9 @@ describe('Concurrent Session Handling', () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 1, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -360,9 +359,9 @@ describe('API Request Validation', () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 2, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 2}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ const SURFACES = [
|
||||
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
||||
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
||||
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
||||
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,11 @@ const SURFACES = [
|
||||
*/
|
||||
const CSS_LESS_STYLES = new Set(['off', 'fly']);
|
||||
|
||||
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
|
||||
function isCssLess(attr: string, key: string): boolean {
|
||||
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
|
||||
}
|
||||
|
||||
function styleKeys(arrayName: string): string[] {
|
||||
const start = animSource.indexOf(`const ${arrayName} = [`);
|
||||
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
|
||||
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
|
||||
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
|
||||
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
|
||||
|
||||
function themes(): Theme[] {
|
||||
const start = animSource.indexOf('const ANIM_THEMES = [');
|
||||
const body = animSource.slice(start, animSource.indexOf('];', start));
|
||||
return [
|
||||
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
|
||||
const parsed = [
|
||||
...body.matchAll(
|
||||
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
|
||||
),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
|
||||
// Every theme entry must parse: a theme missing a surface (or a regex that
|
||||
// drifted from the source) would otherwise pass the checks below vacuously.
|
||||
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
|
||||
describe(`${surface.attr} surface`, () => {
|
||||
it('backs every style with a rule that names a keyframe block that exists', () => {
|
||||
for (const key of styleKeys(surface.array)) {
|
||||
if (CSS_LESS_STYLES.has(key)) {
|
||||
if (isCssLess(surface.attr, key)) {
|
||||
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
|
||||
continue;
|
||||
}
|
||||
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('ships the blur style on all four surfaces', () => {
|
||||
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
|
||||
/**
|
||||
* Tiles are the exception: six frames animate at once, so their styles stay
|
||||
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
|
||||
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
|
||||
*/
|
||||
it('ships the blur style on the four single-element surfaces', () => {
|
||||
for (const surface of SURFACES) {
|
||||
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
|
||||
else expect(styleKeys(surface.array)).toContain('blur');
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every theme an <option> and only styles that exist', () => {
|
||||
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
|
||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tile grid entrance styles', () => {
|
||||
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
|
||||
const tileNames = (key: string, scope: RegExp) =>
|
||||
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
|
||||
.filter((rule) => scope.test(rule[1]))
|
||||
.flatMap((rule) =>
|
||||
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
|
||||
name: m[1],
|
||||
onPseudo: rule[1].includes('::before'),
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
|
||||
* A tile fits once at its final size (#464); a box-model property here would
|
||||
* resize its PTY mid-animation, and a filter on six live terminals at once
|
||||
* is the frame-time cost the pane's `blur` takes for one.
|
||||
*/
|
||||
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
|
||||
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||
if (key === 'off') continue;
|
||||
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
|
||||
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
|
||||
for (const { name, onPseudo } of names) {
|
||||
const body = keyframeBody(name);
|
||||
expect(body, `@keyframes ${name} missing`).not.toBeNull();
|
||||
if (onPseudo) continue; // a wash over the tile, no layout of its own
|
||||
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
|
||||
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
|
||||
* animationend, and the still copy goes on its last tile's `tile-leave*`:
|
||||
* a keyframe named otherwise would leave the class on (or the copy up)
|
||||
* until a backstop timer.
|
||||
*/
|
||||
it('names every frame keyframe for the events tile-grid.js listens for', () => {
|
||||
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
|
||||
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
|
||||
}
|
||||
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
|
||||
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
|
||||
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
|
||||
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
|
||||
expect(timed.length).toBeGreaterThan(0);
|
||||
for (const key of timed) {
|
||||
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
|
||||
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
|
||||
}
|
||||
expect(timed).not.toContain('settle');
|
||||
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
|
||||
// The legacy theme (the default) leaves the grid's own motion untouched.
|
||||
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
|
||||
});
|
||||
|
||||
/**
|
||||
* App Settings → Appearance → Tile Animations: one option per style, wired
|
||||
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
|
||||
* own `settle` first and named as the off default.
|
||||
*/
|
||||
it('lists every tile style in the Tile Animations setting, off by default', () => {
|
||||
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
|
||||
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
|
||||
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
|
||||
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
|
||||
expect(values[0]).toBe('settle');
|
||||
expect(select).toContain('<option value="settle">Off (default)</option>');
|
||||
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
|
||||
// Nothing new for an install that never picks it: no saved key means settle.
|
||||
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
|
||||
});
|
||||
|
||||
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
|
||||
it('plays every terminal pane style on a tile screen too', () => {
|
||||
for (const key of styleKeys('TERM_ANIM_STYLES')) {
|
||||
if (CSS_LESS_STYLES.has(key)) continue;
|
||||
// The body itself, not only its ::before wash.
|
||||
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Tests that file paths displayed in terminal output are clickable
|
||||
* and open the log viewer window correctly.
|
||||
*
|
||||
* Port allocation: 3154 (see CLAUDE.md test port table)
|
||||
* Port allocation: ephemeral port
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
@@ -14,8 +14,7 @@ import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const TEST_PORT = 3154;
|
||||
const baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
let baseUrl: string;
|
||||
const BROWSER_TIMEOUT = 30000;
|
||||
|
||||
// Helper to run agent-browser commands
|
||||
@@ -97,8 +96,9 @@ describe('File Link Click Tests', () => {
|
||||
testLogFile = join(testDir, 'test.log');
|
||||
writeFileSync(testLogFile, '=== Test Log Started ===\n');
|
||||
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Test if browser is available
|
||||
|
||||
@@ -239,6 +239,14 @@ describe('gitNonInteractiveEnv', () => {
|
||||
it("does not override a user's own GIT_SSH_COMMAND", () => {
|
||||
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
|
||||
});
|
||||
|
||||
// Issue #568. CI runs in an English locale, so the real-git tests below cannot
|
||||
// catch a revert: only this assertion fails if the pin is dropped.
|
||||
it("pins git's messages to English over the host's locale, since classifyGitFailure() matches English stderr", () => {
|
||||
const env = gitNonInteractiveEnv({ LC_ALL: 'de_DE.UTF-8', LANG: 'de_DE.UTF-8', LANGUAGE: 'de' });
|
||||
expect(env.LC_ALL).toBe('C');
|
||||
expect(env.LANG).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLsRemoteOutput', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3192;
|
||||
const ENV = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
@@ -85,7 +84,7 @@ describe('Git status indicator in a real browser', () => {
|
||||
write('a.txt', '2\n');
|
||||
write('new file.txt', 'n\n');
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
@@ -96,7 +95,7 @@ describe('Git status indicator in a real browser', () => {
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
repoSession = await createSession(repo);
|
||||
plainSession = await createSession(plain);
|
||||
|
||||
@@ -782,21 +782,19 @@ describe('subagent stop guard helper', () => {
|
||||
});
|
||||
|
||||
// ========== Hook Event API Integration Tests ==========
|
||||
// Port 3130 reserved for hooks integration tests
|
||||
// Hooks integration tests use an ephemeral port
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const TEST_PORT = 3130;
|
||||
|
||||
describe('Hook Event API', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let testSessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
|
||||
// Create a test session
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
@@ -976,9 +974,9 @@ describe('Hook Data Sanitization', () => {
|
||||
let testSessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 1, false, true); // Port 3131
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
|
||||
// Create a test session
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
|
||||
@@ -11,15 +11,14 @@ import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
const PORT = 3168;
|
||||
|
||||
describe('Stable HTTP contract (live server)', () => {
|
||||
let server: WebServer;
|
||||
const base = `http://localhost:${PORT}`;
|
||||
let base: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
base = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -238,4 +238,16 @@ describe('image upload insertion policy', () => {
|
||||
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
|
||||
});
|
||||
|
||||
it('shows the server reason in the toast when an upload fails', async () => {
|
||||
const app = loadImageInputApp();
|
||||
const reason = 'Prompt uploads are not supported for remote (SSH) sessions';
|
||||
app._uploadPasteImage = vi.fn(async () => {
|
||||
throw new Error(reason);
|
||||
});
|
||||
|
||||
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
|
||||
|
||||
expect(app.showToast).toHaveBeenCalledWith(`1 failed: ${reason}`, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { ImageWatcher } from '../src/image-watcher.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
describe('ImageWatcher', () => {
|
||||
@@ -92,6 +93,14 @@ describe('ImageWatcher', () => {
|
||||
expect(watcher.getWatchedSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores Codeman's own upload folders, so a pdf the user handed over is not a detected artifact", () => {
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
|
||||
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
|
||||
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
|
||||
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('should replace watcher when working directory changes', () => {
|
||||
watcher.watchSession('session-1', '/home/user/project-a');
|
||||
watcher.watchSession('session-1', '/home/user/project-b');
|
||||
|
||||
+10
-14
@@ -13,18 +13,14 @@
|
||||
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
|
||||
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
|
||||
*
|
||||
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
|
||||
* (per MEMORY.md, ports 3150+ for tests)
|
||||
* Ports: ephemeral, for this and the two server-backed describes below
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3164;
|
||||
const ORDERING_PORT = 3165;
|
||||
const LONG_PREFIX_PORT = 3192;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
|
||||
describe('Inline rename input', () => {
|
||||
let server: WebServer;
|
||||
@@ -32,11 +28,12 @@ describe('Inline rename input', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode = true
|
||||
server = new WebServer(0, false, true); // testMode = true
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for app.js to expose window.app and finish constructor init.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -667,11 +664,11 @@ describe('Inline rename write ordering', () => {
|
||||
type Pending = { body: string; resolve: (response: Response) => void };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(ORDERING_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (window as { app?: unknown }).app !== 'undefined' &&
|
||||
@@ -1010,14 +1007,13 @@ describe('Inline rename write ordering', () => {
|
||||
describe('Vertical rail rename editor with a long prefix', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
const port = LONG_PREFIX_PORT;
|
||||
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
|
||||
let sessionId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(port, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
const res = await fetch(`http://localhost:${port}/api/sessions`, {
|
||||
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: NAME, mode: 'shell' }),
|
||||
@@ -1048,7 +1044,7 @@ describe('Vertical rail rename editor with a long prefix', () => {
|
||||
settings
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
|
||||
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
|
||||
await row.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const TEST_PORT = 3115;
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
|
||||
/**
|
||||
@@ -18,9 +17,9 @@ describe('Integration Flows', () => {
|
||||
const createdSessions: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -282,9 +281,9 @@ describe('SSE Event Flow', () => {
|
||||
const createdSessions: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 1, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -3,19 +3,17 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3194;
|
||||
|
||||
describe('Key tester in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.focus('#keyTesterInput');
|
||||
|
||||
@@ -124,11 +124,11 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
});
|
||||
|
||||
it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// `.codeman-uploads/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/home/arkon/default/claudeman/.codeman-uploads/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
|
||||
@@ -470,3 +470,100 @@ describe('insertTerminalText under predict policy', () => {
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalInput.cursorViewportRow', () => {
|
||||
const cursorViewportRow = terminalInput.cursorViewportRow as (t: unknown) => number | null;
|
||||
const term = (viewportY: number, baseY: number, cursorY: number, rows = 24) => ({
|
||||
rows,
|
||||
buffer: { active: { viewportY, baseY, cursorY } },
|
||||
});
|
||||
|
||||
it('equals cursorY while the viewport sits at the bottom', () => {
|
||||
expect(cursorViewportRow(term(10, 10, 3))).toBe(3);
|
||||
});
|
||||
|
||||
it('shifts by baseY - viewportY when the viewport is parked above the bottom', () => {
|
||||
// scrollToLastNonEmptyLine() parked the viewport at 47 of a baseY of 83: the
|
||||
// cursor on line 83 is screen row 36, where a bare cursorY would say row 0
|
||||
expect(cursorViewportRow(term(47, 83, 0, 38))).toBe(36);
|
||||
});
|
||||
|
||||
it('is null once the cursor row is scrolled off screen', () => {
|
||||
expect(cursorViewportRow(term(0, 83, 0, 38))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null when the buffer is unreadable', () => {
|
||||
expect(cursorViewportRow({ rows: 24, buffer: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-echo prompt finders with the viewport parked above the bottom', () => {
|
||||
type Finder = { type: string; find: (t: unknown) => { row: number; col: number } | null };
|
||||
function finderFor(mode: string): Finder {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay);
|
||||
app._updateLocalEchoState();
|
||||
return overlay.prompts[overlay.prompts.length - 1] as Finder;
|
||||
}
|
||||
function terminal(
|
||||
lines: Record<number, string>,
|
||||
geom: { viewportY: number; baseY: number; cursorY: number; cursorX?: number; rows?: number; cols?: number }
|
||||
) {
|
||||
return {
|
||||
rows: geom.rows ?? 38,
|
||||
cols: geom.cols ?? 80,
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: geom.viewportY,
|
||||
baseY: geom.baseY,
|
||||
cursorY: geom.cursorY,
|
||||
cursorX: geom.cursorX ?? 5,
|
||||
getLine: (y: number) => (y in lines ? { translateToString: () => lines[y] } : undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('claude: the cursor fallback reports the cursor screen row, not cursorY', () => {
|
||||
const { find } = finderFor('claude');
|
||||
const parked = terminal({ 83: 'user@host:~$ ' }, { viewportY: 47, baseY: 83, cursorY: 0, cursorX: 13 });
|
||||
expect(find(parked)).toEqual({ row: 36, col: 13 });
|
||||
});
|
||||
|
||||
it('claude: reports no prompt once the cursor row is scrolled off screen', () => {
|
||||
const { find } = finderFor('claude');
|
||||
expect(find(terminal({ 83: 'user@host:~$ ' }, { viewportY: 0, baseY: 83, cursorY: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('claude: a prompt glyph on the cursor row wins over the cursor fallback', () => {
|
||||
const { find } = finderFor('claude');
|
||||
const parked = terminal({ 83: '\u276f ' }, { viewportY: 47, baseY: 83, cursorY: 0, cursorX: 2 });
|
||||
expect(find(parked)).toEqual({ row: 36, col: 2 });
|
||||
});
|
||||
|
||||
it('claude: a history glyph right above the live screen is ignored, however close to the cursor', () => {
|
||||
const { find } = finderFor('claude');
|
||||
// line 82 is scrollback (baseY is 83): one row above the cursor on screen, still not the composer
|
||||
const parked = terminal(
|
||||
{ 82: '\u276f old prompt', 83: 'user@host:~$ ' },
|
||||
{ viewportY: 47, baseY: 83, cursorY: 0, cursorX: 13 }
|
||||
);
|
||||
expect(find(parked)).toEqual({ row: 36, col: 13 });
|
||||
});
|
||||
|
||||
it('claude: a composer taller than a handful of rows still finds its glyph', () => {
|
||||
const { find } = finderFor('claude');
|
||||
// glyph on line 70, cursor thirteen continuation rows below it on line 83, all live screen
|
||||
const parked = terminal(
|
||||
{ 70: '\u276f a very long pasted prompt', 83: 'still typing' },
|
||||
{ viewportY: 47, baseY: 70, cursorY: 13, cursorX: 12 }
|
||||
);
|
||||
expect(find(parked)).toEqual({ row: 23, col: 2 });
|
||||
});
|
||||
|
||||
it('opencode: reads the border on the cursor screen row when parked', () => {
|
||||
const { find } = finderFor('opencode');
|
||||
const parked = terminal({ 83: '\u2503 hello' }, { viewportY: 47, baseY: 83, cursorY: 0 });
|
||||
expect(find(parked)).toEqual({ row: 36, col: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Clicking a link to another heading of the SAME markdown file in the File Viewer must scroll to
|
||||
* that heading. It did nothing: marked emits no heading ids, and with `<base href="/">` a bare
|
||||
* `#section` href is not an in-page link anyway. Real xterm-free browser, real preview overlay,
|
||||
* real click.
|
||||
*
|
||||
* Browser-driven, so excluded from `npm run test:ci` (config/test-suites.ts). Run locally:
|
||||
* npm run test:browser -- test/markdown-anchor-links.browser.test.ts
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const filler = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => `Paragraph ${i} of filler text so the document is taller than the viewer.`).join(
|
||||
'\n\n'
|
||||
);
|
||||
|
||||
describe('in-document links in the markdown File Viewer', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'md-anchors-'));
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'README.md'),
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'[Usage](#usage) · [Second install](#install-1) · [Mixed case](#My-Title) · [Encoded](#my%20title) · [Nowhere](#nope) · [Top](#)',
|
||||
'',
|
||||
'## Install',
|
||||
filler(30),
|
||||
'## Usage',
|
||||
filler(30),
|
||||
'## Install',
|
||||
filler(30),
|
||||
'## My Title',
|
||||
filler(30),
|
||||
'## Settings',
|
||||
filler(10),
|
||||
].join('\n')
|
||||
);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await (await browser.newContext({ viewport: { width: 1100, height: 700 } })).newPage();
|
||||
// Reduced motion makes the scroll instant (the code uses behavior 'auto' for it), so the
|
||||
// position can be read right after the click instead of racing a smooth-scroll animation.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
const sessionId = await page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, root);
|
||||
await page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, sessionId);
|
||||
await page.evaluate(({ sid, path }) => (window as any).app.openFilePreview(path, sid), {
|
||||
sid: sessionId,
|
||||
path: join(root, 'README.md'),
|
||||
});
|
||||
await page.waitForSelector('#filePreviewBody .file-preview-md h2');
|
||||
// A marker that a full navigation would wipe.
|
||||
await page.evaluate(() => ((window as any).__stillHere = true));
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
const state = () =>
|
||||
page.evaluate(() => {
|
||||
const body = document.getElementById('filePreviewBody')!;
|
||||
return {
|
||||
scrollTop: body.scrollTop,
|
||||
stillHere: (window as any).__stillHere === true,
|
||||
url: location.href,
|
||||
bodyTop: body.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
/** How far the nth heading's top is from the top of the scrolling viewer, in px. */
|
||||
const headingOffset = (text: string, nth = 0) =>
|
||||
page.evaluate(
|
||||
({ text, nth }) => {
|
||||
const body = document.getElementById('filePreviewBody')!;
|
||||
const hs = [...body.querySelectorAll('.file-preview-md h1, .file-preview-md h2')].filter(
|
||||
(h) => h.textContent === text
|
||||
);
|
||||
return Math.round(hs[nth].getBoundingClientRect().top - body.getBoundingClientRect().top);
|
||||
},
|
||||
{ text, nth }
|
||||
);
|
||||
const click = async (name: string) => {
|
||||
// A DOM click: Playwright's own click scrolls the link into view first, which would move the
|
||||
// very scroll position these tests measure.
|
||||
await page.locator(`#filePreviewBody a:text-is("${name}")`).evaluate((a) => (a as HTMLElement).click());
|
||||
await page.waitForTimeout(100);
|
||||
};
|
||||
|
||||
it('scrolls to the heading a link names, and stays on the page', async () => {
|
||||
const before = await state();
|
||||
expect(before.scrollTop).toBe(0);
|
||||
await click('Usage');
|
||||
const after = await state();
|
||||
expect(after.scrollTop).toBeGreaterThan(500);
|
||||
expect(Math.abs(await headingOffset('Usage'))).toBeLessThan(60); // the heading is at the top of the viewer
|
||||
expect(after.stillHere).toBe(true); // no navigation
|
||||
expect(after.url).toBe(before.url);
|
||||
});
|
||||
|
||||
it('a repeated heading is reachable as slug-1, as on GitHub', async () => {
|
||||
await page.evaluate(() => (document.getElementById('filePreviewBody')!.scrollTop = 0));
|
||||
await click('Second install');
|
||||
expect(Math.abs(await headingOffset('Install', 1))).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and with an encoded space', async () => {
|
||||
for (const name of ['Mixed case', 'Encoded']) {
|
||||
await page.evaluate(() => (document.getElementById('filePreviewBody')!.scrollTop = 0));
|
||||
await click(name);
|
||||
expect(Math.abs(await headingOffset('My Title')), name).toBeLessThan(60);
|
||||
}
|
||||
});
|
||||
|
||||
it('a link to nowhere does nothing (no scroll, no navigation), and # goes back to the top', async () => {
|
||||
await page.evaluate(() => (document.getElementById('filePreviewBody')!.scrollTop = 0));
|
||||
await click('Nowhere');
|
||||
const stay = await state();
|
||||
expect(stay.scrollTop).toBe(0);
|
||||
expect(stay.stillHere).toBe(true);
|
||||
|
||||
await click('Usage');
|
||||
expect((await state()).scrollTop).toBeGreaterThan(500);
|
||||
await page.evaluate(() => (document.getElementById('filePreviewBody')!.scrollTop = 99999));
|
||||
await page.evaluate(() => document.querySelector('#filePreviewBody a:not([data-path])')!.scrollIntoView());
|
||||
await click('Top');
|
||||
expect((await state()).scrollTop).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('a heading cannot capture an element of the app by id (Settings heading, no id given)', async () => {
|
||||
const ids = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('#filePreviewBody h1, #filePreviewBody h2')].map((h) => h.id)
|
||||
);
|
||||
expect(ids.every((id) => id === '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* In-document links in rendered markdown (`[Install](#installation)`).
|
||||
*
|
||||
* marked emits no heading ids, and with `<base href="/">` a bare `#installation` href points at the
|
||||
* dashboard's root, so the File Viewer's links neither found a target nor stayed on the page. The
|
||||
* helpers in constants.js give headings GitHub-style slugs (as `data-md-anchor`, never `id`) and
|
||||
* resolve a fragment to a heading inside the rendered document only.
|
||||
*
|
||||
* Builds a JSDOM window in-test under the default node env (do NOT declare a per-file jsdom
|
||||
* environment: it externalizes node:fs under vite and the readFileSync below stops working).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const APP = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface Anchors {
|
||||
slug(text: string): string;
|
||||
assign(root: Element): void;
|
||||
find(root: Element, href: string): Element | null;
|
||||
}
|
||||
|
||||
function boot(html: string) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body><div id="settings">app element</div><div class="doc">${html}</div></body>`,
|
||||
{
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
}
|
||||
);
|
||||
const win = dom.window as unknown as Window & { eval(code: string): unknown; CodemanMarkdownAnchors: Anchors };
|
||||
win.eval(CONSTANTS);
|
||||
const doc = win.document.querySelector('.doc') as Element;
|
||||
return { win, doc, anchors: win.CodemanMarkdownAnchors };
|
||||
}
|
||||
|
||||
describe('markdownHeadingSlug (GitHub rules)', () => {
|
||||
const { anchors } = boot('');
|
||||
it.each([
|
||||
['Installation', 'installation'],
|
||||
['Why `codeman`?', 'why-codeman'],
|
||||
['Über uns', 'über-uns'],
|
||||
['A B', 'a--b'],
|
||||
[' Trim me ', 'trim-me'],
|
||||
['Q&A: what, why?', 'qa-what-why'],
|
||||
['snake_case and kebab-case', 'snake_case-and-kebab-case'],
|
||||
['日本語 の 見出し', '日本語-の-見出し'],
|
||||
['🚀 Launch', '-launch'],
|
||||
['', ''],
|
||||
])('%j -> %j', (text, slug) => expect(anchors.slug(text)).toBe(slug));
|
||||
});
|
||||
|
||||
describe('assign + find', () => {
|
||||
const html = `
|
||||
<h1>Overview</h1><p>x</p>
|
||||
<h2>Install</h2><h2>Usage</h2><h3>Install</h3><h3>Install</h3>
|
||||
<h2>My Title</h2><a id="custom-spot"></a>`;
|
||||
|
||||
it('slugs every heading, and a repeated title gets -1, -2 like GitHub', () => {
|
||||
const { doc, anchors } = boot(html);
|
||||
anchors.assign(doc);
|
||||
const slugs = [...doc.querySelectorAll('h1, h2, h3')].map((h) => (h as HTMLElement).dataset.mdAnchor);
|
||||
expect(slugs).toEqual(['overview', 'install', 'usage', 'install-1', 'install-2', 'my-title']);
|
||||
anchors.assign(doc); // idempotent
|
||||
expect([...doc.querySelectorAll('h1, h2, h3')].map((h) => (h as HTMLElement).dataset.mdAnchor)).toEqual(slugs);
|
||||
});
|
||||
|
||||
it('finds a heading by its slug, case-insensitively, percent-encoded or with a space', () => {
|
||||
const { doc, anchors } = boot(html);
|
||||
const title = doc.querySelectorAll('h2')[2];
|
||||
for (const href of ['#my-title', '#My-Title', '#my%20title', '#My Title']) {
|
||||
expect(anchors.find(doc, href), href).toBe(title);
|
||||
}
|
||||
expect(anchors.find(doc, '#install-2')?.tagName).toBe('H3');
|
||||
});
|
||||
|
||||
it('finds an explicit id the author wrote, and treats # as the top of the document', () => {
|
||||
const { doc, anchors } = boot(html);
|
||||
expect(anchors.find(doc, '#custom-spot')?.id).toBe('custom-spot');
|
||||
expect(anchors.find(doc, '#')).toBe(doc);
|
||||
expect(anchors.find(doc, '')).toBe(doc);
|
||||
});
|
||||
|
||||
it('returns null for a fragment that matches nothing, and a malformed escape is used as written', () => {
|
||||
const { doc, anchors } = boot(html);
|
||||
expect(anchors.find(doc, '#nope')).toBeNull();
|
||||
expect(() => anchors.find(doc, '#%E0%A4%A')).not.toThrow();
|
||||
});
|
||||
|
||||
it('never resolves against the app: a heading cannot claim an element id outside the document', () => {
|
||||
const { doc, anchors } = boot('<h1>Settings</h1>');
|
||||
const found = anchors.find(doc, '#settings');
|
||||
expect(found?.tagName).toBe('H1'); // the heading, not <div id="settings"> in the app
|
||||
expect(found?.id).toBe(''); // and the heading was not given an id
|
||||
});
|
||||
});
|
||||
|
||||
describe('the click delegate', () => {
|
||||
it('handles fragment links before anything that would let the browser follow them', () => {
|
||||
const delegate = APP.slice(APP.indexOf('_bindResponseViewerInteractions(body) {'));
|
||||
const fragment = delegate.indexOf('a[href^="#"]');
|
||||
expect(fragment).toBeGreaterThan(-1);
|
||||
// After file-path links (their href is '#', they open the preview) and before the loopback/new-tab handling.
|
||||
expect(fragment).toBeGreaterThan(delegate.indexOf("closest('a.rv-path')"));
|
||||
expect(fragment).toBeLessThan(delegate.indexOf('openLinkThroughWebTabIfLoopback'));
|
||||
expect(delegate.slice(fragment, fragment + 400)).toContain('preventDefault');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment node
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { binaryOnPath, MCP_SYNC_ONLY_TOOLS, mcpSyncOnlyTargets } from '../src/mcp-sync-targets.js';
|
||||
import { mcpSyncTargets } from '../src/web/routes/mcp-sync-routes.js';
|
||||
|
||||
describe('sync-only MCP targets (tools that are not Codeman run modes)', () => {
|
||||
it('declares GitHub Copilot CLI with its config file, dialect and relocation var', () => {
|
||||
const t = mcpSyncOnlyTargets(new Set(), () => true).find((x) => x.id === 'copilot')!;
|
||||
expect(t).toMatchObject({
|
||||
label: 'GitHub Copilot CLI',
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
installed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('asks about the declared binary, and reports it not installed when it is missing', () => {
|
||||
const asked: string[] = [];
|
||||
const t = mcpSyncOnlyTargets(new Set(), (b) => (asked.push(b), false));
|
||||
expect(asked).toEqual(MCP_SYNC_ONLY_TOOLS.map((x) => x.binary));
|
||||
expect(t.every((x) => x.installed === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('yields to a registry CLI that already uses the id', () => {
|
||||
expect(mcpSyncOnlyTargets(new Set(['copilot']), () => true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('comes after the registry CLIs in the targets the route syncs', () => {
|
||||
const ids = mcpSyncTargets({}).map((t) => t.id);
|
||||
expect(ids).toContain('copilot');
|
||||
expect(ids.indexOf('copilot')).toBe(ids.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('binaryOnPath', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'bin-on-path-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('finds an executable in PATH and ignores a non-executable file of the same name', () => {
|
||||
writeFileSync(join(dir, 'fake-tool'), '#!/bin/sh\n');
|
||||
chmodSync(join(dir, 'fake-tool'), 0o644);
|
||||
expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(false);
|
||||
chmodSync(join(dir, 'fake-tool'), 0o755);
|
||||
expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(true);
|
||||
expect(binaryOnPath('no-such-tool-xyz', { PATH: dir })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -585,3 +585,154 @@ describe('relocated config dirs (the CLI reads its file somewhere else)', () =>
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Copilot CLI (copilot-json)', () => {
|
||||
const COPILOT: McpSyncTarget = {
|
||||
id: 'copilot',
|
||||
label: 'GitHub Copilot CLI',
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
installed: true,
|
||||
};
|
||||
const withCopilot = (...ids: string[]) => [...only(...ids), COPILOT];
|
||||
|
||||
// Captured from `copilot mcp add` (1.0.94) into a throwaway COPILOT_HOME.
|
||||
const REAL = JSON.stringify({
|
||||
mcpServers: {
|
||||
h1: {
|
||||
tools: ['*'],
|
||||
type: 'http',
|
||||
url: 'https://example.invalid/mcp',
|
||||
headers: { Authorization: 'Bearer x', 'X-A': 'b' },
|
||||
},
|
||||
s1: { tools: ['*'], type: 'sse', url: 'https://example.invalid/sse' },
|
||||
l1: { tools: ['*'], type: 'local', command: 'node', args: ['server.js', '--flag'], env: { K: 'V', K2: 'V2' } },
|
||||
l2: { tools: ['a', 'b'], type: 'local', command: 'npx', args: ['-y', 'pkg'] },
|
||||
},
|
||||
});
|
||||
|
||||
it('reads what `copilot mcp add` writes: local, http and sse, ignoring the tools filter', () => {
|
||||
const servers = parseServers('copilot-json', REAL);
|
||||
expect(servers.h1).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://example.invalid/mcp',
|
||||
headers: { Authorization: 'Bearer x', 'X-A': 'b' },
|
||||
});
|
||||
expect(servers.s1).toEqual({ transport: 'sse', url: 'https://example.invalid/sse' });
|
||||
expect(servers.l1).toEqual({
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js', '--flag'],
|
||||
env: { K: 'V', K2: 'V2' },
|
||||
});
|
||||
expect(servers.l2).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'pkg'] });
|
||||
});
|
||||
|
||||
it('writes servers the way `copilot mcp add` does: tools ["*"], type local/http/sse', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('copilot-json', null, {
|
||||
fs: { transport: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: '1' } },
|
||||
web: { transport: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } },
|
||||
live: { transport: 'sse', url: 'https://l.test/sse' },
|
||||
})
|
||||
);
|
||||
expect(out.mcpServers.fs).toEqual({
|
||||
tools: ['*'],
|
||||
type: 'local',
|
||||
command: 'npx',
|
||||
args: ['-y', 'fs'],
|
||||
env: { T: '1' },
|
||||
});
|
||||
expect(out.mcpServers.web).toEqual({ tools: ['*'], type: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } });
|
||||
expect(out.mcpServers.live).toEqual({ tools: ['*'], type: 'sse', url: 'https://l.test/sse' });
|
||||
});
|
||||
|
||||
it('keeps an existing server, its tools filter and every other key when adding', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('copilot-json', JSON.stringify({ other: 1, mcpServers: JSON.parse(REAL).mcpServers }), {
|
||||
l2: { transport: 'stdio', command: 'changed' },
|
||||
n: { transport: 'stdio', command: 'x' },
|
||||
})
|
||||
);
|
||||
expect(out.other).toBe(1);
|
||||
expect(out.mcpServers.l2.tools).toEqual(['a', 'b']);
|
||||
expect(out.mcpServers.l2.command).toBe('npx');
|
||||
expect(out.mcpServers.n.type).toBe('local');
|
||||
});
|
||||
|
||||
it('copies between Copilot and another CLI in both directions, with sse expressed', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
put('.copilot/mcp-config.json', REAL);
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
|
||||
expect(result(r, 'copilot').added).toEqual(['fs']);
|
||||
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 'l2', 's1']);
|
||||
expect(JSON.parse(get('.copilot/mcp-config.json')).mcpServers.fs).toMatchObject({
|
||||
type: 'local',
|
||||
command: 'npx',
|
||||
tools: ['*'],
|
||||
});
|
||||
expect(JSON.parse(get('.claude.json')).mcpServers.s1).toMatchObject({ type: 'sse' });
|
||||
// second run: nothing left to do
|
||||
const again = await syncMcpServers(withCopilot('claude'), { apply: true, home });
|
||||
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not copy a server switched off with `copilot mcp disable` (it lives in settings.json)', async () => {
|
||||
put('.copilot/mcp-config.json', REAL);
|
||||
put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'], theme: 'dark' }));
|
||||
put('.claude.json', JSON.stringify({ mcpServers: {} }));
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
|
||||
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
|
||||
expect(JSON.parse(get('.claude.json')).mcpServers.l2).toBeUndefined();
|
||||
expect(r.disabled).toEqual(['l2']);
|
||||
// settings.json is only ever read
|
||||
expect(JSON.parse(get('.copilot/settings.json'))).toEqual({ disabledMcpServers: ['l2'], theme: 'dark' });
|
||||
});
|
||||
|
||||
it('a name switched off in Copilot but live in another CLI is still synced from the live one', async () => {
|
||||
put('.copilot/mcp-config.json', REAL);
|
||||
put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'] }));
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { l2: { type: 'stdio', command: 'npx', args: ['-y', 'pkg'] } } }));
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: false, home });
|
||||
expect(r.disabled).toEqual([]);
|
||||
expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
|
||||
});
|
||||
|
||||
it('reports the target unreadable, and writes nothing, when settings.json is not valid JSON', async () => {
|
||||
put('.copilot/mcp-config.json', REAL);
|
||||
put('.copilot/settings.json', '{ "disabledMcpServers": ["l2", "SECRET-NAME" ');
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
|
||||
expect(result(r, 'copilot').status).toBe('unreadable');
|
||||
expect(result(r, 'copilot').error).toContain('settings.json');
|
||||
expect(result(r, 'copilot').error).not.toContain('SECRET-NAME');
|
||||
expect(get('.copilot/mcp-config.json')).toBe(REAL);
|
||||
});
|
||||
|
||||
it('follows COPILOT_HOME and never touches ~/.copilot then', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
const copilotHome = join(home, 'elsewhere/copilot');
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home, env: { COPILOT_HOME: copilotHome } });
|
||||
expect(result(r, 'copilot').file).toBe(join(copilotHome, 'mcp-config.json'));
|
||||
expect(JSON.parse(readFileSync(join(copilotHome, 'mcp-config.json'), 'utf8')).mcpServers.fs.type).toBe('local');
|
||||
expect(existsSync(join(home, '.copilot'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is left alone when it is neither installed nor configured', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
const r = await syncMcpServers([...only('claude'), { ...COPILOT, installed: false }], { apply: true, home });
|
||||
expect(result(r, 'copilot').status).toBe('absent');
|
||||
expect(existsSync(join(home, '.copilot'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the env values of a copied server out of the result and the file private', async () => {
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { s: { type: 'stdio', command: 'x', env: { TOKEN: 'hunter2' } } } })
|
||||
);
|
||||
const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
|
||||
expect(JSON.stringify(r)).not.toContain('hunter2');
|
||||
expect(statSync(join(home, '.copilot/mcp-config.json')).mode & 0o077).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-19
@@ -44,16 +44,18 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Port | Description |
|
||||
|------|------|-------------|
|
||||
| `keyboard.test.ts` | 3200 | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
|
||||
| `tabs.test.ts` | 3201 | Tab switching, swipe navigation, keyboard nav |
|
||||
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
|
||||
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
|
||||
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
|
||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (138 devices) |
|
||||
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
|
||||
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
|
||||
Each file starts its own server on an ephemeral port (`createTestServer()`, then `server.boundPort`), so files never compete for a port.
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `keyboard.test.ts` | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
|
||||
| `tabs.test.ts` | Tab switching, swipe navigation, keyboard nav |
|
||||
| `subagent-windows.test.ts` | Mobile subagent card dimensions, stacking, interactions |
|
||||
| `settings.test.ts` | Settings modal, mobile defaults, persistence |
|
||||
| `layout.test.ts` | General mobile layout, fixed elements, device classes |
|
||||
| `device-matrix.test.ts` | Cross-device parametric tests (138 devices) |
|
||||
| `visual-regression.test.ts` | Screenshot comparison at key breakpoints |
|
||||
| `accessibility.test.ts` | WCAG touch targets, zoom, focus, ARIA |
|
||||
|
||||
## Device Matrix
|
||||
|
||||
@@ -99,7 +101,7 @@ The phone block is `max-width: 599px` and the tablet block starts at `min-width:
|
||||
|
||||
```
|
||||
Test File
|
||||
├─ helpers/server.ts → WebServer(port, false, testMode=true)
|
||||
├─ helpers/server.ts → WebServer(0, false, testMode=true), ephemeral port
|
||||
├─ helpers/browser.ts → Playwright Chromium / WebKit
|
||||
├─ helpers/cdp.ts → Chrome DevTools Protocol (Chromium only)
|
||||
├─ helpers/keyboard-sim.ts → 3-layer keyboard simulation
|
||||
@@ -144,10 +146,10 @@ Snapshots at 10 key breakpoints: 320, 375, 390, 393, 430, 440, 600, 768, 834, 10
|
||||
## Helpers Reference
|
||||
|
||||
### `helpers/constants.ts`
|
||||
All magic numbers centralized: ports, CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
|
||||
All magic numbers centralized: CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
|
||||
|
||||
### `helpers/server.ts`
|
||||
`createTestServer(port)` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true`.
|
||||
`createTestServer()` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true` on an ephemeral port; read it from `server.boundPort`.
|
||||
|
||||
### `helpers/browser.ts`
|
||||
`createDevicePage(device, url, engine?)` — creates a Playwright browser context with the device's viewport, DPR, UA, touch support, navigates to the URL.
|
||||
@@ -196,9 +198,7 @@ Unified `swipe()` / `tap()` with auto CDP/synthetic selection. Also exports per-
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
1. Pick a unique port (next: 3208+) — search `const PORT =` across test files
|
||||
2. Add the port to `helpers/constants.ts` PORTS object
|
||||
3. Use the standard test pattern:
|
||||
1. Use the standard test pattern (no port to pick: the server binds port 0):
|
||||
|
||||
```typescript
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
@@ -207,12 +207,16 @@ import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
|
||||
describe('My Test', () => {
|
||||
let server;
|
||||
beforeAll(async () => { server = await createTestServer(MY_PORT); });
|
||||
let baseUrl: string;
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
afterAll(async () => { await stopTestServer(server); await closeAllBrowsers(); });
|
||||
|
||||
it('works on phone', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
const { page, context } = await createDevicePage(device, `http://localhost:${MY_PORT}`);
|
||||
const { page, context } = await createDevicePage(device, baseUrl);
|
||||
try {
|
||||
// ... assertions ...
|
||||
} finally {
|
||||
@@ -222,7 +226,7 @@ describe('My Test', () => {
|
||||
});
|
||||
```
|
||||
|
||||
4. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
|
||||
2. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
|
||||
|
||||
## Findings Log
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Port 3207 - Mobile accessibility tests
|
||||
// Ephemeral port - Mobile accessibility tests
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import type { Page, BrowserContext } from 'playwright';
|
||||
import { PORTS, SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
|
||||
import { SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import type { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = PORTS.ACCESSIBILITY;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
|
||||
describe('Mobile Accessibility', () => {
|
||||
let server: WebServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer(PORT);
|
||||
server = await createTestServer();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -39,7 +39,7 @@ describe('Mobile Accessibility', () => {
|
||||
|
||||
it('all visible interactive elements >= 44x44px on phones', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Mobile Accessibility', () => {
|
||||
|
||||
it('all visible interactive elements >= 44x44px on tablets', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('Mobile Accessibility', () => {
|
||||
|
||||
it('keyboard accessory bar buttons meet size requirement', async () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('Mobile Accessibility', () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
});
|
||||
@@ -159,7 +159,7 @@ describe('Mobile Accessibility', () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
});
|
||||
@@ -229,7 +229,7 @@ describe('Mobile Accessibility', () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
});
|
||||
@@ -294,7 +294,7 @@ describe('Mobile Accessibility', () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
});
|
||||
@@ -357,7 +357,7 @@ describe('Mobile Accessibility', () => {
|
||||
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
const result = await createDevicePage(device, baseUrl, 'chromium');
|
||||
page = result.page;
|
||||
context = result.context;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user