mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffaa5ee80c | ||
|
|
6aecc3b858 | ||
|
|
470cf79776 | ||
|
|
06c4c7da16 | ||
|
|
7917273188 | ||
|
|
b451b3851e | ||
|
|
6d6e7da481 | ||
|
|
52267f8617 | ||
|
|
36af183f97 | ||
|
|
c029cea620 | ||
|
|
b8038a592c | ||
|
|
ccd6df893f | ||
|
|
e082d8e438 | ||
|
|
fa53a5751e | ||
|
|
23d145b121 | ||
|
|
5724e0c8b4 | ||
|
|
9649b5019b | ||
|
|
ec2a036543 | ||
|
|
c197e9370b | ||
|
|
d887002ca8 | ||
|
|
574f3db58b | ||
|
|
17a976fa2e | ||
|
|
2cf37529e9 | ||
|
|
df398c5c68 | ||
|
|
d68a173a23 | ||
|
|
0ff57ce304 | ||
|
|
f39c66e4e8 | ||
|
|
4398dbfad0 | ||
|
|
c9a5fdab00 | ||
|
|
7616de13de | ||
|
|
af032fc81a | ||
|
|
41a10b159e | ||
|
|
e6b258fc44 | ||
|
|
98c6c1881d | ||
|
|
7cbce5bf6c | ||
|
|
3df113fc54 | ||
|
|
0ae39cdd94 | ||
|
|
45db24bacf | ||
|
|
4123d229f4 | ||
|
|
ee1a155e2c | ||
|
|
2d96472dbe | ||
|
|
e0542bb172 |
@@ -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.33.3",
|
||||
"version": "1.34.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 1.33.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -78,7 +78,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.33.3 (must match `package.json`)
|
||||
**Version**: 1.34.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -137,8 +137,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`. Tests: `test/advisor-model.test.ts`
|
||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||
@@ -245,6 +245,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
@@ -255,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
@@ -320,7 +322,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
@@ -368,7 +370,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
@@ -380,7 +382,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
@@ -431,7 +433,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -31,8 +32,12 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -713,6 +713,41 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
|
||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||
|
||||
## MCP server sync
|
||||
|
||||
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
|
||||
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
|
||||
|
||||
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).
|
||||
- `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`.
|
||||
- `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).
|
||||
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
|
||||
|
||||
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||
|
||||
## Webhook notifications
|
||||
|
||||
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | -------------------- | -------------------------------------------- | ----- |
|
||||
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
|
||||
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
|
||||
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
|
||||
|
||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -118,6 +118,10 @@ sure its row is one the agent cannot write.
|
||||
|
||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||
|
||||
## The newline chord
|
||||
|
||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||
|
||||
## Arg-template safety
|
||||
|
||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||
@@ -249,6 +253,14 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||
|
||||
## 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`.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
## See also
|
||||
|
||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||
|
||||
@@ -532,6 +532,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
|
||||
---
|
||||
|
||||
## 10c. Webhook notifications (outbound channel)
|
||||
|
||||
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||
curl -s "$API/api/subagents" | jq # background agents
|
||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||
|
||||
# with ID set to a session id:
|
||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||
|
||||
@@ -6,15 +6,16 @@ opening the session.
|
||||
|
||||
## The signals, cheapest first
|
||||
|
||||
| Surface | Reaches you | Default |
|
||||
| ---------------------- | ------------------------------------------------- | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
| Surface | Reaches you | Default |
|
||||
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
|
||||
## Tab alerts
|
||||
|
||||
@@ -60,6 +61,51 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||
and for a team channel.
|
||||
|
||||
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||
|
||||
Setup, in **App Settings → Notifications → Webhook**:
|
||||
|
||||
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||
sessionId, sessionName, host, at }`.
|
||||
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||
Send test saves anything you changed first, so it always tests what is on screen.
|
||||
|
||||
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||
(an HTTP status, a timeout, a refused connection).
|
||||
|
||||
Behaviour worth knowing:
|
||||
|
||||
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||
(which also turns the channel off).
|
||||
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||
the only thing keeping strangers out.
|
||||
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||
message is sent, and redirects are not followed.
|
||||
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||
once, so a flapping prompt cannot flood a channel.
|
||||
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||
the webhook, and it receives every user's session events (session names, tool names, error
|
||||
text). Point it somewhere every user would be comfortable with.
|
||||
|
||||
## The Approvals Inbox
|
||||
|
||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||
@@ -152,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in
|
||||
## Recommended setup for unattended runs
|
||||
|
||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||
webhook to ntfy if no browser will ever be open.
|
||||
3. Approvals Inbox on.
|
||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||
[Keeping Agents Running](Keeping-Agents-Running).
|
||||
@@ -162,7 +209,8 @@ from the lock screen.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||
has no such requirement, since the server sends it.
|
||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
||||
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
||||
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||
|
||||
@@ -50,6 +50,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
|
||||
|
||||
### Header & Panels
|
||||
|
||||
@@ -121,11 +122,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
||||
| 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. |
|
||||
|
||||
### Notifications
|
||||
|
||||
Master toggle, browser notifications, push subscription, audio alerts, and the idle
|
||||
threshold that decides when a quiet session counts as needing you. See
|
||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
|
||||
Generated
+16
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.3",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.3",
|
||||
"version": "1.34.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -32,6 +32,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"smol-toml": "^1.9.0",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -10114,6 +10115,18 @@
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
@@ -12372,7 +12385,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.3",
|
||||
"version": "1.34.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -105,6 +105,7 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"smol-toml": "^1.9.0",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -106,10 +106,10 @@ terminal.onData((data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
|
||||
terminal.onWriteParsed(() => {
|
||||
if (zerolag.hasPending) zerolag.rerender();
|
||||
});
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
|
||||
// Unconditional: rerender() is a no-op when there is nothing to draw, and
|
||||
// hasPending would miss an overlay that shows only an IME composition.
|
||||
terminal.onWriteParsed(() => zerolag.rerender());
|
||||
```
|
||||
|
||||
That is the whole integration. Everything below is for tuning it.
|
||||
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
|
||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||
```
|
||||
|
||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|--------|---------|-------------|
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||
|
||||
### Backspace handling
|
||||
|
||||
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|
||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||
|
||||
### IME composition
|
||||
|
||||
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
|
||||
|
||||
```typescript
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
|
||||
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
|
||||
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
|
||||
```
|
||||
|
||||
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
|
||||
|
||||
### Flushed text
|
||||
|
||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt
|
||||
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
||||
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
|
||||
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"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",
|
||||
|
||||
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
lines,
|
||||
compositionStart,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||
// dropped along with that line when the tail is all that fits.
|
||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineOffsets.push(offset);
|
||||
offset += [...line].length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
let firstVisible = 0;
|
||||
let keepsPromptLine = true;
|
||||
let topRow = promptRow;
|
||||
if (rows && rows > 0) {
|
||||
if (lines.length > rows) {
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
firstVisible = lines.length - rows;
|
||||
visibleLines = lines.slice(firstVisible);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
topPx,
|
||||
widthPx,
|
||||
cellH,
|
||||
cellW,
|
||||
charTop,
|
||||
charHeight,
|
||||
font,
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -156,7 +186,8 @@ function makeLine(
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null
|
||||
terminal?: XtermTerminal | null,
|
||||
compositionFrom?: number
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
@@ -172,6 +203,7 @@ function makeLine(
|
||||
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
@@ -189,9 +221,15 @@ function makeLine(
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
index++;
|
||||
}
|
||||
|
||||
return el;
|
||||
|
||||
@@ -163,6 +163,12 @@ export interface CellDimensions {
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
/**
|
||||
* Index (in code points, across all `lines`) where IME composition text
|
||||
* begins. Characters from there on are drawn underlined and marked
|
||||
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||
*/
|
||||
compositionStart?: number;
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
|
||||
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
// IME text still being composed: drawn after the pending text, never sent.
|
||||
private _composition = '';
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
} else if (this._hasContent()) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
@@ -206,8 +208,14 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
||||
* The consumer SHOULD send backspace to the PTY.
|
||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||
*
|
||||
* Any IME composition is dropped in every case, and the overlay is repainted
|
||||
* without it (hidden when nothing else is left).
|
||||
*/
|
||||
removeChar(): 'pending' | 'flushed' | false {
|
||||
// A backspace that reaches the overlay means no composition is open.
|
||||
const droppedComposition = this._composition.length > 0;
|
||||
this._composition = '';
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
@@ -243,6 +251,9 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return 'flushed';
|
||||
}
|
||||
|
||||
// Nothing to remove, but a composition-only overlay is still on screen
|
||||
// drawing the text dropped above.
|
||||
if (droppedComposition) this._hide();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -252,6 +263,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._composition = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
@@ -297,7 +309,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText) {
|
||||
if (this._pendingText || this._composition) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
@@ -312,7 +324,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._hasContent()) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
@@ -325,7 +337,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
@@ -391,7 +403,37 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
this._options.prompt = finder;
|
||||
this._lastPromptPos = null;
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── IME composition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show text an IME is still composing as an underlined tail after the
|
||||
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||
* Pass `''` to remove it.
|
||||
*
|
||||
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||
* composition. `clear()` and `removeChar()` drop it too.
|
||||
*/
|
||||
setComposition(text: string): void {
|
||||
// One visual line of provisional text: control characters and line breaks
|
||||
// would break the cell grid.
|
||||
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||
if (next === this._composition) return;
|
||||
this._composition = next;
|
||||
if (this._hasContent()) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||
get composition(): string {
|
||||
return this._composition;
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
@@ -425,7 +467,13 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
/**
|
||||
* Whether there is pending or flushed text. Excludes the IME composition,
|
||||
* which is never sent, so an overlay showing only a composition reports
|
||||
* `false` while still on screen. To re-place the overlay after output or a
|
||||
* resize, call `rerender()` unconditionally: it is a no-op when there is
|
||||
* nothing to draw.
|
||||
*/
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
@@ -443,6 +491,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _hasContent(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||
}
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
@@ -505,7 +557,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
if (!this._hasContent()) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -563,12 +615,16 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
}
|
||||
}
|
||||
|
||||
// The composition is a styled tail after everything the user has typed.
|
||||
const compositionStart = [...displayText].length;
|
||||
displayText += this._composition;
|
||||
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -608,6 +664,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
compositionStart: this._composition ? compositionStart : undefined,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||
// after the pending text. Visual only, never part of what a consumer sends.
|
||||
|
||||
const CELL_W = 10;
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: opts.lines ?? ['$ '] },
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cellWidth: CELL_W,
|
||||
cellHeight: 20,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||||
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||||
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
}
|
||||
|
||||
function lineText(line: HTMLDivElement): string {
|
||||
return Array.from(line.children)
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function compositionText(overlay: HTMLDivElement): string {
|
||||
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('setComposition', () => {
|
||||
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
|
||||
const [line] = lineDivs(overlay);
|
||||
expect(lineText(line)).toBe('abcxy');
|
||||
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||||
for (const span of spans.slice(0, 3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||||
expect(span.style.textDecoration).toBe('');
|
||||
}
|
||||
for (const span of spans.slice(3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||||
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
}
|
||||
// Grid positions continue straight on from the pending text.
|
||||
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||
expect(overlay.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('places a wide composition by cell width after wide pending text', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('今日は');
|
||||
addon.setComposition('天気');
|
||||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||
});
|
||||
|
||||
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||
const { addon } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setFlushed(2, 'zz');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||||
expect(addon.composition).toBe('xy');
|
||||
expect(addon.state.pendingText).toBe('abc');
|
||||
expect(addon.state.flushedText).toBe('zz');
|
||||
});
|
||||
|
||||
it('shows on an empty prompt without making anything pending', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('かな');
|
||||
expect(addon.pendingText).toBe('');
|
||||
expect(addon.hasPending).toBe(false);
|
||||
expect(addon.state.visible).toBe(true);
|
||||
expect(compositionText(overlay)).toBe('かな');
|
||||
});
|
||||
|
||||
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||||
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||||
const { addon, overlay } = setup({ cols: 12 });
|
||||
addon.appendText('abcdefgh');
|
||||
addon.setComposition('WXYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||||
expect(compositionText(overlay)).toBe('WXYZ');
|
||||
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||
expect(second[0].style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||||
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||||
addon.appendText('abcdefghij');
|
||||
addon.setComposition('XYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||||
expect(compositionText(overlay)).toBe('XYZ');
|
||||
const first = Array.from(lines[0].children);
|
||||
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||||
});
|
||||
|
||||
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
});
|
||||
|
||||
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(overlay.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.clear();
|
||||
expect(addon.composition).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
addon.addChar('q');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||||
});
|
||||
|
||||
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('pending');
|
||||
expect(addon.pendingText).toBe('ab');
|
||||
expect(addon.composition).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('ka');
|
||||
expect(compositionText(overlay)).toBe('ka');
|
||||
expect(addon.removeChar()).toBe(false);
|
||||
expect(addon.composition).toBe('');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(addon.state.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setFlushed(3, 'abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('flushed');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('text appended while composing lands before the tail', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
addon.addChar('c');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
});
|
||||
|
||||
it('rerender() and refreshFont() keep the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.rerender();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
addon.refreshFont();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('re-renders when only the composition changes', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('x');
|
||||
addon.setComposition('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('strips control characters and line breaks from the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('a\nb\u0007c
');
|
||||
expect(addon.composition).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('abc');
|
||||
});
|
||||
|
||||
it('draws the block cursor after the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||
// prompt col 0 + offset 2 + 4 cells
|
||||
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||||
});
|
||||
});
|
||||
@@ -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.33.3",
|
||||
"version": "1.34.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -391,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`). Three differences that break copied code:
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`session-routes.ts:648`).
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -83,9 +83,11 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||
@@ -111,8 +113,10 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'mobile-ime-preview.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'tab-layout-browser.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
'terminal-ui.js',
|
||||
|
||||
@@ -391,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`). Three differences that break copied code:
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`session-routes.ts:648`).
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -27,6 +28,14 @@ const envName = z
|
||||
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
|
||||
.max(64);
|
||||
|
||||
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
|
||||
const mcpRelativePath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
|
||||
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
|
||||
|
||||
/**
|
||||
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
|
||||
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
|
||||
@@ -377,6 +386,26 @@ const capabilitiesSchema = z
|
||||
privilegedEnvKeys: z.array(envName).max(8),
|
||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||
maxFrameBytes: z.number().int().positive().optional(),
|
||||
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||
mcpConfig: z
|
||||
.object({
|
||||
// Home-relative, no traversal: sync writes to this path.
|
||||
path: mcpRelativePath,
|
||||
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||
format: z.enum([
|
||||
'claude-json',
|
||||
'gemini-json',
|
||||
'codex-toml',
|
||||
'opencode-json',
|
||||
'antigravity-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.
|
||||
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
customModelInjection: z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { CliEntry } from './types.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
|
||||
|
||||
const HOME_DIRS = {
|
||||
local: '~/.local/bin',
|
||||
@@ -306,6 +307,12 @@ const CLAUDE: CliEntry = {
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||
mcpConfig: {
|
||||
path: '.claude.json',
|
||||
format: 'claude-json',
|
||||
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||
},
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||
// llama.cpp server. Claude reads these at process start only, so switching requires a
|
||||
// respawn, never a live hot-swap.
|
||||
@@ -486,6 +493,12 @@ const OPENCODE: CliEntry = {
|
||||
...agentDefaults(),
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||
mcpConfig: {
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
},
|
||||
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
|
||||
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
|
||||
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
|
||||
@@ -532,6 +545,7 @@ const CODEX: CliEntry = {
|
||||
bypassApprovals: { type: 'bool' },
|
||||
animations: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
|
||||
resumeId: { type: 'token', pattern: 'id' },
|
||||
},
|
||||
variants: [
|
||||
@@ -543,6 +557,14 @@ const CODEX: CliEntry = {
|
||||
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
|
||||
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
|
||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||
// One literal per level: an argv token cannot splice a value into a literal, and
|
||||
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
|
||||
// what admits a level, so an unknown one emits nothing.
|
||||
...CODEX_REASONING_EFFORTS.map((level) => ({
|
||||
flag: '--config',
|
||||
value: `model_reasoning_effort=${level}`,
|
||||
when: { param: 'reasoningEffort', is: level },
|
||||
})),
|
||||
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
|
||||
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||
],
|
||||
@@ -620,6 +642,11 @@ const CODEX: CliEntry = {
|
||||
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
|
||||
// regression; `schema.ts` now rejects a name that is not a declared param.
|
||||
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
|
||||
mcpConfig: {
|
||||
path: '.codex/config.toml',
|
||||
format: 'codex-toml',
|
||||
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||
},
|
||||
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
|
||||
// so the user's real ~/.codex/config.toml is never touched.
|
||||
customModelInjection: {
|
||||
@@ -721,6 +748,12 @@ const GEMINI: CliEntry = {
|
||||
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
|
||||
// sends no geminiConfig at all would still get yolo for free.
|
||||
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
|
||||
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
|
||||
mcpConfig: {
|
||||
path: '.gemini/settings.json',
|
||||
format: 'gemini-json',
|
||||
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||
},
|
||||
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
|
||||
// start). Confirm the exact model-override env var name against the installed
|
||||
// gemini-cli version before shipping.
|
||||
@@ -800,6 +833,8 @@ const ANTIGRAVITY: CliEntry = {
|
||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||
// SENT config needs the flag forced off — nothing is materialized.
|
||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
|
||||
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
|
||||
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
|
||||
// custom-endpoint setting and explicitly say it "cannot currently" become the core
|
||||
// reasoning model. Toolbar entry stays disabled for this mode.
|
||||
|
||||
@@ -90,6 +90,12 @@ export interface CliVariant {
|
||||
args: ArgSpec[];
|
||||
}
|
||||
|
||||
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
|
||||
export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||
|
||||
/** 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 interface CliLaunch {
|
||||
params: Record<string, ParamSpec>;
|
||||
/**
|
||||
@@ -511,6 +517,28 @@ export interface CliCapabilities {
|
||||
gates: Record<string, { minVersion: string; failClosed: boolean }>;
|
||||
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
|
||||
maxFrameBytes?: number;
|
||||
/**
|
||||
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
|
||||
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
|
||||
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
|
||||
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
|
||||
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
|
||||
*/
|
||||
newline?: NewlineSequence;
|
||||
/**
|
||||
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
|
||||
* `path` is relative to the home directory. `format` names the file dialect the sync
|
||||
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
|
||||
* skipped by sync rather than guessed at.
|
||||
*
|
||||
* `relocation` names the env var the CLI itself reads to move that file (codex's
|
||||
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
|
||||
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
|
||||
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
|
||||
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||
* only follows `$HOME`.
|
||||
*/
|
||||
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||
/**
|
||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||
|
||||
+5
-1
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
||||
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
||||
// windows in which Claude Code could run an empty or non-executable file.
|
||||
// rename() swaps the complete, already-executable file in atomically.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
|
||||
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
|
||||
// name let the first rename consume the others' temp file, and their ENOENT
|
||||
// dropped those sessions from tmux to the direct-PTY fallback.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
await writeFile(tmpPath, desired);
|
||||
await chmod(tmpPath, 0o755);
|
||||
await rename(tmpPath, scriptPath);
|
||||
|
||||
+679
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @fileoverview MCP server sync between the enabled agent CLIs.
|
||||
*
|
||||
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
|
||||
* names the file and the dialect). This module reads every participating CLI's list into one
|
||||
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
|
||||
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
|
||||
* own user config.
|
||||
*
|
||||
* Deliberately conservative:
|
||||
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
|
||||
* 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
|
||||
* 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
|
||||
* parsed again and every added server comes back as intended.
|
||||
* - Only the MCP table is touched; every other key in the file is preserved. Files are
|
||||
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
|
||||
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
|
||||
* (overwritten by each sync).
|
||||
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
|
||||
* readable by its owner only.
|
||||
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
|
||||
* - Only one apply runs at a time.
|
||||
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
|
||||
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
|
||||
* cannot be located safely, so that target is reported `skipped` and never written.
|
||||
*
|
||||
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
|
||||
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
|
||||
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
|
||||
*
|
||||
* @module mcp-sync
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
||||
|
||||
export type McpFormat = McpConfigFormat;
|
||||
|
||||
export interface McpServer {
|
||||
transport: 'stdio' | 'http' | 'sse';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** Switched off in the CLI that defines it. Never propagated. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type McpServerMap = Record<string, McpServer>;
|
||||
|
||||
export interface McpSyncTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Home-relative default location of the config file. */
|
||||
path: string;
|
||||
format: McpFormat;
|
||||
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
||||
relocation?: { envVar: string; path: string };
|
||||
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
/** A second apply was requested while one was running. */
|
||||
export class McpSyncBusyError extends Error {
|
||||
constructor() {
|
||||
super('An MCP sync is already running');
|
||||
this.name = 'McpSyncBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error whose message this module wrote itself. It names keys Codeman chose and server names
|
||||
* (which the result reports anyway), never a value from the file, so it may be shown as is.
|
||||
*/
|
||||
class McpConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'McpConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
|
||||
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
|
||||
* "Unexpected token" errors quote about ten characters of source. These files hold env values
|
||||
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
|
||||
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
|
||||
* else by a fixed category.
|
||||
*/
|
||||
function describeMcpSyncError(err: unknown): string {
|
||||
if (err instanceof McpConfigError) return err.message;
|
||||
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
|
||||
if (err instanceof SyntaxError) {
|
||||
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
|
||||
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
|
||||
const pos = /at position (\d+)/.exec(err.message);
|
||||
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
|
||||
return 'unexpected error';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
|
||||
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
|
||||
function dict<T>(): Record<string, T> {
|
||||
return Object.create(null) as Record<string, T>;
|
||||
}
|
||||
|
||||
/** Own, safe keys of an untrusted table. */
|
||||
function safeKeys(table: Record<string, unknown>): string[] {
|
||||
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
|
||||
}
|
||||
|
||||
function strMap(v: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(v)) return undefined;
|
||||
const out = dict<string>();
|
||||
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function strArr(v: unknown): string[] | undefined {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
|
||||
}
|
||||
|
||||
/** Drop undefined/empty fields so equal servers compare equal. */
|
||||
function clean(s: McpServer): McpServer {
|
||||
const out: McpServer = { transport: s.transport };
|
||||
if (s.command) out.command = s.command;
|
||||
if (s.args?.length) out.args = s.args;
|
||||
if (s.env && Object.keys(s.env).length) out.env = s.env;
|
||||
if (s.cwd) out.cwd = s.cwd;
|
||||
if (s.url) out.url = s.url;
|
||||
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
|
||||
if (s.disabled) out.disabled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
|
||||
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
|
||||
function fingerprint(s: McpServer): string {
|
||||
const t = s.transport === 'stdio' ? 'stdio' : 'url';
|
||||
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
|
||||
}
|
||||
|
||||
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
|
||||
function fullIdentity(s: McpServer): string {
|
||||
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
|
||||
}
|
||||
|
||||
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON dialects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fromClaude(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const type = raw.type;
|
||||
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
|
||||
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toClaude(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
|
||||
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
|
||||
}
|
||||
|
||||
function fromGemini(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
||||
// today, and a bare `url` with no type is the legacy SSE form.
|
||||
if (typeof raw.httpUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
||||
if (typeof raw.url === 'string') {
|
||||
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: raw.command,
|
||||
args: strArr(raw.args),
|
||||
env: strMap(raw.env),
|
||||
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGemini(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') {
|
||||
return {
|
||||
command: s.command,
|
||||
args: s.args ?? [],
|
||||
...(s.env ? { env: s.env } : {}),
|
||||
...(s.cwd ? { cwd: s.cwd } : {}),
|
||||
};
|
||||
}
|
||||
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
|
||||
}
|
||||
|
||||
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
|
||||
function fromAntigravity(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.disabled === true;
|
||||
if (typeof raw.serverUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: raw.command,
|
||||
args: strArr(raw.args),
|
||||
env: strMap(raw.env),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||
if (s.transport === 'sse') return null;
|
||||
if (s.transport === 'stdio') {
|
||||
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
|
||||
}
|
||||
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
|
||||
}
|
||||
|
||||
function fromOpencode(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.enabled === false;
|
||||
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||
}
|
||||
if (raw.type === 'local') {
|
||||
const cmd = strArr(raw.command);
|
||||
if (!cmd?.length) return null;
|
||||
return clean({
|
||||
transport: 'stdio',
|
||||
command: cmd[0],
|
||||
args: cmd.slice(1),
|
||||
env: strMap(raw.environment),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toOpencode(s: McpServer): Record<string, unknown> {
|
||||
if (s.transport === 'stdio') {
|
||||
return {
|
||||
type: 'local',
|
||||
command: [s.command, ...(s.args ?? [])],
|
||||
...(s.env ? { environment: s.env } : {}),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
|
||||
}
|
||||
|
||||
interface JsonDialect {
|
||||
/** Key holding the server table. */
|
||||
key: string;
|
||||
from(raw: unknown): McpServer | null;
|
||||
to(s: McpServer): Record<string, unknown> | null;
|
||||
/** Top-level keys to seed when creating the file from nothing. */
|
||||
seed?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 },
|
||||
'opencode-json': {
|
||||
key: 'mcp',
|
||||
from: fromOpencode,
|
||||
to: toOpencode,
|
||||
seed: { $schema: 'https://opencode.ai/config.json' },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codex TOML (the `[mcp_servers.*]` tables only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fromCodex(t: Record<string, unknown>): McpServer | null {
|
||||
const disabled = t.enabled === false;
|
||||
if (typeof t.url === 'string') {
|
||||
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
|
||||
}
|
||||
if (typeof t.command === 'string') {
|
||||
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const tomlStr = (v: string): string => JSON.stringify(v);
|
||||
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
|
||||
|
||||
function toCodexToml(name: string, s: McpServer): string {
|
||||
const head = `[mcp_servers.${tomlKey(name)}]`;
|
||||
const lines = [head];
|
||||
if (s.transport === 'stdio') {
|
||||
lines.push(`command = ${tomlStr(s.command ?? '')}`);
|
||||
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
|
||||
if (s.env) {
|
||||
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
|
||||
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`url = ${tomlStr(s.url ?? '')}`);
|
||||
if (s.headers) {
|
||||
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
|
||||
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialect entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedConfig {
|
||||
/** Servers this module understands. */
|
||||
servers: McpServerMap;
|
||||
/** Every name defined under the MCP table, in any shape: these are never appended over. */
|
||||
names: Set<string>;
|
||||
}
|
||||
|
||||
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
|
||||
if (text === null || !text.trim()) return dict<unknown>();
|
||||
if (format === 'codex-toml') {
|
||||
const doc = parseToml(text);
|
||||
const table = doc.mcp_servers;
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
|
||||
return table;
|
||||
}
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: unknown = JSON.parse(text);
|
||||
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
|
||||
const table = doc[dialect.key];
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
|
||||
const table = mcpTable(format, text);
|
||||
const servers = dict<McpServer>();
|
||||
const names = new Set<string>();
|
||||
for (const name of safeKeys(table)) {
|
||||
names.add(name);
|
||||
const raw = table[name];
|
||||
const s =
|
||||
format === 'codex-toml'
|
||||
? isRecord(raw)
|
||||
? fromCodex(raw)
|
||||
: null
|
||||
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
|
||||
if (s) servers[name] = s;
|
||||
}
|
||||
return { servers, names };
|
||||
}
|
||||
|
||||
/** The servers of a config file's text. */
|
||||
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
|
||||
return parseConfig(format, text).servers;
|
||||
}
|
||||
|
||||
/** Whether this dialect can express the server. */
|
||||
function canExpress(format: McpFormat, s: McpServer): boolean {
|
||||
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add servers to a config file's text and return the new text. A name already defined under the
|
||||
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
|
||||
* come back as intended, otherwise this throws and nothing should be written.
|
||||
*/
|
||||
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
|
||||
const before = parseConfig(format, text);
|
||||
const todo = dict<McpServer>();
|
||||
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
|
||||
const names = Object.keys(todo);
|
||||
if (names.length === 0) return text ?? '';
|
||||
|
||||
let out: string;
|
||||
if (format === 'codex-toml') {
|
||||
const base = text ?? '';
|
||||
const eol = base.includes('\r\n') ? '\r\n' : '\n';
|
||||
const sep =
|
||||
base.length === 0
|
||||
? ''
|
||||
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
|
||||
? ''
|
||||
: base.endsWith('\n')
|
||||
? eol
|
||||
: eol + eol;
|
||||
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
|
||||
out = base + sep + blocks.join(eol);
|
||||
} else {
|
||||
const dialect = JSON_DIALECTS[format];
|
||||
const doc: Record<string, unknown> =
|
||||
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
|
||||
const existing = doc[dialect.key];
|
||||
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||
for (const n of names) {
|
||||
const entry = dialect.to(todo[n]);
|
||||
if (entry) table[n] = entry;
|
||||
}
|
||||
doc[dialect.key] = table;
|
||||
out = JSON.stringify(doc, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// Re-read what we are about to write.
|
||||
const after = parseConfig(format, out);
|
||||
for (const n of before.names) {
|
||||
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
|
||||
}
|
||||
for (const n of names) {
|
||||
const got = after.servers[n];
|
||||
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
|
||||
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readText(file: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(file, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
|
||||
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
|
||||
* readable by its owner only.
|
||||
*/
|
||||
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
|
||||
let target = file;
|
||||
try {
|
||||
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
|
||||
try {
|
||||
await fs.lstat(file);
|
||||
throw new McpConfigError('config path is a dangling symlink');
|
||||
} catch (inner) {
|
||||
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = 0o600;
|
||||
try {
|
||||
mode = (await fs.stat(target)).mode & 0o777;
|
||||
await fs.copyFile(target, `${target}.codeman-bak`);
|
||||
await fs.chmod(`${target}.codeman-bak`, 0o600);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
if (secret) mode &= ~0o077;
|
||||
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
await fs.writeFile(tmp, text, { mode });
|
||||
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
|
||||
await fs.chmod(tmp, mode);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmp).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpSyncOptions {
|
||||
/** false = report what would change without writing. */
|
||||
apply: boolean;
|
||||
home?: string;
|
||||
/**
|
||||
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
|
||||
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
|
||||
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
|
||||
* env can never aim a write outside that home.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The config file a target means, honouring its relocation env var. `skip` is set when the var
|
||||
* holds something that cannot be located safely (a relative path resolves against the CLI's
|
||||
* working directory, which differs per session), so the target is neither read nor written.
|
||||
*/
|
||||
function resolveFile(
|
||||
t: McpSyncTarget,
|
||||
home: string,
|
||||
env: Record<string, string | undefined>
|
||||
): { file: string; skip?: string } {
|
||||
const rel = t.relocation;
|
||||
const dir = rel ? env[rel.envVar] : undefined;
|
||||
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
|
||||
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
|
||||
if (!isAbsolute(dir)) {
|
||||
return {
|
||||
file: `$${rel.envVar}/${rel.path}`,
|
||||
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
|
||||
};
|
||||
}
|
||||
return { file: join(dir, rel.path) };
|
||||
}
|
||||
|
||||
let applying = false;
|
||||
|
||||
/**
|
||||
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
|
||||
* order: when two CLIs define a name differently, the first one's definition is the one copied).
|
||||
* Throws `McpSyncBusyError` if another apply is running.
|
||||
*/
|
||||
export async function syncMcpServers(
|
||||
targets: McpSyncTarget[],
|
||||
opts: McpSyncOptions,
|
||||
unsupported: string[] = []
|
||||
): Promise<McpSyncResult> {
|
||||
if (opts.apply) {
|
||||
if (applying) throw new McpSyncBusyError();
|
||||
applying = true;
|
||||
}
|
||||
try {
|
||||
return await run(targets, opts, unsupported);
|
||||
} finally {
|
||||
if (opts.apply) applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
|
||||
const home = opts.home ?? homedir();
|
||||
const env = opts.env ?? (opts.home === undefined ? process.env : {});
|
||||
const seen = new Set<string>();
|
||||
const live = targets
|
||||
.map((t) => ({ t, ...resolveFile(t, home, env) }))
|
||||
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
|
||||
|
||||
const state = live.map(({ t, file, skip }) => {
|
||||
const res: McpSyncTargetResult = {
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
file,
|
||||
status: skip ? 'skipped' : 'ok',
|
||||
...(skip ? { error: skip } : {}),
|
||||
servers: [],
|
||||
added: [],
|
||||
skipped: [],
|
||||
};
|
||||
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
|
||||
});
|
||||
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
try {
|
||||
if (!s.t.installed && !(await exists(s.file))) {
|
||||
s.res.status = 'absent';
|
||||
continue;
|
||||
}
|
||||
const parsed = parseConfig(s.t.format, await readText(s.file));
|
||||
s.servers = parsed.servers;
|
||||
s.names = parsed.names;
|
||||
s.res.servers = [...parsed.names];
|
||||
} catch (err) {
|
||||
s.res.status = 'unreadable';
|
||||
s.res.error = describeMcpSyncError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
|
||||
const union = dict<McpServer>();
|
||||
const conflicts = new Set<string>();
|
||||
const switchedOff = new Set<string>();
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
for (const name of Object.keys(s.servers)) {
|
||||
const def = s.servers[name];
|
||||
if (def.disabled) {
|
||||
switchedOff.add(name);
|
||||
continue;
|
||||
}
|
||||
if (!(name in union)) union[name] = def;
|
||||
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
|
||||
}
|
||||
}
|
||||
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
|
||||
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
const add = dict<McpServer>();
|
||||
for (const name of Object.keys(union)) {
|
||||
if (s.names.has(name)) continue;
|
||||
if (canExpress(s.t.format, union[name])) add[name] = union[name];
|
||||
else s.res.skipped.push(name);
|
||||
}
|
||||
s.res.added = Object.keys(add);
|
||||
if (!opts.apply || s.res.added.length === 0) continue;
|
||||
try {
|
||||
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
|
||||
const fresh = await readText(s.file);
|
||||
const out = addServers(s.t.format, fresh, add);
|
||||
const current = parseConfig(s.t.format, fresh);
|
||||
const written = Object.keys(add).filter((n) => !current.names.has(n));
|
||||
if (written.length === 0) {
|
||||
s.res.added = [];
|
||||
continue;
|
||||
}
|
||||
const subset = dict<McpServer>();
|
||||
for (const n of written) subset[n] = add[n];
|
||||
await writeAtomic(s.file, out, carriesSecrets(subset));
|
||||
s.res.added = written;
|
||||
} catch (err) {
|
||||
s.res.status = 'failed';
|
||||
s.res.error = describeMcpSyncError(err);
|
||||
s.res.added = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applied: opts.apply,
|
||||
targets: state.map((s) => s.res),
|
||||
conflicts: [...conflicts].sort(),
|
||||
disabled,
|
||||
unsupported,
|
||||
};
|
||||
}
|
||||
@@ -213,6 +213,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return getCli(mode)?.capabilities.external ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
|
||||
*
|
||||
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
|
||||
* (claude) launches on that field. Every other CLI takes its model in its own config object
|
||||
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
|
||||
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
|
||||
* publishes and persists the field only where this holds, so a codex cron session never
|
||||
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
|
||||
* any CLI where it does not.
|
||||
*/
|
||||
export function cliTakesSessionModel(mode: SessionMode): boolean {
|
||||
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
|
||||
}
|
||||
|
||||
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
return getCli(mode)?.label ?? mode;
|
||||
@@ -1838,6 +1853,9 @@ export class Session extends EventEmitter {
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
|
||||
// object) and may be the app-wide Claude default cron handed it.
|
||||
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
|
||||
advisorModel: this._advisorModel,
|
||||
customModel: this.customModel,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
|
||||
+3
-1
@@ -24,9 +24,10 @@
|
||||
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
|
||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -72,3 +73,4 @@ export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './mcp-sync.js';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
|
||||
*
|
||||
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
|
||||
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
|
||||
*/
|
||||
|
||||
/** One participating CLI in a sync result. */
|
||||
export interface McpSyncTargetResult {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The config file read (and written). For a `skipped` target, the unresolved location. */
|
||||
file: string;
|
||||
/**
|
||||
* `absent`: not installed and no config file, so neither read nor created.
|
||||
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
|
||||
* var is a relative path), so it is neither read nor written; `error` says why.
|
||||
* `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).
|
||||
*/
|
||||
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
|
||||
/** Why the target is not `ok`. Position or category only, never file content. */
|
||||
error?: string;
|
||||
servers: string[];
|
||||
/** Servers added (apply) or that would be added (plan). */
|
||||
added: string[];
|
||||
/** Missing servers this dialect cannot express. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** The `data` of `GET`/`POST /api/mcp-sync`. */
|
||||
export interface McpSyncResult {
|
||||
applied: boolean;
|
||||
targets: McpSyncTargetResult[];
|
||||
/** Names defined differently by different CLIs; existing definitions are left untouched. */
|
||||
conflicts: string[];
|
||||
/** Names left out because the only definitions are switched off in their own CLI. */
|
||||
disabled: string[];
|
||||
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
|
||||
unsupported: string[];
|
||||
}
|
||||
+43
-2
@@ -6,13 +6,17 @@
|
||||
* Key exports:
|
||||
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
|
||||
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
|
||||
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
|
||||
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
|
||||
*
|
||||
* Persistence:
|
||||
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
|
||||
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
|
||||
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
|
||||
*
|
||||
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. No dependencies on other domain modules.
|
||||
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
|
||||
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** A registered push subscription */
|
||||
@@ -32,3 +36,40 @@ export interface VapidKeys {
|
||||
privateKey: string;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
/** Services the webhook channel can format a message for. */
|
||||
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
|
||||
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
|
||||
|
||||
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
|
||||
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
|
||||
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
|
||||
|
||||
export type WebhookUrgency = 'critical' | 'warning' | 'info';
|
||||
|
||||
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
|
||||
export interface WebhookConfig {
|
||||
enabled: boolean;
|
||||
kind: WebhookKind;
|
||||
url: string;
|
||||
scope: WebhookScope;
|
||||
}
|
||||
|
||||
/** One delivery attempt. `error` never contains the URL. */
|
||||
export interface WebhookResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
|
||||
export interface WebhookStatus {
|
||||
enabled: boolean;
|
||||
kind: WebhookKind;
|
||||
scope: WebhookScope;
|
||||
hasUrl: boolean;
|
||||
/** Scheme + host only; the path and query are the secret. */
|
||||
urlMasked: string;
|
||||
lastResult: WebhookResult | null;
|
||||
}
|
||||
|
||||
+22
-1
@@ -12,7 +12,7 @@
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
@@ -377,6 +377,16 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
|
||||
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
|
||||
* Which of them a given model honours is codex's business; Codeman only keeps the value
|
||||
* to a known word, since it lands in the launch argv.
|
||||
*/
|
||||
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
|
||||
|
||||
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
|
||||
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
|
||||
|
||||
/**
|
||||
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
|
||||
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
|
||||
@@ -418,6 +428,8 @@ export type CodexRenderMode = 'hybrid';
|
||||
export interface CodexConfig {
|
||||
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
|
||||
reasoningEffort?: CodexReasoningEffort;
|
||||
/** Resume a previous codex conversation by session id (passed via --resume) */
|
||||
resumeSessionId?: string;
|
||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||
@@ -817,6 +829,15 @@ export interface SessionState {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
|
||||
advisorModel?: string;
|
||||
/**
|
||||
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
|
||||
* the app-wide default when there was none. Persisted so a recovered session relaunches on
|
||||
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
|
||||
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
|
||||
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
|
||||
* absent for them.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
|
||||
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
|
||||
|
||||
+499
-11
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
// Owner tab layout (grouped vertical rail)
|
||||
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
@@ -581,6 +583,12 @@ class CodemanApp {
|
||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
||||
this.tabLayout = null;
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
this._lastTabGroupStructureKey = null;
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -1242,6 +1250,12 @@ class CodemanApp {
|
||||
|
||||
// Use capture to handle before terminal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
|
||||
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
|
||||
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
|
||||
// phase, before the field's own handler. Must stay the first statement.
|
||||
if (e.target?.closest?.('[data-raw-keys]')) return;
|
||||
|
||||
// Don't intercept keys during CJK IME composition
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
@@ -4361,6 +4375,10 @@ class CodemanApp {
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
// (Re)read the owner tab layout on every init, including SSE reconnects: a
|
||||
// tab:layoutChanged sent while this client was disconnected is never replayed.
|
||||
this._loadTabLayout();
|
||||
|
||||
if (data.respawnStatus) {
|
||||
this.respawnStatus = data.respawnStatus;
|
||||
} else {
|
||||
@@ -5183,6 +5201,12 @@ class CodemanApp {
|
||||
_updateActiveTabImmediate(sessionId) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
// Grouped rail: selecting a session hidden in a collapsed group must show it
|
||||
// (and re-hide the previous exception), which a class toggle cannot do.
|
||||
if (this._isTabGroupStructureStale()) {
|
||||
this._fullRenderSessionTabs();
|
||||
return;
|
||||
}
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.id === sessionId) {
|
||||
@@ -5191,6 +5215,7 @@ class CodemanApp {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
this._syncTabTreeSelection(container);
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
@@ -5342,7 +5367,12 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
|
||||
// against the rows the projection SHOWS, not every live session.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const currentIds = groupProjection
|
||||
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
|
||||
: new Set(this.sessions.keys());
|
||||
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
@@ -5351,14 +5381,20 @@ class CodemanApp {
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const wantedWebIds = groupProjection
|
||||
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
|
||||
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5545,6 +5581,15 @@ class CodemanApp {
|
||||
subagentBadgeEl.remove();
|
||||
}
|
||||
}
|
||||
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
|
||||
// highlight, and change the alerts a collapsed header stands in for, none
|
||||
// of which rebuilds the rail. Keep what assistive tech and the headers
|
||||
// report in step. The flat list (role=tablist) never takes this branch.
|
||||
if (groupProjection && container.getAttribute('role') === 'tree') {
|
||||
this._syncTabTreeSelection(container);
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5676,6 +5721,12 @@ class CodemanApp {
|
||||
const prevScrollTop = container.scrollTop;
|
||||
const prevActiveTabId = this._lastRenderedActiveTabId;
|
||||
const isFirstRender = !container.querySelector('.session-tab');
|
||||
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
||||
// back on the same item (by identity) so a background render or a keyboard
|
||||
// collapse does not drop a keyboard user to <body>.
|
||||
const focusWasInside = container.contains(document.activeElement);
|
||||
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
||||
this._tabFocusIdentity = null;
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
@@ -5696,6 +5747,10 @@ class CodemanApp {
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
const rowHtml = new Map();
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -5759,7 +5814,7 @@ class CodemanApp {
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5782,12 +5837,41 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
|
||||
);
|
||||
parts.push(
|
||||
window.CodemanTabLayout.renderProjection(
|
||||
groupProjection,
|
||||
(ref) =>
|
||||
ref.kind === 'session'
|
||||
? rowHtml.get(ref.id) || ''
|
||||
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
|
||||
escapeHtml
|
||||
)
|
||||
);
|
||||
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
|
||||
} else {
|
||||
parts.push(...rowHtml.values());
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
this._applyTabListRole(container, !!groupProjection);
|
||||
if (groupProjection) {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5838,6 +5922,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this._tabKeydownHandler = (e) => {
|
||||
// The grouped rail is a tree with its own key model; everything else
|
||||
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
|
||||
if (container.getAttribute('role') === 'tree') {
|
||||
this._handleTabTreeKeydown(e, container);
|
||||
return;
|
||||
}
|
||||
// Up/Down are aliases of Left/Right, not replacements: the strip stays
|
||||
// arrow-key navigable exactly as before, the vertical sidebar just gains
|
||||
// the axis a user reaches for there.
|
||||
@@ -5862,8 +5952,7 @@ class CodemanApp {
|
||||
// Enter or Space activates the tab
|
||||
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const sessionId = tabs[currentIndex].dataset.id;
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
this._activateTabRow(tabs[currentIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5894,6 +5983,250 @@ class CodemanApp {
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', this._tabKeydownHandler);
|
||||
|
||||
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
|
||||
// Tab-ing away and back returns to it and there is still exactly one stop.
|
||||
if (!this._tabTreeFocusinHandler) {
|
||||
this._tabTreeFocusinHandler = (e) => {
|
||||
if (container.getAttribute('role') !== 'tree') return;
|
||||
const item = e.target?.closest?.('[role="treeitem"]');
|
||||
if (item && container.contains(item)) this._setTabTreeStop(container, item);
|
||||
};
|
||||
container.addEventListener('focusin', this._tabTreeFocusinHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
|
||||
_activateTabRow(row) {
|
||||
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
|
||||
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Grouped rail: tree semantics and keyboard model
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
|
||||
// named-group headers are level-1 treeitems that own their rows (level 2);
|
||||
// ungrouped rows and a collapsed group's kept selection are level 1. One item
|
||||
// carries tabindex=0 (roving), every control inside a row is removed from the
|
||||
// tab order, and focus is restored by identity across full re-renders. The
|
||||
// flat rail and the header strip keep role=tablist / role=tab untouched.
|
||||
|
||||
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
|
||||
_applyTabListRole(container, grouped) {
|
||||
if (grouped) {
|
||||
container.setAttribute('role', 'tree');
|
||||
container.setAttribute('aria-label', 'Sessions');
|
||||
} else if (container.getAttribute('role') === 'tree') {
|
||||
container.setAttribute('role', 'tablist');
|
||||
container.setAttribute('aria-label', 'Session tabs');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity of a tree item (or anything inside one) across re-renders. */
|
||||
_tabTreeIdentity(element) {
|
||||
const item = element?.closest?.('[data-tab-group-header], .session-tab');
|
||||
if (!item) return null;
|
||||
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
|
||||
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
|
||||
if (item.dataset.id) return `session:${item.dataset.id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible tree items in the order the eye reads them: each section's header,
|
||||
* then its rows. A sorted rail paints rows with the flex `order` property
|
||||
* inside their own group column, so rows are ordered by COMPUTED order within
|
||||
* a section (stable, so the unsorted rail keeps DOM order).
|
||||
*/
|
||||
_tabTreeItems(container) {
|
||||
const sorted = this.isTabRailSorted();
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
const items = [];
|
||||
for (const section of container.querySelectorAll('.tab-layout-group')) {
|
||||
const header = section.querySelector(':scope > [role="treeitem"]');
|
||||
if (header) items.push(header);
|
||||
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
|
||||
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
items.push(...rows);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Move the single tab stop to `item` (every other tree item gets -1). */
|
||||
_setTabTreeStop(container, item) {
|
||||
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn freshly rendered rows into tree items and place the roving tab stop.
|
||||
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
|
||||
* its controls); only their semantics change here, never their content.
|
||||
*/
|
||||
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('role', 'treeitem');
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
|
||||
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
|
||||
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
|
||||
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
|
||||
control.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
|
||||
const items = this._applyTabTreePositions(container);
|
||||
|
||||
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
|
||||
// A focused row that a collapse just hid hands focus to its group header.
|
||||
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
|
||||
const target =
|
||||
byIdentity(identity) ||
|
||||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
|
||||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
|
||||
items[0];
|
||||
if (!target) return;
|
||||
this._setTabTreeStop(container, target);
|
||||
if (refocus && document.activeElement !== target) target.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* aria-posinset / aria-setsize within each level, in PAINTED order: the
|
||||
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
|
||||
* group's own rows. Runs after every full render AND after an incremental pass,
|
||||
* because the activity-sorted rail re-sorts rows in place. Returns the items.
|
||||
*/
|
||||
_applyTabTreePositions(container) {
|
||||
const items = this._tabTreeItems(container);
|
||||
const sets = new Map();
|
||||
for (const item of items) {
|
||||
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
|
||||
if (!sets.has(owner)) sets.set(owner, []);
|
||||
sets.get(owner).push(item);
|
||||
}
|
||||
for (const members of sets.values()) {
|
||||
members.forEach((item, index) => {
|
||||
const setsize = String(members.length);
|
||||
const posinset = String(index + 1);
|
||||
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
|
||||
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed group hides its rows, including ones that need the user. Its
|
||||
* header takes the most urgent hidden alert in the tab alert language
|
||||
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
|
||||
* behind a collapse is never invisible. Patched in place on both render paths:
|
||||
* alerts change without a rebuild.
|
||||
*/
|
||||
_syncTabGroupHeaderAlerts(container, projection) {
|
||||
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
|
||||
const alert = alerts[header.dataset.tabGroupHeader];
|
||||
header.classList.toggle('tab-alert-action', alert === 'action');
|
||||
header.classList.toggle('tab-alert-idle', alert === 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||
_syncTabTreeSelection(container) {
|
||||
if (container?.getAttribute('role') !== 'tree') return;
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
|
||||
* visible items, Home/End jump, Right expands a header or enters it, Left
|
||||
* collapses a header or climbs from a row to its header, Enter/Space select a
|
||||
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
|
||||
*/
|
||||
_handleTabTreeKeydown(e, container) {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const items = this._tabTreeItems(container);
|
||||
const current = e.target?.closest?.('[role="treeitem"]');
|
||||
// A key pressed on a control INSIDE a row (its close or overflow button,
|
||||
// focused by a click or handed focus back by the action menu) belongs to
|
||||
// that control: Enter there must reopen the menu, not re-select the row.
|
||||
// Same contract as the flat list, which acts only on a focused row.
|
||||
if (!current || current !== e.target) return;
|
||||
const index = items.indexOf(current);
|
||||
if (index < 0) return;
|
||||
const groupId = current.dataset.tabGroupHeader || null;
|
||||
const expanded = current.getAttribute('aria-expanded') === 'true';
|
||||
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
|
||||
const expandable = current.hasAttribute('aria-expanded');
|
||||
const focusAt = (next) => {
|
||||
if (!next) return;
|
||||
this._setTabTreeStop(container, next);
|
||||
next.focus();
|
||||
};
|
||||
const toggle = (collapse) => {
|
||||
// The toggle re-renders the rail; keep focus on this header through it.
|
||||
this._tabFocusIdentity = `group:${groupId}`;
|
||||
this.toggleTabGroupCollapsed(groupId, collapse);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp': {
|
||||
const step = e.key === 'ArrowDown' ? 1 : -1;
|
||||
focusAt(items[(index + step + items.length) % items.length]);
|
||||
break;
|
||||
}
|
||||
case 'Home':
|
||||
focusAt(items[0]);
|
||||
break;
|
||||
case 'End':
|
||||
focusAt(items[items.length - 1]);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (!groupId || !expandable) return;
|
||||
if (!expanded) toggle(false);
|
||||
else {
|
||||
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
|
||||
if (!child) return;
|
||||
focusAt(child);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (groupId) {
|
||||
if (!expandable || !expanded) return;
|
||||
toggle(true);
|
||||
} else {
|
||||
const group = current.closest('[role="group"]');
|
||||
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
|
||||
if (!header) return;
|
||||
focusAt(header);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (groupId) toggle();
|
||||
else this._activateTabRow(current);
|
||||
break;
|
||||
case 'F10':
|
||||
case 'ContextMenu':
|
||||
if (e.key === 'F10' && !e.shiftKey) return;
|
||||
if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
||||
current.dataset.id
|
||||
);
|
||||
} else if (current.dataset.webviewId) {
|
||||
this.showWebviewModal?.(current.dataset.webviewId);
|
||||
} else return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleSessionTabClick(event, sessionId) {
|
||||
@@ -5978,6 +6311,141 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: grouped vertical rail (read-only)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
||||
// already projects them onto the global session order, so sessionOrder, Alt+N,
|
||||
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
||||
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
||||
// collapse. With no groups (or any read failure) the rail is the flat list it
|
||||
// has always been.
|
||||
|
||||
_ensureTabLayoutCoordinator() {
|
||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||
if (!window.CodemanTabLayout) return null;
|
||||
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
const data = await this._apiJson('/api/tab-layout');
|
||||
if (!data?.layout) throw new Error('Tab layout unavailable');
|
||||
return data.layout;
|
||||
},
|
||||
applyLayout: (layout) => this._applyTabLayout(layout),
|
||||
applyFallback: () => this._applyTabLayout(null),
|
||||
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
|
||||
// tab:layoutChanged asks again: an unreachable server must not cost a
|
||||
// fetch every 5 s for as long as the page stays open.
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 60000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
|
||||
cancelRetry: (timer) => clearTimeout(timer),
|
||||
});
|
||||
return this._tabLayoutCoordinator;
|
||||
}
|
||||
|
||||
_loadTabLayout() {
|
||||
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
|
||||
}
|
||||
|
||||
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
|
||||
_onTabLayoutChanged(data) {
|
||||
const me = window.__codemanUser;
|
||||
// Another user's layout changed: nothing of ours moved. (The GET is
|
||||
// owner-scoped server-side, so this is a saved request, not a guard.)
|
||||
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
||||
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
||||
this._loadTabLayout();
|
||||
}
|
||||
|
||||
/** Adopt a layout read (or null after a failed read, which renders flat). */
|
||||
_applyTabLayout(layout) {
|
||||
let next = null;
|
||||
if (layout) {
|
||||
try {
|
||||
next = window.CodemanTabLayout.normalizeLayout(layout);
|
||||
} catch {
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
// An overtaken response is already dropped by the coordinator; this guards a
|
||||
// reordering between the coordinator and an SSE-triggered reload.
|
||||
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
||||
this.tabLayout = next;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
|
||||
: { ids: [], ok: !next };
|
||||
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||
// The server announces a layout change on every session create/close, web
|
||||
// tab create/delete and order PUT, and most of those move nothing on this
|
||||
// rail (always so on the flat rail, which is every owner without groups).
|
||||
// Rebuild only when what the rail would draw actually changed.
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||
_getTabCollapseStorage() {
|
||||
if (this._tabCollapseStorageFailed) return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
this._tabCollapseStorageFailed = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouped projection for the CURRENT render, or null when the rail should
|
||||
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
|
||||
* no layout yet, a failed read, or a layout without groups.
|
||||
*/
|
||||
_projectTabGroups() {
|
||||
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
|
||||
return window.CodemanTabLayout.project(this.tabLayout, {
|
||||
// sessionOrder, not the sessions Map: rows are built in that order, so a
|
||||
// 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],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
});
|
||||
}
|
||||
|
||||
_tabGroupStructureKey(projection) {
|
||||
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
|
||||
}
|
||||
|
||||
/** True when the DOM's grouping no longer matches what a render would draw. */
|
||||
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
|
||||
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse/expand one group (header click). Per-device: stored in localStorage,
|
||||
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
|
||||
* A storage failure leaves every group expanded rather than half-remembered.
|
||||
*/
|
||||
toggleTabGroupCollapsed(groupId, forceCollapsed) {
|
||||
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;
|
||||
if (shouldCollapse) next.add(groupId);
|
||||
else next.delete(groupId);
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const saved = storage
|
||||
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
|
||||
: { ids: [], ok: false };
|
||||
if (!saved.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(saved.ids);
|
||||
// The full render also redraws connectors anchored to rows that just moved.
|
||||
this._fullRenderSessionTabs();
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -5989,7 +6457,10 @@ class CodemanApp {
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
if (this.isTabRailSorted()) {
|
||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
||||
// "move into that group", and the server would re-rank it within its old
|
||||
// group anyway. Grouped editing comes with its own drag model.
|
||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
}
|
||||
@@ -6080,10 +6551,26 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
|
||||
* neighbour in its OWN section. Across a group boundary the server re-ranks
|
||||
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
||||
* session:orderChanged comes back, and this client would keep a swapped
|
||||
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
|
||||
* drag is off in the grouped rail. Any other layout: always allowed.
|
||||
*/
|
||||
_canSwapActiveTabWith(neighbourId) {
|
||||
const projection = this._projectTabGroups();
|
||||
if (!projection) return true;
|
||||
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
|
||||
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -6093,6 +6580,7 @@ class CodemanApp {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
'Open away digest': '打开离开期间摘要',
|
||||
'Session Manager': '会话管理器',
|
||||
'Session actions': '会话操作',
|
||||
Ungrouped: '未分组',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
|
||||
@@ -1822,6 +1822,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Key tester</span>
|
||||
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
|
||||
</div>
|
||||
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
|
||||
placeholder="Click here, then press keys"
|
||||
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
|
||||
</div>
|
||||
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Header & Panels ═══════════════════════════════════════ -->
|
||||
@@ -2487,6 +2503,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
</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>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Notifications ════════════════════════════════════════════ -->
|
||||
@@ -2614,6 +2654,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="webhookGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Send alerts to a webhook</span>
|
||||
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Service</span></div>
|
||||
<select id="webhookKind" class="set-select">
|
||||
<option value="ntfy">ntfy</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="generic">Generic JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Webhook URL</span>
|
||||
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="webhookScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
@@ -3751,10 +3842,14 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
|
||||
<script defer src="mobile-ime-preview.js"></script>
|
||||
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
|
||||
<script defer src="tab-layout-browser.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
|
||||
*
|
||||
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
|
||||
* Korean, dictation) is not visible inside the terminal until it commits, so
|
||||
* the user types blind. The controller listens to the helper textarea's
|
||||
* composition events and asks the caller to render the latest composition
|
||||
* (`phase: 'provisional'`), coalesced to one render per animation frame and
|
||||
* capped at 2048 characters. When xterm emits the committed text through
|
||||
* onData, the caller hands it to `consumeTerminalData()`, which switches the
|
||||
* preview to `phase: 'committed'` until something else shows the text: the
|
||||
* local echo overlay or a prediction (`completeCommit`), authoritative
|
||||
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
|
||||
* same 2 s bound applies while waiting for a commit that never reaches onData
|
||||
* (the user deleted the whole composition), so a later unrelated chunk is never
|
||||
* mistaken for it.
|
||||
*
|
||||
* Keydown ordering: xterm registers its textarea keydown listener in the
|
||||
* capture phase inside terminal.open() and finalizes the composition there
|
||||
* (CompositionHelper.keydown), emitting the commit through onData
|
||||
* synchronously. The controller therefore observes keydown in the capture
|
||||
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
|
||||
* before any listener on the textarea itself, and finalizes on exactly the
|
||||
* keys xterm does.
|
||||
*
|
||||
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
|
||||
* and every callback is wrapped so a failing render cannot block the wire.
|
||||
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
|
||||
* composition view untouched.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const COMMITTED_VISUAL_TTL = 2000;
|
||||
const PREVIEW_CAP = 2048;
|
||||
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
|
||||
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
|
||||
// keydown during a composition finalizes it.
|
||||
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
|
||||
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
||||
|
||||
function isIosWebKitTouch(nav = navigator) {
|
||||
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
|
||||
const platform = String(nav && nav.platform ? nav.platform : '');
|
||||
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
|
||||
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
|
||||
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
|
||||
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
// Must be the textarea or an ancestor of it, so its capture listener runs
|
||||
// before xterm's capture listener on the textarea.
|
||||
const keydownTarget = options.keydownTarget || textarea;
|
||||
const render = typeof options.render === 'function' ? options.render : function () {};
|
||||
const clear = typeof options.clear === 'function' ? options.clear : function () {};
|
||||
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
|
||||
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
|
||||
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
|
||||
let generation = 0;
|
||||
let composing = false;
|
||||
let awaitingCommit = false;
|
||||
let committed = false;
|
||||
let latestValue = '';
|
||||
let renderPhase = null;
|
||||
let frameToken = null;
|
||||
let timerToken = null;
|
||||
let finalizedByKeydown = false;
|
||||
let destroyed = false;
|
||||
let invokingClear = false;
|
||||
|
||||
function safely(callback, ...args) {
|
||||
try {
|
||||
return callback(...args);
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelScheduledFrame() {
|
||||
const token = frameToken;
|
||||
frameToken = null;
|
||||
if (token && token.id !== undefined) safely(cancelFrame, token.id);
|
||||
}
|
||||
|
||||
function cancelCommittedTimer() {
|
||||
const token = timerToken;
|
||||
timerToken = null;
|
||||
if (token && token.id !== undefined) safely(clearTimer, token.id);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
if (invokingClear) return;
|
||||
invokingClear = true;
|
||||
safely(clear);
|
||||
invokingClear = false;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
generation += 1;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = false;
|
||||
latestValue = '';
|
||||
renderPhase = null;
|
||||
finalizedByKeydown = false;
|
||||
clearVisual();
|
||||
}
|
||||
|
||||
function scheduleLatestPreview(phase) {
|
||||
if (destroyed) return;
|
||||
renderPhase = phase;
|
||||
if (frameToken) return;
|
||||
const token = { generation, id: undefined };
|
||||
frameToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
|
||||
frameToken = null;
|
||||
const value = latestValue.slice(0, PREVIEW_CAP);
|
||||
const phaseToRender = renderPhase;
|
||||
safely(render, { text: value, phase: phaseToRender });
|
||||
};
|
||||
const id = safely(scheduleFrame, callback);
|
||||
if (frameToken === token) {
|
||||
if (id === undefined) frameToken = null;
|
||||
else token.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
function beginComposition() {
|
||||
cleanup();
|
||||
if (destroyed) return;
|
||||
composing = true;
|
||||
}
|
||||
|
||||
function updateComposition(event) {
|
||||
if (!composing) return;
|
||||
latestValue = event.data == null ? '' : String(event.data);
|
||||
scheduleLatestPreview('provisional');
|
||||
}
|
||||
|
||||
function onComposingInput(event) {
|
||||
if (!event.isComposing) return;
|
||||
updateComposition({ data: event.data == null ? textarea.value : event.data });
|
||||
}
|
||||
|
||||
function armFallbackTimer(isCurrent) {
|
||||
const token = { generation, id: undefined };
|
||||
timerToken = token;
|
||||
const callback = function () {
|
||||
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
|
||||
timerToken = null;
|
||||
cleanup();
|
||||
};
|
||||
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
|
||||
if (timerToken === token) {
|
||||
if (id === undefined) {
|
||||
timerToken = null;
|
||||
return false;
|
||||
}
|
||||
token.id = id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function finalizeComposition(value, fromKeydown) {
|
||||
if (!composing) return;
|
||||
composing = false;
|
||||
awaitingCommit = true;
|
||||
committed = false;
|
||||
finalizedByKeydown = fromKeydown;
|
||||
latestValue = value == null ? latestValue : String(value);
|
||||
scheduleLatestPreview('provisional');
|
||||
// A commit that never reaches onData (the composition was deleted, so
|
||||
// xterm emits nothing) must not leave the controller waiting forever.
|
||||
cancelCommittedTimer();
|
||||
const owner = generation;
|
||||
armFallbackTimer(function () {
|
||||
return awaitingCommit && generation === owner;
|
||||
});
|
||||
}
|
||||
|
||||
function onCompositionEnd(event) {
|
||||
if (finalizedByKeydown) {
|
||||
finalizedByKeydown = false;
|
||||
return;
|
||||
}
|
||||
finalizeComposition(event.data, false);
|
||||
}
|
||||
|
||||
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
|
||||
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
|
||||
// 20/229 and 16/17/18 keep the composition open and every other keyCode
|
||||
// finalizes it. `isComposing` and `key` are deliberately not consulted,
|
||||
// because xterm does not consult them.
|
||||
function onKeydown(event) {
|
||||
if (keydownTarget !== textarea && event.target !== textarea) return;
|
||||
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
|
||||
finalizeComposition(latestValue, true);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (destroyed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function consumeTerminalData(data) {
|
||||
if (
|
||||
destroyed ||
|
||||
!awaitingCommit ||
|
||||
typeof data !== 'string' ||
|
||||
data.length === 0 ||
|
||||
CONTROL_OR_LINE_BREAK.test(data)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
generation += 1;
|
||||
const owner = generation;
|
||||
cancelScheduledFrame();
|
||||
cancelCommittedTimer();
|
||||
composing = false;
|
||||
awaitingCommit = false;
|
||||
committed = true;
|
||||
latestValue = data;
|
||||
renderPhase = 'committed';
|
||||
safely(onCommit, data);
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
scheduleLatestPreview('committed');
|
||||
if (destroyed || generation !== owner || !committed) return true;
|
||||
|
||||
const armed = armFallbackTimer(function () {
|
||||
return committed;
|
||||
});
|
||||
if (!armed && !destroyed && generation === owner && committed) cleanup();
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeCommit(result) {
|
||||
if (destroyed || !result || result.predicted !== true || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function noteAuthoritativeOutput() {
|
||||
if (destroyed || !committed) return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
[textarea, 'compositionstart', beginComposition],
|
||||
[textarea, 'compositionupdate', updateComposition],
|
||||
[textarea, 'input', onComposingInput],
|
||||
[textarea, 'compositionend', onCompositionEnd],
|
||||
[keydownTarget, 'keydown', onKeydown, true],
|
||||
[textarea, 'blur', reset],
|
||||
];
|
||||
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
consumeTerminalData,
|
||||
completeCommit,
|
||||
noteAuthoritativeOutput,
|
||||
reset,
|
||||
destroy,
|
||||
get state() {
|
||||
return {
|
||||
generation,
|
||||
composing,
|
||||
awaitingCommit,
|
||||
committed,
|
||||
latest: latestValue,
|
||||
framePending: frameToken !== null,
|
||||
timerPending: timerToken !== null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.MobileImePreview = { create, isIosWebKitTouch };
|
||||
})(globalThis);
|
||||
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
if (edges.length === 0) return;
|
||||
this._lineageEdgeCount = edges.length;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stripRect = strip.getBoundingClientRect();
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
|
||||
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
for (const id of [edge.parentId, edge.childId]) {
|
||||
const key = 'tab:' + id;
|
||||
if (rects.has(key)) continue;
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
rects.set(key, tab ? tab.getBoundingClientRect() : null);
|
||||
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||
if (rects.has(endpoint.key)) continue;
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
for (const edge of edges) {
|
||||
const parentRect = rects.get('tab:' + edge.parentId);
|
||||
const childRect = rects.get('tab:' + edge.childId);
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
|
||||
@@ -416,6 +416,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
|
||||
// The routes read the SAVED setting, so remember what it was on open: switching it on
|
||||
// here does nothing server-side until Save (see mcpSync()).
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -1137,6 +1144,226 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updateCheck = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
|
||||
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
|
||||
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
|
||||
*/
|
||||
keyTesterEvent(ev) {
|
||||
const log = document.getElementById('keyTesterLog');
|
||||
if (!log) return;
|
||||
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
|
||||
// The field is readonly, so nothing is typed into it either way.
|
||||
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
|
||||
const line =
|
||||
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
|
||||
`mods=${mods.join('+') || 'none'}` +
|
||||
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
|
||||
(ev.repeat ? ' (repeat)' : '');
|
||||
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
|
||||
log.textContent = lines.slice(-14).join('\n');
|
||||
log.style.display = 'block';
|
||||
},
|
||||
|
||||
/**
|
||||
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
|
||||
* shown disabled, because both endpoints would only answer 403. Called on open and from the
|
||||
* checkbox's own onchange (assigning .checked fires no change event).
|
||||
*/
|
||||
applyMcpSyncVisibility() {
|
||||
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
|
||||
const row = document.getElementById('mcpSyncActionRow');
|
||||
if (row) row.style.display = on ? '' : 'none';
|
||||
const out = this.$('mcpSyncResult');
|
||||
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
|
||||
this._applyMcpSyncAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
|
||||
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
|
||||
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
|
||||
* `window.__codemanUser`'s real role can resolve after settings were opened once.
|
||||
*/
|
||||
_applyMcpSyncAdminGate() {
|
||||
const group = document.getElementById('mcpSyncGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html) => {
|
||||
if (out) { out.style.display = 'block'; out.innerHTML = html; }
|
||||
};
|
||||
// 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.');
|
||||
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;
|
||||
show('Working…');
|
||||
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
show(escapeHtml(body?.error || 'MCP sync failed.'));
|
||||
return;
|
||||
}
|
||||
const data = body.data;
|
||||
const rows = data.targets.map((t) => {
|
||||
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
|
||||
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
|
||||
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
|
||||
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
|
||||
const verb = data.applied ? 'added' : 'would add';
|
||||
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
|
||||
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
|
||||
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
|
||||
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
|
||||
});
|
||||
const conflicts = data.conflicts.length
|
||||
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const disabled = data.disabled?.length
|
||||
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
const unsupported = data.unsupported?.length
|
||||
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
|
||||
: '';
|
||||
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
|
||||
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
|
||||
* this page. The URL box is write-only; the status line shows scheme + host only.
|
||||
*
|
||||
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
|
||||
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
|
||||
* saveWebhook() beside the settings PUT the same way it saves the model config
|
||||
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
|
||||
* `_webhookPending()` can tell an edited group from an untouched one.
|
||||
*/
|
||||
_webhookSay(text, bad = false) {
|
||||
const out = document.getElementById('webhookResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadWebhook() {
|
||||
const group = document.getElementById('webhookGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/webhook');
|
||||
if (!res || !res.ok) {
|
||||
this._webhookLoaded = null;
|
||||
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
|
||||
return;
|
||||
}
|
||||
let body = null;
|
||||
try { body = await res.json(); } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('webhookEnabled').checked = d.enabled === true;
|
||||
document.getElementById('webhookKind').value = d.kind;
|
||||
document.getElementById('webhookScope').value = d.scope;
|
||||
const url = document.getElementById('webhookUrl');
|
||||
url.value = '';
|
||||
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
|
||||
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
|
||||
const clearBtn = document.getElementById('webhookClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
|
||||
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
|
||||
this._webhookLoaded = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
kind: document.getElementById('webhookKind').value,
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
this._webhookSay(
|
||||
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
|
||||
!d.lastResult.ok
|
||||
);
|
||||
} else {
|
||||
this._webhookSay('');
|
||||
}
|
||||
},
|
||||
|
||||
/** True when the visible webhook group differs from what loadWebhook() last showed. */
|
||||
_webhookPending() {
|
||||
const group = document.getElementById('webhookGroup');
|
||||
const loaded = this._webhookLoaded;
|
||||
if (!group || group.style.display === 'none' || !loaded) return false;
|
||||
return (
|
||||
document.getElementById('webhookUrl').value.trim() !== '' ||
|
||||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
|
||||
document.getElementById('webhookKind').value !== loaded.kind ||
|
||||
document.getElementById('webhookScope').value !== loaded.scope
|
||||
);
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveWebhook() {
|
||||
const payload = {
|
||||
enabled: document.getElementById('webhookEnabled').checked,
|
||||
kind: document.getElementById('webhookKind').value,
|
||||
scope: document.getElementById('webhookScope').value,
|
||||
};
|
||||
const url = document.getElementById('webhookUrl').value.trim();
|
||||
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
|
||||
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the webhook.';
|
||||
this._webhookSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Saved.');
|
||||
return '';
|
||||
},
|
||||
|
||||
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
|
||||
async clearWebhook() {
|
||||
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
|
||||
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadWebhook();
|
||||
this._webhookSay('Webhook URL removed.');
|
||||
},
|
||||
|
||||
async testWebhook() {
|
||||
const btn = document.getElementById('webhookTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
|
||||
this._webhookSay('Sending…');
|
||||
const res = await this._apiPost('/api/webhook/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._webhookSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
_setUpdateResult(html) {
|
||||
const el = this.$('updateResult');
|
||||
if (el) { el.style.display = 'block'; el.innerHTML = html; }
|
||||
@@ -2182,6 +2409,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2455,6 +2683,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
sessionLineageLines: _sll,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -2479,7 +2708,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
|
||||
this.showToast('Settings saved', 'success');
|
||||
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
|
||||
// settings.json), so like the model config above it is saved beside the settings PUT, not in
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else {
|
||||
this.showToast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
// Show tunnel-specific feedback if toggled on
|
||||
if (settings.tunnelEnabled) {
|
||||
@@ -2490,7 +2728,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Settings saved locally', 'warning');
|
||||
}
|
||||
|
||||
this.closeAppSettings();
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
// Voice availability is a server-side answer, so re-probe after a save:
|
||||
// otherwise the mic keeps using the pre-save provider until the next reload.
|
||||
@@ -4130,4 +4372,5 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
window.app?._applyMcpSyncAdminGate?.();
|
||||
});
|
||||
|
||||
+124
-3
@@ -460,6 +460,31 @@ textarea:focus-visible {
|
||||
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview {
|
||||
position: absolute;
|
||||
left: var(--xterm-helper-left, 0px);
|
||||
top: var(--xterm-helper-top, 0px);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
color: var(--terminal-foreground, var(--text, #fff));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 1.2;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Session tab focus */
|
||||
.session-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -634,6 +659,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- Grouped rail (owner tab layout) ---------------------------------- */
|
||||
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
|
||||
Each group's rows sit in their OWN flex column, so the activity sort's inline
|
||||
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 6px 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-layout-group-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-layout-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-layout-group-chevron::before {
|
||||
content: '\25BE';
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
|
||||
leaf with no aria-expanded at all, and its chevron must still show its state. */
|
||||
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .header-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1707,7 +1809,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
/* A control, like its gear/detach siblings: the pointer must not read as
|
||||
"nothing here" over the one button that closes the session. */
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
/* Performance: use transform for GPU acceleration */
|
||||
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
|
||||
@@ -1760,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||
is fine at these alphas and is exactly what keeps it visible over the active
|
||||
tab's opaque-ish background. */
|
||||
.session-tabs .session-tab.tab-alert-action::before {
|
||||
.session-tabs .session-tab.tab-alert-action::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-action::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -1785,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.session-tabs .session-tab.tab-alert-idle::before {
|
||||
.session-tabs .session-tab.tab-alert-idle::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -10510,6 +10616,21 @@ kbd {
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.lineage-line-dot.lineage-line-dot--proxied {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @fileoverview Read-only browser projection of the owner tab layout.
|
||||
*
|
||||
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
||||
* is the server model). Browser assets cannot import that TypeScript, so this
|
||||
* module is a small, dependency-free mirror that owns three things:
|
||||
*
|
||||
* 1. Projection: which live sessions and open web tabs land in which group,
|
||||
* and which rows a collapsed group hides.
|
||||
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
|
||||
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
|
||||
* byte-identical to the flat rail's row.
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
*
|
||||
* The server stays the only authority for layout content. Collapse is a
|
||||
* per-device view preference and lives in localStorage only.
|
||||
*
|
||||
* Grouped rendering is opt-in by construction: a layout with no groups (every
|
||||
* owner until they create one) projects to `null`, and the caller keeps the flat
|
||||
* rail exactly as it was.
|
||||
*
|
||||
* @dependency none
|
||||
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
|
||||
*/
|
||||
|
||||
(function initCodemanTabLayout(global) {
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
const copyRefs = (value) =>
|
||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
||||
|
||||
/**
|
||||
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
||||
* server adding model fields cannot leak half-understood state into the view.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return {
|
||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||
groups: groups
|
||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
name: typeof group.name === 'string' ? group.name : '',
|
||||
refs: copyRefs(group.refs),
|
||||
})),
|
||||
ungrouped: copyRefs(value.ungrouped),
|
||||
};
|
||||
}
|
||||
|
||||
function hasGroups(layout) {
|
||||
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
|
||||
}
|
||||
|
||||
/** Stored collapse ids, or null when the stored value is not a JSON array. */
|
||||
function parseCollapsedIds(raw) {
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? stableIds(parsed) : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
|
||||
* or write threw), and the caller then keeps every group expanded. A malformed
|
||||
* VALUE is not a store failure: it reads as "nothing collapsed" and is
|
||||
* rewritten, or a shape left behind by another build (a rollback) would leave
|
||||
* collapse disabled on this device for good.
|
||||
*/
|
||||
function loadCollapsedGroupIds(storage, validGroupIds) {
|
||||
try {
|
||||
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
|
||||
const loaded = parsed || [];
|
||||
if (validGroupIds === undefined) return { ids: loaded, ok: true };
|
||||
// Garbage-collect ids of groups that no longer exist, so a deleted group's
|
||||
// id cannot silently collapse a future group that reuses it.
|
||||
const valid = new Set(stableIds(validGroupIds));
|
||||
const kept = loaded.filter((id) => valid.has(id));
|
||||
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
|
||||
return { ids: kept, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
|
||||
return { ids, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a layout onto what is live in this browser.
|
||||
*
|
||||
* Every live session and open web tab appears exactly once: stored refs keep
|
||||
* their group and stored order; anything the layout has not caught up with yet
|
||||
* (a session created a moment ago, a web tab opened on this device only) is
|
||||
* appended to the ungrouped section in the caller's order. Saved web tabs that
|
||||
* are not open here are skipped, as are refs to sessions that are gone.
|
||||
*
|
||||
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!hasGroups(layout)) return null;
|
||||
const liveSessionIds = stableIds(options.liveSessionIds);
|
||||
const openWebviewIds = stableIds(options.openWebviewIds);
|
||||
const live = new Set(liveSessionIds);
|
||||
const open = new Set(openWebviewIds);
|
||||
const collapsed = new Set(asIds(options.collapsedGroupIds));
|
||||
const highlighted = options.activeWebviewId
|
||||
? `webview:${options.activeWebviewId}`
|
||||
: options.activeSessionId
|
||||
? `session:${options.activeSessionId}`
|
||||
: '';
|
||||
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
const sectionByRef = {};
|
||||
const sections = [];
|
||||
|
||||
const place = (refs, sectionId, isCollapsed) => {
|
||||
const shown = [];
|
||||
const hidden = [];
|
||||
let count = 0;
|
||||
for (const ref of refs) {
|
||||
const key = refKey(ref);
|
||||
if (placed.has(key) || !renderable(ref)) continue;
|
||||
placed.add(key);
|
||||
sectionByRef[key] = sectionId;
|
||||
count++;
|
||||
const copy = { kind: ref.kind, id: ref.id };
|
||||
if (isCollapsed && key !== highlighted) {
|
||||
hiddenTabGroupByRef[key] = sectionId;
|
||||
hidden.push(copy);
|
||||
continue;
|
||||
}
|
||||
shown.push(copy);
|
||||
visibleRefs.push(copy);
|
||||
}
|
||||
return { shown, hidden, count };
|
||||
};
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({
|
||||
id: null,
|
||||
name: '',
|
||||
refs: ungrouped.shown,
|
||||
hidden: [],
|
||||
count: ungrouped.count,
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
|
||||
/**
|
||||
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
|
||||
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
|
||||
* selection, any expanded group) draws its own alert, so it is not counted
|
||||
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
|
||||
*/
|
||||
function hiddenGroupAlerts(projection, alertOf) {
|
||||
const result = {};
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
for (const section of sections) {
|
||||
if (section.id === null || !Array.isArray(section.hidden)) continue;
|
||||
let best = null;
|
||||
for (const ref of section.hidden) {
|
||||
if (ref.kind !== 'session') continue;
|
||||
const alert = alertOf(ref.id);
|
||||
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
|
||||
}
|
||||
if (best) result[section.id] = best;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where, the headers' names, what a collapse hides), as opposed to a row's own
|
||||
* status/name/badges. The incremental render path only patches rows in place,
|
||||
* so a change here forces a full rebuild.
|
||||
*
|
||||
* Deliberately NOT the layout version: the server bumps it on every session
|
||||
* create/close and order PUT, and a bump that moves nothing visible must not
|
||||
* cost every client a full tab-strip rebuild. `layout` is accepted for
|
||||
* signature stability only.
|
||||
*/
|
||||
function structureKey(_layout, projection, collapsedGroupIds) {
|
||||
if (!projection) return null;
|
||||
return JSON.stringify({
|
||||
collapsed: stableIds(collapsedGroupIds).sort(),
|
||||
sections: projection.sections.map((section) => [
|
||||
section.id,
|
||||
section.name,
|
||||
section.count,
|
||||
section.refs.map(refKey),
|
||||
(section.hidden || []).map(refKey),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
|
||||
* `escapeHtml` is the caller's escaper. Group names are user content, so they
|
||||
* are escaped and marked `data-i18n-skip`.
|
||||
*
|
||||
* The caller makes the list itself the `tree` and marks rows up as treeitems
|
||||
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
|
||||
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
|
||||
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
|
||||
* (the rows sit below the header visually, not inside it).
|
||||
* - a COLLAPSED group owns nothing: the one row it still shows (the
|
||||
* selection) is a level-1 sibling, never the child of a closed node.
|
||||
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
|
||||
* so it is not announced as an expanded parent of an empty group.
|
||||
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
|
||||
* divider only, hidden from assistive tech, and its rows are not a group.
|
||||
*/
|
||||
function renderProjection(projection, renderRef, escapeHtml) {
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
return sections
|
||||
.map((section, index) => {
|
||||
const rows = section.refs.map((ref) => renderRef(ref)).join('');
|
||||
if (section.id === null) {
|
||||
return (
|
||||
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
|
||||
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
|
||||
);
|
||||
}
|
||||
const id = escapeHtml(section.id);
|
||||
const refsId = `tab-layout-group-refs-${index}`;
|
||||
const nameId = `tab-layout-group-name-${index}`;
|
||||
const leaf = section.count === 0;
|
||||
const expanded = !section.collapsed && !leaf;
|
||||
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
||||
return (
|
||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-wins layout loading. A response that was overtaken by a later load is
|
||||
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
|
||||
* retry, replacing any retry already pending.
|
||||
*
|
||||
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
|
||||
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
|
||||
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
|
||||
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
|
||||
* any success resets the count.
|
||||
*/
|
||||
function createLoadCoordinator(options) {
|
||||
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
|
||||
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
|
||||
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let retryHandle = null;
|
||||
let failures = 0;
|
||||
const clearRetry = () => {
|
||||
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
|
||||
retryHandle = null;
|
||||
};
|
||||
const load = async () => {
|
||||
if (disposed) return false;
|
||||
const requestGeneration = ++generation;
|
||||
clearRetry();
|
||||
try {
|
||||
const layout = await options.fetchLayout();
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures = 0;
|
||||
options.applyLayout(layout);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures++;
|
||||
options.applyFallback();
|
||||
if (failures <= maxRetries) {
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
|
||||
retryHandle = options.scheduleRetry(() => load(), delay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return {
|
||||
load,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation++;
|
||||
clearRetry();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.CodemanTabLayout = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
project,
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -77,11 +77,14 @@
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
||||
// are held there with their arrival time instead of written under it.
|
||||
// `_liveQueue` is non-null from the pull's response until its finally
|
||||
// block: live frames are held there with their arrival time instead of
|
||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._markerOwed = false;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
@@ -169,14 +172,17 @@
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||
if (ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
@@ -301,18 +307,37 @@
|
||||
}
|
||||
|
||||
// The socket's close, split out of connect() so the tests can drive it.
|
||||
// While a history pull is running the marker waits for the pull's finally
|
||||
// block: written now, it would sit above the output the pull is still
|
||||
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
|
||||
// land in the middle of a chunked replay.
|
||||
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
||||
// written now, it would sit above the output a pull is still holding (flushed
|
||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
||||
// response holds the marker too, for as long as the request takes (up to its
|
||||
// budget, see _pullHistory()).
|
||||
_onSocketClosed() {
|
||||
this._wsReady = false;
|
||||
this._wsClosed = true;
|
||||
if (!this._liveQueue) this._writeDisconnectedMarker();
|
||||
if (this._bufferLoading) this._markerOwed = true;
|
||||
else this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a history pull that ends on a
|
||||
// closed socket can write it (see _pullHistory()'s finally block).
|
||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
||||
// a closed socket. Called from each load's own finally, just before
|
||||
// _endBufferLoad() starts any trailing refresh.
|
||||
_stampMarkerIfOwed() {
|
||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
||||
// a write() on a later tick: a marker written here would land in the
|
||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
||||
const owed = this._markerOwed;
|
||||
this._markerOwed = false;
|
||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
||||
_writeDisconnectedMarker() {
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
}
|
||||
@@ -351,6 +376,7 @@
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._stampMarkerIfOwed();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
@@ -430,27 +456,44 @@
|
||||
// main thread) and replays it under the reader's current place. Holds the
|
||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||
async _pullHistory() {
|
||||
// A close before the pull already wrote its marker; one during it did not.
|
||||
const closedBefore = this._wsClosed;
|
||||
this._bufferLoading = true;
|
||||
this._liveQueue = [];
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
// Two budgets on one signal. The request itself gets the primary pane's
|
||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
||||
// for that whole budget. Once the headers land live output IS held, so the
|
||||
// body read gets the short one instead: a body that hangs would otherwise
|
||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
||||
// be re-armed, hence the controller; without AbortController the pull
|
||||
// simply has no deadline.
|
||||
const controller = global.AbortController ? new global.AbortController() : null;
|
||||
let abortTimer = null;
|
||||
const armDeadline = (ms) => {
|
||||
if (!controller) return;
|
||||
clearTimeout(abortTimer);
|
||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
||||
};
|
||||
try {
|
||||
// A deadline, because live output is held for as long as this runs: a
|
||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
||||
// lands in the catch below, which releases the flag and the queue. It
|
||||
// covers the body read too, not just the headers.
|
||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
||||
signal: controller?.signal,
|
||||
});
|
||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
||||
// The cutoff below is the response's arrival, the same `since` rule the
|
||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||
// for the instant tmux took the capture, which lies somewhere in the
|
||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||
// by one round trip and not closable without a server-side capture time.
|
||||
capturedAt = performance.now();
|
||||
// Opened only now: a frame from before the response is either replaced by
|
||||
// the capture or written unchanged, so holding it for the round trip
|
||||
// bought nothing and froze the pane for as long as the fetch took.
|
||||
this._liveQueue = [];
|
||||
const payload = (await res.json())?.data;
|
||||
clearTimeout(abortTimer);
|
||||
const buffer = payload?.terminalBuffer;
|
||||
const term = this.terminal;
|
||||
if (!buffer || !term || this._destroyed) return;
|
||||
@@ -476,6 +519,7 @@
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
replayed = true;
|
||||
if (this._wsClosed) this._markerOwed = true;
|
||||
await writeChunked(term, buffer, () => this._destroyed);
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// xterm parses asynchronously: an empty write's callback fires only
|
||||
@@ -490,6 +534,7 @@
|
||||
} catch {
|
||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
const queued = this._liveQueue ?? [];
|
||||
this._liveQueue = null;
|
||||
// After a replay, only frames that arrived after the capture are news;
|
||||
@@ -500,15 +545,14 @@
|
||||
if (entry.clear) this.terminal?.clear();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// A replay's own `\x1bc` wipes a marker written before the pull,
|
||||
// painting a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket, so re-stamp it
|
||||
// after a replay. A close DURING the pull wrote no marker at all
|
||||
// (_onSocketClosed() defers it while the queue is live), so write it
|
||||
// whether or not this pull replayed. Checked after the queue flush so
|
||||
// it is the last thing on screen, matching what the close would have
|
||||
// left had the pull never run.
|
||||
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
|
||||
// Settled after the queue flush so the marker is the last thing on
|
||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
||||
// one written before it, which would paint a fresh, current-looking
|
||||
// history while onData keeps silently dropping every keystroke on the
|
||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
||||
// is left to that refresh, which writes it below its own replay.
|
||||
this._stampMarkerIfOwed();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
@@ -525,6 +569,10 @@
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
||||
// so a refresh on a closed socket owes it back once its replay is written.
|
||||
if (this._wsClosed) this._markerOwed = true;
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
||||
// focus itself, and the accessory bar, which is built to be used while the
|
||||
// keyboard is open.
|
||||
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
|
||||
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
|
||||
// headers are listed by role instead, or tapping one would drop the keyboard.
|
||||
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
|
||||
'input',
|
||||
'textarea',
|
||||
@@ -64,6 +67,7 @@
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="treeitem"]',
|
||||
'.keyboard-accessory-bar',
|
||||
'.path-picker-overlay',
|
||||
].join(',');
|
||||
@@ -250,6 +254,235 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
_destroyMobileImePreview() {
|
||||
try {
|
||||
this._mobileImePreview?.destroy?.();
|
||||
} catch {
|
||||
// The preview is visual-only; terminal replacement must continue.
|
||||
}
|
||||
this._mobileImePreview = null;
|
||||
this._mobileImePreviewSessionId = null;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreviewNode?.remove?.();
|
||||
} catch {
|
||||
// Best-effort node cleanup only.
|
||||
}
|
||||
try {
|
||||
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
|
||||
} catch {
|
||||
// Best-effort ownership cleanup only.
|
||||
}
|
||||
this._mobileImePreviewNode = null;
|
||||
this._mobileImePreviewHelpers = null;
|
||||
try {
|
||||
if (this._mobileImePreviewOfflineHandler) {
|
||||
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
}
|
||||
if (this._mobileImePreviewPagehideHandler) {
|
||||
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort listener cleanup only.
|
||||
}
|
||||
this._mobileImePreviewOfflineHandler = null;
|
||||
this._mobileImePreviewPagehideHandler = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
|
||||
* text an IME is composing inside the terminal, so the user types blind.
|
||||
*
|
||||
* Two homes, chosen per render:
|
||||
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
|
||||
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
|
||||
* `.xterm-screen`). So the overlay draws the composition itself, as an
|
||||
* underlined tail after its pending text (`setComposition`).
|
||||
* - Otherwise (a shell, or the overlay could not place it): a span inside
|
||||
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
|
||||
* the helper textarea, which follow the PTY cursor.
|
||||
*
|
||||
* Visual only: nothing here touches the input path, and every failure
|
||||
* leaves no DOM behind.
|
||||
*/
|
||||
_initMobileImePreview() {
|
||||
this._destroyMobileImePreview();
|
||||
let preview = null;
|
||||
let helpers = null;
|
||||
try {
|
||||
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
|
||||
const textarea = this.terminal?.textarea;
|
||||
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
|
||||
if (!textarea || !helpers) return;
|
||||
|
||||
preview = document.createElement('span');
|
||||
this._mobileImePreviewNode = preview;
|
||||
this._mobileImePreviewHelpers = helpers;
|
||||
preview.className = 'codeman-ime-preview';
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
preview.hidden = true;
|
||||
helpers.appendChild(preview);
|
||||
const syncPreviewTypography = () => {
|
||||
try {
|
||||
const compositionView =
|
||||
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
|
||||
if (!compositionView || !preview.style) return;
|
||||
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
|
||||
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
|
||||
const value = style?.[property] || compositionView.style?.[property];
|
||||
if (value) preview.style[property] = value;
|
||||
}
|
||||
const theme = this.terminal?.options?.theme;
|
||||
let foreground = theme?.foreground;
|
||||
let background = theme?.background;
|
||||
if (!foreground || !background) {
|
||||
try {
|
||||
const current = window.codemanCurrentXtermTheme?.();
|
||||
foreground = foreground || current?.foreground;
|
||||
background = background || current?.background;
|
||||
} catch {
|
||||
// Theme lookup is best-effort; retain the safe terminal fallback.
|
||||
}
|
||||
}
|
||||
preview.style.color = foreground || '#e0e0e0';
|
||||
// Opaque, like xterm's own composition view, so the preview does not
|
||||
// overprint whatever sits at the cursor (a dim composer placeholder).
|
||||
preview.style.backgroundColor = background || '#0d0d0d';
|
||||
} catch {
|
||||
// Typography matching is visual-only and must not block input.
|
||||
}
|
||||
};
|
||||
// The overlay only when it is what shows typed text right now (local echo
|
||||
// on, and not handed back to plain PTY echo by a composer nav key).
|
||||
const localEchoOverlay = () =>
|
||||
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
|
||||
? this._localEchoOverlay || null
|
||||
: null;
|
||||
const clearOverlayComposition = () => {
|
||||
try {
|
||||
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
|
||||
} catch {}
|
||||
};
|
||||
const hideSpan = () => {
|
||||
try {
|
||||
preview.hidden = true;
|
||||
} catch {}
|
||||
try {
|
||||
preview.textContent = '';
|
||||
} catch {}
|
||||
try {
|
||||
delete preview.dataset.phase;
|
||||
} catch {}
|
||||
try {
|
||||
helpers.classList.remove('codeman-ime-preview-owned');
|
||||
} catch {}
|
||||
};
|
||||
const clearPreview = () => {
|
||||
clearOverlayComposition();
|
||||
hideSpan();
|
||||
};
|
||||
const controller = MobileImePreview.create({
|
||||
textarea,
|
||||
// An ancestor of the textarea: its capture-phase keydown listener runs
|
||||
// before xterm's capture listener on the textarea, which finalizes the
|
||||
// composition and emits the commit synchronously.
|
||||
keydownTarget: this.terminal.element,
|
||||
render: ({ text, phase }) => {
|
||||
try {
|
||||
const overlay = localEchoOverlay();
|
||||
if (overlay && typeof overlay.setComposition === 'function') {
|
||||
overlay.setComposition(text);
|
||||
// No prompt found = nothing drawn: fall back to the span.
|
||||
if (!text || overlay.state?.visible) {
|
||||
hideSpan();
|
||||
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
|
||||
return;
|
||||
}
|
||||
overlay.setComposition('');
|
||||
} else {
|
||||
clearOverlayComposition();
|
||||
}
|
||||
syncPreviewTypography();
|
||||
preview.textContent = text;
|
||||
preview.dataset.phase = phase;
|
||||
preview.hidden = !text;
|
||||
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
|
||||
} catch {
|
||||
clearPreview();
|
||||
}
|
||||
},
|
||||
clear: clearPreview,
|
||||
});
|
||||
this._mobileImePreview = controller;
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
|
||||
// Offline and pagehide only reset: initTerminal() runs once per page
|
||||
// load, so destroying on pagehide would leave the preview off for good
|
||||
// after a back-forward cache restore (iOS Safari keeps pages there).
|
||||
this._mobileImePreviewOfflineHandler = () => {
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// Disconnect cleanup is visual-only.
|
||||
}
|
||||
};
|
||||
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
|
||||
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
|
||||
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
|
||||
} catch {
|
||||
this._destroyMobileImePreview();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tell the IME preview about a chunk xterm emitted through onData. Returns
|
||||
* true when the chunk is the IME's committed text, in which case the preview
|
||||
* holds it (phase 'committed') until something else shows it. Never throws.
|
||||
*/
|
||||
_consumeMobileImeTerminalData(data) {
|
||||
let isImeCommit = false;
|
||||
try {
|
||||
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
|
||||
} catch {
|
||||
// The preview is visual-only; normal terminal input must continue.
|
||||
}
|
||||
// Output accepted from here on can carry the echo of this commit.
|
||||
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
|
||||
return isImeCommit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear a committed IME preview once terminal output accepted AFTER the
|
||||
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
|
||||
* written flush covered (null when part of it was deferred), so output that
|
||||
* was already queued before the commit can never clear it early.
|
||||
*/
|
||||
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
|
||||
try {
|
||||
const commitSeq = this._mobileImeCommitOutputSeq;
|
||||
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
|
||||
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
this._mobileImePreview?.noteAuthoritativeOutput?.();
|
||||
} catch {
|
||||
// Authoritative output is never delayed or consumed by the preview.
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The local echo overlay has just taken a committed IME chunk through the
|
||||
* ordinary printable/paste branch, so it now shows the text: release the
|
||||
* preview instead of waiting for terminal output.
|
||||
*/
|
||||
_transferMobileImeCommitToLocalEcho() {
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.completeCommit?.({ predicted: true });
|
||||
} catch {
|
||||
// Ownership transfer is visual-only.
|
||||
}
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._initMobileImePreview();
|
||||
this._installMobileTapMouseGuard();
|
||||
this._installShiftDragSelection();
|
||||
this._installTouchSelectionFocusGuard();
|
||||
@@ -447,8 +681,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
// which the inner application recognizes as "insert newline" vs carriage return.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
|
||||
// but NOT one carrying only Shift, so unless every event type is swallowed here,
|
||||
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||
if (ev.type === 'keydown' && this.activeSessionId) {
|
||||
if (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// has to re-resolve their gate before the redraw, not just move them.
|
||||
this.applyLineageLineSettings?.();
|
||||
this.updateConnectionLines();
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Unguarded on purpose: hasPending excludes an IME composition, so a
|
||||
// composition-only overlay would stay on the old prompt row. rerender()
|
||||
// is a no-op when the overlay has nothing to draw.
|
||||
this._localEchoOverlay?.rerender();
|
||||
// Pane B (split view) has its own container and its own fit()/resize
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
@@ -1212,6 +1450,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
const handleTerminalData = (data) => {
|
||||
// Before anything can rewrite `data`: is this chunk the IME's commit?
|
||||
const isImeCommit = this._consumeMobileImeTerminalData(data);
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1289,6 +1529,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// When enabled, keystrokes are buffered locally in the overlay for
|
||||
// instant visual feedback. Nothing is sent to the PTY until Enter
|
||||
// (or a control char) is pressed — avoids out-of-order char delivery.
|
||||
// An IME commit takes the same printable/paste branch as typed text,
|
||||
// and the overlay then shows it in place of the preview.
|
||||
if (this._localEchoEnabled && !echoPassthrough) {
|
||||
if (data === '\x7f') {
|
||||
const source = this._localEchoOverlay?.removeChar();
|
||||
@@ -1345,6 +1587,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (data.length > 1 && data.charCodeAt(0) >= 32) {
|
||||
// Paste: append to overlay only (sent on Enter)
|
||||
this._localEchoOverlay?.appendText(data);
|
||||
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
|
||||
return;
|
||||
}
|
||||
if (data.charCodeAt(0) < 32) {
|
||||
@@ -1490,6 +1733,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
// Printable char: add to overlay only (sent on Enter)
|
||||
this._localEchoOverlay?.addChar(data);
|
||||
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3525,6 +3769,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
batchTerminalWrite(data) {
|
||||
// Arrival order of output, so the IME preview can tell output that
|
||||
// followed a commit from output that was already queued before it.
|
||||
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
|
||||
// Feed the renderer watchdog. Recorded before the buffer-load early return
|
||||
// below: a write that is queued rather than written still means the pipeline
|
||||
// owes us a frame once it drains.
|
||||
@@ -3588,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Accumulate raw data (may contain DEC 2026 markers)
|
||||
this.pendingWrites.push(data);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this._scheduleTerminalWriteFlush();
|
||||
},
|
||||
|
||||
@@ -3619,6 +3867,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Transfer buffered data to normal pending writes
|
||||
this.pendingWrites.push(this.flickerFilterBuffer);
|
||||
this._pendingWritesOutputSeq = this._terminalOutputSeq;
|
||||
this.flickerFilterBuffer = '';
|
||||
this.flickerFilterActive = false;
|
||||
|
||||
@@ -3649,6 +3898,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Position is tracked dynamically by _findPrompt() on every render.
|
||||
*/
|
||||
_updateLocalEchoState() {
|
||||
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
|
||||
this._mobileImePreviewSessionId = this.activeSessionId;
|
||||
this._mobileImeCommitOutputSeq = null;
|
||||
try {
|
||||
this._mobileImePreview?.reset?.();
|
||||
} catch {
|
||||
// The preview is visual-only; session switching must continue.
|
||||
}
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
@@ -3854,6 +4112,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
|
||||
deferred = true;
|
||||
}
|
||||
// Newest output this chunk fully contains, for the IME preview. A split
|
||||
// chunk may not hold that output yet, so it reports nothing.
|
||||
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
|
||||
this._terminalWriteInFlight = true;
|
||||
this._terminalWriteInFlightBytes = writeChunk.length;
|
||||
try {
|
||||
@@ -3871,6 +4132,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// because the test's write mock moved the viewport synchronously.)
|
||||
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
|
||||
});
|
||||
} catch (err) {
|
||||
this._terminalWriteInFlight = false;
|
||||
@@ -3900,9 +4162,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Re-position local echo overlay after terminal writes — Ink redraws can
|
||||
// move the ❯ prompt to a different row, making the overlay invisible.
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Unguarded on purpose: hasPending excludes an IME composition, so a
|
||||
// composition-only overlay (the first word of a prompt) would otherwise
|
||||
// stay on the old row. rerender() is a no-op when there is nothing to draw.
|
||||
this._localEchoOverlay?.rerender();
|
||||
|
||||
// After Tab completion: detect the completed text in the overlay.
|
||||
// Use terminal.write('', callback) to defer detection until xterm.js
|
||||
|
||||
@@ -304,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
if (!this.webviews.get(id)) continue;
|
||||
parts.push(this.renderWebviewTab(id, idx));
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
/**
|
||||
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
|
||||
* The grouped vertical rail places single web tabs into their group with this,
|
||||
* so a web tab's markup is the same in every layout.
|
||||
*/
|
||||
renderWebviewTab(id, idx) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return '';
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
@@ -322,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span></span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
@@ -348,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
* session record involved. A dropped plan therefore returns the user to
|
||||
* resuming by hand, one at a time, which is where they are without this
|
||||
* feature. What the plan held that a transcript does not is the owner, the
|
||||
* name, the env overrides, the effort, the advisor model and the lineage.
|
||||
* name, the env overrides, the effort, the model, the advisor model and the
|
||||
* lineage.
|
||||
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
|
||||
* import and no IO, which keeps it unit-testable and cycle-free.
|
||||
* - Spending is take-then-build: `take()` removes entries synchronously, before
|
||||
|
||||
@@ -28,6 +28,8 @@ export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @fileoverview MCP server sync (src/mcp-sync.ts).
|
||||
*
|
||||
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
|
||||
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
|
||||
*
|
||||
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
|
||||
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
|
||||
* is admin only. A second apply while one is running answers 409. Responses carry server names
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type McpSyncResult,
|
||||
} from '../../types.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
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';
|
||||
|
||||
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
|
||||
export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.mcpSyncEnabled === true;
|
||||
}
|
||||
|
||||
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
|
||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||
return enabledClis()
|
||||
.filter((e) => e.capabilities.mcpConfig)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
...e.capabilities.mcpConfig!,
|
||||
installed: isCliEntryInstalled(e, availability),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
|
||||
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
|
||||
*/
|
||||
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
|
||||
return enabledClis()
|
||||
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
|
||||
.map((e) => e.label);
|
||||
}
|
||||
|
||||
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
if (!(await readMcpSyncEnabled())) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerMcpSyncRoutes(app: FastifyInstance): void {
|
||||
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
|
||||
const denied = await gate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
try {
|
||||
const availability = await probeStockCliAvailability();
|
||||
const targets = mcpSyncTargets(availability);
|
||||
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
if (err instanceof McpSyncBusyError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
|
||||
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
|
||||
}
|
||||
@@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
|
||||
),
|
||||
effort: saved.effort,
|
||||
model: saved.model,
|
||||
advisorModel: saved.advisorModel,
|
||||
attachmentHistory:
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import {
|
||||
Session,
|
||||
cliTakesSessionModel,
|
||||
isAltScreenStripMode,
|
||||
isExternalCliMode,
|
||||
isMuxAltScreenOnlyStripMode,
|
||||
} from '../../session.js';
|
||||
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
@@ -888,6 +895,25 @@ export function registerSessionRoutes(
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
|
||||
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
|
||||
// dropped without a word; refuse it before anything is written for the session.
|
||||
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
|
||||
);
|
||||
}
|
||||
// An attach launches nothing (the remote agent is already running), so a launch model
|
||||
// or advisor would be dropped the same way, so both are refused, as they have been since
|
||||
// they were added. The older launch fields (effort, envOverrides) predate this and keep
|
||||
// their silent ignore here, since refusing them now would break existing callers.
|
||||
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
|
||||
);
|
||||
}
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
let remote = undefined;
|
||||
|
||||
@@ -1073,9 +1099,10 @@ export function registerSessionRoutes(
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
// in that CLI's own config object.
|
||||
// 'claude-settings-file' — claude alone, whose model is written to
|
||||
// <case>/.claude/settings.local.json rather than passed as
|
||||
// a flag, so the app-wide default applies here.
|
||||
// 'claude-settings-file' — claude alone, whose persistent model is written to
|
||||
// <case>/.claude/settings.local.json (`modelOverride`). A
|
||||
// per-session `model` from the caller goes out as --model and
|
||||
// wins; without one, the app-wide default applies.
|
||||
// 'none' — shell has no model; deepseek's is a composition entry in
|
||||
// the profile's config tree, not a session field
|
||||
// (docs/deepseek-integration.md). Both get nothing.
|
||||
@@ -1086,7 +1113,7 @@ export function registerSessionRoutes(
|
||||
| string
|
||||
| undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
? body.model || modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
@@ -2093,21 +2120,16 @@ export function registerSessionRoutes(
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
|
||||
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
|
||||
// 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
|
||||
// Map key names to hex byte sequences
|
||||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||||
'S-Enter': ['0a'], // \n (line feed)
|
||||
'C-Enter': ['0a'], // \n (line feed)
|
||||
};
|
||||
const hex = KEY_HEX_MAP[key];
|
||||
if (!hex) {
|
||||
if (key !== 'S-Enter' && key !== 'C-Enter') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
@@ -2117,6 +2139,18 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
|
||||
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
|
||||
// Esc+Enter declares it in the registry instead of being special-cased here.
|
||||
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
|
||||
'line-feed': ['0a'], // \n
|
||||
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
|
||||
};
|
||||
const hex =
|
||||
key === 'C-Enter'
|
||||
? NEWLINE_HEX['line-feed']
|
||||
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
|
||||
|
||||
try {
|
||||
// Route through the dedicated Codeman socket — bare `tmux` would target the
|
||||
// user's default server and never find this session (same #80 regression class).
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
|
||||
*
|
||||
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
|
||||
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
|
||||
* POST /api/webhook/test — send one test message with the saved config
|
||||
*
|
||||
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
|
||||
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
|
||||
* the channel receives every session's events, the same reach an admin's own Web Push has.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type WebhookResult,
|
||||
type WebhookStatus,
|
||||
} from '../../types.js';
|
||||
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { WebhookUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
maskWebhookUrl,
|
||||
readWebhookConfig,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookNotifier,
|
||||
} from '../../webhook-notify.js';
|
||||
|
||||
export interface WebhookRouteDeps {
|
||||
notifier: WebhookNotifier;
|
||||
configDir: string;
|
||||
/** The instance's window title, so a test message says which machine sent it. */
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
|
||||
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<WebhookStatus> => {
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
kind: cfg.kind,
|
||||
scope: cfg.scope,
|
||||
hasUrl: cfg.url !== '',
|
||||
urlMasked: maskWebhookUrl(cfg.url),
|
||||
lastResult: deps.notifier.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
|
||||
const current = await readWebhookConfig(deps.configDir);
|
||||
const next = {
|
||||
enabled: patch.enabled ?? current.enabled,
|
||||
kind: patch.kind ?? current.kind,
|
||||
scope: patch.scope ?? current.scope,
|
||||
url: patch.url !== undefined ? patch.url.trim() : current.url,
|
||||
};
|
||||
if (next.url) {
|
||||
const problem = webhookUrlProblem(next.url);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
}
|
||||
if (next.enabled && !next.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
|
||||
}
|
||||
try {
|
||||
await writeWebhookConfig(deps.configDir, next);
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
if (!cfg.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
|
||||
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
@@ -313,6 +315,7 @@ const CodexConfigSchema = z
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
@@ -543,6 +546,21 @@ export const CreateSessionSchema = z.object({
|
||||
advisorModel: advisorModelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/**
|
||||
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
|
||||
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
|
||||
* pattern, so a value accepted here is never rejected at launch. The first character must be
|
||||
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
|
||||
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
|
||||
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
|
||||
* it for any other CLI and on a remote attach.
|
||||
*/
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
|
||||
.or(z.literal(''))
|
||||
.optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
@@ -1346,6 +1364,13 @@ export const SettingsUpdateSchema = z
|
||||
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
|
||||
*/
|
||||
cliManagementEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
|
||||
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
|
||||
* the server user's home (including any env values and headers on the servers), so it is
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
@@ -1871,6 +1896,20 @@ export const PushPreferencesUpdateSchema = z.object({
|
||||
pushPreferences: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
|
||||
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
|
||||
* lists are the store's own, so the schema can never accept a value the store would coerce away.
|
||||
*/
|
||||
export const WebhookUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
kind: z.enum(WEBHOOK_KINDS).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
url: z.string().max(2048).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
|
||||
+55
-22
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
|
||||
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
|
||||
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
|
||||
import type { WebhookUrgency } from '../types/push.js';
|
||||
import { webviewFetch } from './webview-egress.js';
|
||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||
import type { RemoteWakeRegistry } from '../remote-wake.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
@@ -197,6 +200,8 @@ import {
|
||||
registerVoiceRoutes,
|
||||
registerWebviewRoutes,
|
||||
registerTabLayoutRoutes,
|
||||
registerMcpSyncRoutes,
|
||||
registerWebhookRoutes,
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
readCustomModelEndpointsEnabled,
|
||||
@@ -368,6 +373,8 @@ export class WebServer extends EventEmitter {
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
|
||||
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
@@ -1130,6 +1137,12 @@ export class WebServer extends EventEmitter {
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerMcpSyncRoutes(this.app);
|
||||
registerWebhookRoutes(this.app, {
|
||||
notifier: this.webhookNotifier,
|
||||
configDir: getDataDir(),
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
|
||||
@@ -2652,6 +2665,25 @@ export class WebServer extends EventEmitter {
|
||||
const template = WebServer.PUSH_EVENT_MAP[event];
|
||||
if (!template) return;
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
const body = WebServer.pushBodyText(event, data, sessionName);
|
||||
|
||||
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
|
||||
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
|
||||
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
|
||||
void this.webhookNotifier
|
||||
.notify({
|
||||
event,
|
||||
title: template.title,
|
||||
body,
|
||||
urgency: template.urgency as WebhookUrgency,
|
||||
sessionId: sessionId || undefined,
|
||||
sessionName: sessionName || undefined,
|
||||
host: this.windowTitle,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscriptions = this.pushStore.getAll();
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
@@ -2670,9 +2702,6 @@ export class WebServer extends EventEmitter {
|
||||
const vapidKeys = this.pushStore.getVapidKeys();
|
||||
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
@@ -2680,25 +2709,6 @@ export class WebServer extends EventEmitter {
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: template.title,
|
||||
// Hostname-aware prefix so OS-level notifications from multiple Codeman
|
||||
@@ -2752,6 +2762,28 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** The notification body for an event (shared by Web Push and the webhook channel). */
|
||||
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private cleanupDeadSSEClients(): void {
|
||||
this.sse.cleanupDeadClients();
|
||||
}
|
||||
@@ -3489,6 +3521,7 @@ export class WebServer extends EventEmitter {
|
||||
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
model: savedState?.model,
|
||||
advisorModel: savedState?.advisorModel,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// The pane's last Enter. Without it the response viewer would show
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
|
||||
* already trigger Web Push, so a headless server can reach a phone without a browser tab or a
|
||||
* push subscription.
|
||||
*
|
||||
* Split in three, so the parts that matter are testable without a network:
|
||||
* - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
|
||||
* - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
|
||||
* anyone holding a Slack/Discord webhook URL can post as it)
|
||||
* - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
|
||||
* `settings.json`, which every logged-in user can read through `GET /api/settings`.
|
||||
* - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
|
||||
* the RESOLVED address at connect time, redirects are not followed, and the call is bounded
|
||||
* by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
|
||||
* - The URL never appears in a log line, a result, or an error message.
|
||||
* - Session names and error text are user/agent-controlled, so they cannot ping a channel:
|
||||
* Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
|
||||
*
|
||||
* @module webhook-notify
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
|
||||
import { isEgressBlockedError } from './web/webview-egress.js';
|
||||
import {
|
||||
WEBHOOK_KINDS,
|
||||
WEBHOOK_SCOPES,
|
||||
type WebhookConfig,
|
||||
type WebhookKind,
|
||||
type WebhookResult,
|
||||
type WebhookScope,
|
||||
type WebhookUrgency,
|
||||
} from './types/push.js';
|
||||
|
||||
const WEBHOOK_FILE = 'webhook.json';
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const SEND_TIMEOUT_MS = 5000;
|
||||
const MAX_BODY_CHARS = 500;
|
||||
/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
|
||||
const DEDUPE_WINDOW_MS = 3000;
|
||||
const MAX_IN_FLIGHT = 5;
|
||||
|
||||
export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
|
||||
|
||||
export interface WebhookMessage {
|
||||
event: string;
|
||||
title: string;
|
||||
body: string;
|
||||
urgency: WebhookUrgency;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
/** The Codeman instance's window title, so several machines are told apart. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
|
||||
export function webhookUrlProblem(raw: string): string | null {
|
||||
if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return 'Not a valid URL';
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
|
||||
if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
|
||||
const blocked = blockedWebviewHostReason(url.hostname);
|
||||
if (blocked) return `Refused: ${blocked}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scheme + host only: the path and query of a webhook URL are the secret. */
|
||||
export function maskWebhookUrl(raw: string): string {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return `${url.protocol}//${url.host}/•••`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
|
||||
if (!cfg.enabled || !cfg.url) return false;
|
||||
return cfg.scope === 'all' || urgency !== 'info';
|
||||
}
|
||||
|
||||
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
||||
|
||||
/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
|
||||
function headerSafe(value: string): string {
|
||||
const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
|
||||
return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
/** Slack parses `<!channel>`, `<@U123>` and `<url|text>`; escaping the three control characters turns them to text. */
|
||||
const slackEscape = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const NTFY_PRIORITY: Record<WebhookUrgency, string> = { critical: '5', warning: '4', info: '3' };
|
||||
const NTFY_TAGS: Record<WebhookUrgency, string> = {
|
||||
critical: 'rotating_light',
|
||||
warning: 'bell',
|
||||
info: 'white_check_mark',
|
||||
};
|
||||
|
||||
export interface WebhookRequest {
|
||||
method: 'POST';
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
|
||||
const title = clip(msg.title, 120);
|
||||
const body = clip(msg.body, MAX_BODY_CHARS);
|
||||
const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
|
||||
switch (kind) {
|
||||
case 'ntfy':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Title: headerSafe(`${prefix}${title}`),
|
||||
Priority: NTFY_PRIORITY[msg.urgency],
|
||||
Tags: NTFY_TAGS[msg.urgency],
|
||||
},
|
||||
body: body || title,
|
||||
};
|
||||
case 'slack':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
|
||||
};
|
||||
case 'discord':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
|
||||
// Agent output and session names are not trusted to @everyone a channel.
|
||||
allowed_mentions: { parse: [] },
|
||||
}),
|
||||
};
|
||||
case 'generic':
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: msg.event,
|
||||
title,
|
||||
body,
|
||||
urgency: msg.urgency,
|
||||
sessionId: msg.sessionId ?? null,
|
||||
sessionName: msg.sessionName ?? null,
|
||||
host: msg.host ?? null,
|
||||
at: now.toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function webhookConfigPath(configDir: string): string {
|
||||
return join(configDir, WEBHOOK_FILE);
|
||||
}
|
||||
|
||||
function coerce(raw: unknown): WebhookConfig {
|
||||
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
return {
|
||||
enabled: r.enabled === true,
|
||||
kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
|
||||
? (r.kind as WebhookKind)
|
||||
: DEFAULT_WEBHOOK_CONFIG.kind,
|
||||
url: typeof r.url === 'string' ? r.url : '',
|
||||
scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
|
||||
? (r.scope as WebhookScope)
|
||||
: DEFAULT_WEBHOOK_CONFIG.scope,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWebhookConfig(configDir: string): Promise<WebhookConfig> {
|
||||
try {
|
||||
return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
|
||||
} catch {
|
||||
return { ...DEFAULT_WEBHOOK_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
|
||||
export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
const target = webhookConfigPath(configDir);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmp).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
|
||||
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
|
||||
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
|
||||
*/
|
||||
function describeError(err: unknown): string {
|
||||
const e = err as { name?: string; cause?: { code?: string } };
|
||||
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
|
||||
if (isEgressBlockedError(err)) return 'Refused: target is a link-local or cloud-metadata address';
|
||||
if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
|
||||
if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
|
||||
return 'Network error';
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
cfg: Pick<WebhookConfig, 'kind' | 'url'>,
|
||||
msg: WebhookMessage,
|
||||
fetchImpl: WebhookFetch
|
||||
): Promise<WebhookResult> {
|
||||
const at = Date.now();
|
||||
const problem = webhookUrlProblem(cfg.url);
|
||||
if (problem) return { ok: false, error: problem, at };
|
||||
const req = buildWebhookRequest(cfg.kind, msg);
|
||||
try {
|
||||
const res = await fetchImpl(new URL(cfg.url), {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
});
|
||||
void res.body?.cancel().catch(() => undefined);
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
|
||||
}
|
||||
return res.ok
|
||||
? { ok: true, status: res.status, at }
|
||||
: { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
|
||||
} catch (err) {
|
||||
return { ok: false, error: describeError(err), at };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
|
||||
* never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
|
||||
* the last result for the Settings status line.
|
||||
*/
|
||||
export class WebhookNotifier {
|
||||
private lastSent = new Map<string, number>();
|
||||
private inFlight = 0;
|
||||
private last: WebhookResult | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly load: () => Promise<WebhookConfig>,
|
||||
private readonly fetchImpl: WebhookFetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
get lastResult(): WebhookResult | null {
|
||||
return this.last;
|
||||
}
|
||||
|
||||
async notify(msg: WebhookMessage): Promise<void> {
|
||||
const cfg = await this.load();
|
||||
if (!shouldSendWebhook(cfg, msg.urgency)) return;
|
||||
const key = `${msg.event}:${msg.sessionId ?? ''}`;
|
||||
const t = this.now();
|
||||
const prev = this.lastSent.get(key);
|
||||
if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
|
||||
if (this.inFlight >= MAX_IN_FLIGHT) return;
|
||||
this.lastSent.set(key, t);
|
||||
if (this.lastSent.size > 256) {
|
||||
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
|
||||
}
|
||||
this.inFlight++;
|
||||
try {
|
||||
this.last = await sendWebhook(cfg, msg, this.fetchImpl);
|
||||
} finally {
|
||||
this.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
/** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
|
||||
async sendTest(cfg: Pick<WebhookConfig, 'kind' | 'url'>, host?: string): Promise<WebhookResult> {
|
||||
const result = await sendWebhook(
|
||||
cfg,
|
||||
{
|
||||
event: 'webhook:test',
|
||||
title: 'Codeman test notification',
|
||||
body: 'If you can read this, webhook notifications are working.',
|
||||
urgency: 'info',
|
||||
host,
|
||||
},
|
||||
this.fetchImpl
|
||||
);
|
||||
this.last = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+108
-1
@@ -12,7 +12,7 @@
|
||||
* assertions see exactly what a spawned pane would.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
@@ -27,6 +27,23 @@ import {
|
||||
|
||||
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
|
||||
|
||||
/**
|
||||
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
|
||||
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
|
||||
* stand-in is a real process so `Session.stop()` has a real pid to signal.
|
||||
*/
|
||||
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
|
||||
vi.mock('node-pty', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node-pty')>();
|
||||
return {
|
||||
...real,
|
||||
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
|
||||
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
|
||||
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function extractSettingsJson(cmd: string): unknown {
|
||||
const idx = cmd.indexOf('--settings ');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
@@ -164,6 +181,96 @@ describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
|
||||
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
|
||||
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
|
||||
* the same pair is pinned in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
describe('a per-session model together with an advisor', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const cases = [
|
||||
['ultracode', { ultracode: true, advisorModel: 'opus' }],
|
||||
['high', { advisorModel: 'opus' }],
|
||||
[undefined, { advisorModel: 'opus' }],
|
||||
] as const;
|
||||
|
||||
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort,
|
||||
advisorModel: 'opus',
|
||||
resumeSessionId,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
// The resume variant renders `resume || new`, so the model appears once per branch.
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
const settingsFlags = cmd.match(/--settings /g) ?? [];
|
||||
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
|
||||
expect(extractSettingsJson(cmd)).toEqual(settings);
|
||||
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the statusLine exporter in the same object beside the model', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
|
||||
});
|
||||
|
||||
describe('a real Session on the direct-PTY fallback', () => {
|
||||
const live: Session[] = [];
|
||||
afterEach(async () => {
|
||||
for (const s of live.splice(0)) await s.stop();
|
||||
ptySpawns.length = 0;
|
||||
});
|
||||
|
||||
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: false,
|
||||
model: MODEL,
|
||||
advisorModel: 'opus',
|
||||
effort,
|
||||
});
|
||||
live.push(session);
|
||||
await session.startInteractive();
|
||||
|
||||
expect(ptySpawns).toHaveLength(1);
|
||||
const { args } = ptySpawns[0];
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session advisorModel', () => {
|
||||
it('stores a valid advisor and persists it through toState()', () => {
|
||||
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment node
|
||||
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
|
||||
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
describe('capabilities.newline', () => {
|
||||
it('no stock CLI declares a chord: every one keeps the line feed', () => {
|
||||
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
|
||||
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
|
||||
expect(declared).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(false);
|
||||
});
|
||||
|
||||
it('is optional, so an entry that declares nothing keeps the line feed', () => {
|
||||
const e = claude();
|
||||
delete (e.capabilities as Record<string, unknown>).newline;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
/** A fixed session id, so `--session-id` is stable across runs. */
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
@@ -54,6 +55,20 @@ describe('claude', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
|
||||
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
|
||||
// pattern still admits one, so the builder must stay safe on its own: the value lands
|
||||
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
|
||||
// it starts with, so it can never become a flag of its own.
|
||||
expect(claude({ model: 'claude-fable-5-1' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
|
||||
);
|
||||
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--model "--dangerously-skip-permissions"'
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes through a shell fallback to a fresh session', () => {
|
||||
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
|
||||
// text. This pin is what proves the fallback chain still renders as one command line.
|
||||
@@ -131,6 +146,18 @@ describe('codex', () => {
|
||||
it('resumes with a POSITIONAL subcommand, not a flag', () => {
|
||||
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
|
||||
});
|
||||
|
||||
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoning effort ahead of the resume subcommand', () => {
|
||||
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
|
||||
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini', () => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
|
||||
*
|
||||
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
|
||||
* schema admits only the words codex knows; anything else fails the request rather than
|
||||
* reaching the argv.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
describe('codexConfig.reasoningEffort', () => {
|
||||
it('accepts every level codex knows on both create routes', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
const created = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(created.codexConfig?.reasoningEffort).toBe(level);
|
||||
const quick = QuickStartSchema.parse({
|
||||
caseName: 'work',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(quick.codexConfig?.reasoningEffort).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
|
||||
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* hook definitions for desktop notifications.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
ensureStatusLineExporterScript,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
|
||||
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
|
||||
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
|
||||
// A frozen clock makes every writer agree on the timestamp, which is what two
|
||||
// session creates in one millisecond do. A temp name built from pid + Date.now()
|
||||
// is then shared: the first rename consumes it, every later rename fails ENOENT,
|
||||
// and that session's tmux create fell back to a direct PTY.
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
try {
|
||||
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
|
||||
expect(new Set(paths)).toEqual(new Set([scriptPath]));
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
|
||||
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
|
||||
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('never overrides a real, hand-authored statusLine', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
|
||||
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);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.focus('#keyTesterInput');
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
|
||||
|
||||
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
|
||||
await page.keyboard.press('Shift+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
|
||||
// The keypress is the event that used to leak a bare \r to the PTY.
|
||||
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
|
||||
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
|
||||
});
|
||||
|
||||
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press('Control+Enter');
|
||||
const text = await log();
|
||||
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
|
||||
expect(text).toMatch(/keyup/);
|
||||
// Chromium emits no keypress for a Ctrl chord, which is why only Shift+Enter ever leaked a \r.
|
||||
expect(text).not.toMatch(/keypress/);
|
||||
});
|
||||
|
||||
it('lets no app shortcut fire for keys pressed in the field (Ctrl+W, Ctrl+L, Escape, Alt+1, Ctrl+K)', async () => {
|
||||
// The shortcut dispatcher is a capture-phase document listener, so without a guard it ran before
|
||||
// the field's own handler: Ctrl+W killed the active session, Ctrl+L cleared the terminal and
|
||||
// Escape closed Settings, while this row says nothing is sent to a session.
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
const calls: string[] = [];
|
||||
(window as any).__calls = calls;
|
||||
for (const name of ['killActiveSession', 'clearTerminal', 'openCommandPalette', 'closeAllPanels']) {
|
||||
app[name] = (...args: unknown[]) => void calls.push(name + args.length);
|
||||
}
|
||||
});
|
||||
await page.focus('#keyTesterInput');
|
||||
// [chord, what the tester must report for it]; checked one at a time because the log keeps 14 lines.
|
||||
const chords: [string, RegExp][] = [
|
||||
['Control+W', /key="w" code=KeyW mods=ctrl/i],
|
||||
['Control+L', /key="l" code=KeyL mods=ctrl/i],
|
||||
['Escape', /key="Escape" code=Escape/],
|
||||
['Alt+1', /code=Digit1 mods=alt/],
|
||||
['Control+K', /key="k" code=KeyK mods=ctrl/i],
|
||||
];
|
||||
for (const [chord, seen] of chords) {
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
await page.keyboard.press(chord);
|
||||
expect(await log(), chord).toMatch(seen);
|
||||
expect(await page.evaluate(() => (window as any).__calls), chord).toEqual([]);
|
||||
}
|
||||
expect(await page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets the shortcut fire anywhere else (the guard is scoped to data-raw-keys)', async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).__calls.length = 0;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => (window as any).__calls)).toContain('closeAllPanels0');
|
||||
});
|
||||
|
||||
it('keeps only the last 14 lines and never types into the field', async () => {
|
||||
// The test above blurred the field, so focus it again: without this the presses land on
|
||||
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
|
||||
await page.focus('#keyTesterInput');
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
const lines = async () => (await log()).split('\n');
|
||||
|
||||
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
|
||||
// the log accumulates rather than showing only the latest event.
|
||||
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
|
||||
expect(await lines()).toHaveLength(6);
|
||||
|
||||
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
|
||||
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
|
||||
const capped = await lines();
|
||||
expect(capped).toHaveLength(14);
|
||||
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
|
||||
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
|
||||
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
|
||||
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
|
||||
|
||||
// Readonly: none of those presses typed anything into the field itself.
|
||||
expect(await page.inputValue('#keyTesterInput')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
|
||||
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
function withMcp(mcpConfig: unknown) {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
|
||||
return CliEntrySchema.safeParse(e);
|
||||
}
|
||||
|
||||
describe('capabilities.mcpConfig', () => {
|
||||
it('is declared by exactly the CLIs whose format is verified', () => {
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
|
||||
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
|
||||
});
|
||||
|
||||
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
|
||||
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
|
||||
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
|
||||
it('accepts a home-relative path with a known format', () => {
|
||||
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
|
||||
const reloc = Object.fromEntries(
|
||||
STOCK_CLIS.flatMap((e) =>
|
||||
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
|
||||
)
|
||||
);
|
||||
expect(reloc).toEqual({
|
||||
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||
antigravity: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a relocation with an env var name and a relative path', () => {
|
||||
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
|
||||
expect(withMcp(value).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
|
||||
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
|
||||
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
|
||||
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
|
||||
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
|
||||
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
|
||||
[
|
||||
'relocation path traversal',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation absolute path',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
|
||||
],
|
||||
[
|
||||
'relocation env var that is not a name',
|
||||
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
|
||||
],
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(withMcp(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
// @vitest-environment node
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
|
||||
|
||||
const TARGETS: McpSyncTarget[] = [
|
||||
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
|
||||
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
|
||||
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
path: '.gemini/config/mcp_config.json',
|
||||
format: 'antigravity-json',
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
path: '.config/opencode/opencode.json',
|
||||
format: 'opencode-json',
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
|
||||
let home: string;
|
||||
const put = (rel: string, text: string) => {
|
||||
const file = join(home, rel);
|
||||
mkdirSync(join(file, '..'), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
};
|
||||
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
|
||||
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
|
||||
...TARGETS.find((t) => t.id === id)!,
|
||||
...patch,
|
||||
});
|
||||
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
|
||||
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
describe('dialect parsing', () => {
|
||||
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
|
||||
const servers = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'model = "gpt-5"',
|
||||
'',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "npx"',
|
||||
'args = [',
|
||||
' "-y", # comment',
|
||||
' "@mcp/fs",',
|
||||
']',
|
||||
'env = { TOKEN = "abc" }',
|
||||
'',
|
||||
'[mcp_servers."a.b".env]',
|
||||
'K = "v"',
|
||||
'',
|
||||
'[mcp_servers."a.b"]',
|
||||
'command = "x"',
|
||||
'',
|
||||
'[mcp_servers.web]',
|
||||
'url = "https://x.test/mcp"',
|
||||
'[mcp_servers.web.http_headers]',
|
||||
'Authorization = "Bearer t"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
|
||||
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer t' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
|
||||
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
|
||||
});
|
||||
|
||||
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
|
||||
const g = parseServers(
|
||||
'gemini-json',
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
a: { url: 'https://a' },
|
||||
b: { httpUrl: 'https://b' },
|
||||
c: { command: 'c', args: ['1'] },
|
||||
d: { url: 'https://d', type: 'http' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(g.a.transport).toBe('sse');
|
||||
expect(g.b.transport).toBe('http');
|
||||
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
|
||||
expect(g.d.transport).toBe('http');
|
||||
const o = parseServers(
|
||||
'opencode-json',
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
|
||||
r: { type: 'remote', url: 'https://r' },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
|
||||
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
|
||||
});
|
||||
|
||||
it('throws on unparseable files so they are never written', () => {
|
||||
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
|
||||
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => {
|
||||
it('reads and writes the antigravity dialect', () => {
|
||||
const real = JSON.stringify({
|
||||
mcpServers: {
|
||||
fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } },
|
||||
web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' },
|
||||
},
|
||||
});
|
||||
const servers = parseServers('antigravity-json', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({
|
||||
transport: 'http',
|
||||
url: 'https://x.test/mcp',
|
||||
headers: { Authorization: 'Bearer T' },
|
||||
});
|
||||
const out = JSON.parse(
|
||||
addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } })
|
||||
);
|
||||
expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp');
|
||||
expect(out.mcpServers.fs.disabled).toBe(false);
|
||||
expect(out.mcpServers.s).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('gemini-json', null, {
|
||||
web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } },
|
||||
s: { transport: 'sse', url: 'https://x.test/sse' },
|
||||
})
|
||||
);
|
||||
expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } });
|
||||
expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' });
|
||||
expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http');
|
||||
});
|
||||
|
||||
it('reads codex output as written by `codex mcp add`', () => {
|
||||
const real =
|
||||
'[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n';
|
||||
const servers = parseServers('codex-toml', real);
|
||||
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
|
||||
expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostile config files', () => {
|
||||
it('never lets a server name or a sub-table key reach Object.prototype', () => {
|
||||
const toml = parseServers(
|
||||
'codex-toml',
|
||||
[
|
||||
'[mcp_servers.__proto__]',
|
||||
'command = "x"',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs.__proto__]',
|
||||
'polluted = "yes"',
|
||||
'[mcp_servers.fs]',
|
||||
'command = "y"',
|
||||
'[mcp_servers.toString]',
|
||||
'command = "t"',
|
||||
'call = "x"',
|
||||
].join('\n')
|
||||
);
|
||||
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
|
||||
const json = parseServers(
|
||||
'claude-json',
|
||||
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
|
||||
);
|
||||
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
expect(({} as Record<string, unknown>).command).toBeUndefined();
|
||||
expect(typeof Object.prototype.toString.call).toBe('function');
|
||||
});
|
||||
|
||||
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(r.conflicts).toEqual([]);
|
||||
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
|
||||
'hasOwnProperty',
|
||||
'toString',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-object server table instead of overwriting it', () => {
|
||||
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
|
||||
expect(() => parseServers('claude-json', '[]')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addServers', () => {
|
||||
it('preserves other keys and existing servers', () => {
|
||||
const out = JSON.parse(
|
||||
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
|
||||
keep: { transport: 'stdio', command: 'OVERWRITE' },
|
||||
n: { transport: 'stdio', command: 'n' },
|
||||
})
|
||||
);
|
||||
expect(out.theme).toBe('dark');
|
||||
expect(out.mcpServers.keep).toEqual({ command: 'k' });
|
||||
expect(out.mcpServers.n.command).toBe('n');
|
||||
});
|
||||
|
||||
it('appends codex tables without touching the rest, and quotes odd names', () => {
|
||||
const toml = addServers('codex-toml', 'model = "x"\n', {
|
||||
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
|
||||
});
|
||||
expect(toml.startsWith('model = "x"\n')).toBe(true);
|
||||
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
|
||||
transport: 'stdio',
|
||||
command: 'c',
|
||||
args: ['a"b'],
|
||||
env: { K: 'v' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
|
||||
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
|
||||
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
|
||||
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
|
||||
const out = addServers('codex-toml', existing, {
|
||||
fs: { transport: 'stdio', command: 'other' },
|
||||
extra: { transport: 'stdio', command: 'e' },
|
||||
});
|
||||
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
|
||||
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
|
||||
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
|
||||
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
|
||||
expect(out.startsWith(existing)).toBe(true);
|
||||
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('refuses to write when the result would not read back as intended', () => {
|
||||
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
|
||||
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled servers are not propagated', () => {
|
||||
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
|
||||
put(
|
||||
'.config/opencode/opencode.json',
|
||||
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
|
||||
);
|
||||
put(
|
||||
'.gemini/config/mcp_config.json',
|
||||
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
|
||||
);
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
|
||||
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
|
||||
// ...and each CLI still gets the live one.
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
|
||||
expect(get('.codex/config.toml')).not.toContain('off_oc');
|
||||
// The switched-off entry itself is left as it was (still disabled).
|
||||
expect(get('.codex/config.toml')).toContain('enabled = false');
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
|
||||
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
|
||||
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
|
||||
expect(r.disabled).toEqual([]);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMcpServers', () => {
|
||||
const claudeFile = JSON.stringify({
|
||||
numStartups: 3,
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
|
||||
});
|
||||
const setUpAll = () => {
|
||||
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
|
||||
put('.gemini/settings.json', '{}');
|
||||
put('.codex/config.toml', '');
|
||||
put('.config/opencode/opencode.json', '{}');
|
||||
put('.gemini/config/mcp_config.json', '{}');
|
||||
};
|
||||
|
||||
it('previews without writing and never leaks env values', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
const before = get('.gemini/settings.json');
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home });
|
||||
expect(r.applied).toBe(false);
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
expect(get('.gemini/settings.json')).toBe(before);
|
||||
expect(JSON.stringify(r)).not.toContain('s3cret');
|
||||
});
|
||||
|
||||
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
|
||||
setUpAll();
|
||||
put('.claude.json', claudeFile);
|
||||
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(result(r, 'claude').added).toEqual(['web']);
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
|
||||
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
|
||||
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
|
||||
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
|
||||
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
|
||||
|
||||
const again = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
|
||||
setUpAll();
|
||||
put(
|
||||
'.claude.json',
|
||||
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
|
||||
);
|
||||
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
|
||||
const broken = '{ // jsonc\n "mcp": {} }';
|
||||
put('.config/opencode/opencode.json', broken);
|
||||
const r = await syncMcpServers(TARGETS, { apply: true, home });
|
||||
expect(r.conflicts).toEqual(['x']);
|
||||
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
|
||||
expect(result(r, 'codex').skipped).toEqual(['sse']);
|
||||
expect(result(r, 'opencode').status).toBe('unreadable');
|
||||
expect(get('.config/opencode/opencode.json')).toBe(broken);
|
||||
});
|
||||
|
||||
it('passes the unsupported list through to the result', async () => {
|
||||
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
|
||||
expect(r.unsupported).toEqual(['Pi']);
|
||||
});
|
||||
|
||||
describe('only CLIs that are installed or already have a config file take part', () => {
|
||||
it('never creates config for a CLI that is neither installed nor configured', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
|
||||
const r = await syncMcpServers(notInstalled, { apply: true, home });
|
||||
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(home, '.gemini'))).toBe(false);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
|
||||
expect(result(r, 'gemini').added).toEqual(['fs']);
|
||||
});
|
||||
|
||||
it('an installed CLI with no config yet gets one created', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file safety', () => {
|
||||
it('leaves a file that receives env values or headers readable by its owner only', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('keeps the existing mode when nothing secret is copied', async () => {
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
|
||||
put('.gemini/settings.json', '{}');
|
||||
chmodSync(join(home, '.gemini/settings.json'), 0o664);
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
|
||||
});
|
||||
|
||||
it('writes through a symlinked config instead of replacing the link', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, 'dotfiles'), { recursive: true });
|
||||
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
|
||||
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
|
||||
'npx'
|
||||
);
|
||||
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dangling symlink as failed and writes nothing', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true });
|
||||
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
|
||||
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
expect(result(r, 'gemini').status).toBe('failed');
|
||||
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
put('.gemini/settings.json', '{}');
|
||||
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
|
||||
McpSyncBusyError
|
||||
);
|
||||
await first;
|
||||
// A preview is read-only and is never refused.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
|
||||
// ...and the lock is released afterwards.
|
||||
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
|
||||
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages never quote the config file (it holds env values and headers)', () => {
|
||||
const claudeWithSecret = JSON.stringify({
|
||||
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
|
||||
});
|
||||
|
||||
it('reports an unparseable TOML file by line and column only', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
put(
|
||||
'.codex/config.toml',
|
||||
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
|
||||
);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
|
||||
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
|
||||
});
|
||||
|
||||
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
|
||||
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
|
||||
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
|
||||
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
|
||||
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
|
||||
expect(result(r, 'gemini').status).toBe('unreadable');
|
||||
expect(result(r, 'gemini').error).toBe('not valid JSON');
|
||||
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
|
||||
const body = JSON.stringify(r);
|
||||
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
|
||||
put('.claude.json', claudeWithSecret);
|
||||
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
|
||||
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
|
||||
put('.codex/config.toml', inline);
|
||||
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('failed');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
|
||||
expect(get('.codex/config.toml')).toBe(inline);
|
||||
const body = JSON.stringify(r);
|
||||
expect(body).not.toContain('sk-FILE-SECRET');
|
||||
expect(body).not.toContain('sk-COPIED-SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
|
||||
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
|
||||
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
|
||||
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
|
||||
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
|
||||
|
||||
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const codexHome = join(home, 'elsewhere/codex');
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(result(r, 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
|
||||
const claudeDir = join(home, 'accounts/work');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
|
||||
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
|
||||
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
|
||||
const xdg = join(home, 'xdg');
|
||||
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
|
||||
});
|
||||
expect(result(r, 'claude').servers).toEqual(['fs']);
|
||||
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
|
||||
expect(existsSync(join(home, '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: 'relative/codex' },
|
||||
});
|
||||
const codex = result(r, 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
|
||||
expect(codex.added).toEqual([]);
|
||||
expect(existsSync(join(home, '.codex'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty value means unset, as it does for the CLI', async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
|
||||
apply: true,
|
||||
home,
|
||||
env: { CODEX_HOME: '' },
|
||||
});
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
|
||||
});
|
||||
|
||||
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
|
||||
put('.claude.json', claudeFile);
|
||||
const saved = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = join(home, 'from-process-env');
|
||||
try {
|
||||
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
|
||||
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
|
||||
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = saved;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
|
||||
*
|
||||
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
|
||||
* pin how terminal-ui.js and the delivery graph consume it: script order,
|
||||
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
|
||||
* the onData routing of an IME commit, and the rule that only output accepted
|
||||
* AFTER a commit may clear the committed preview.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexSource = read('src/web/public/index.html');
|
||||
const buildSource = read('scripts/build.mjs');
|
||||
const terminalSource = read('src/web/public/terminal-ui.js');
|
||||
const cssSource = read('src/web/public/styles.css');
|
||||
|
||||
type Fn = ReturnType<typeof vi.fn>;
|
||||
type App = Record<string, any>;
|
||||
|
||||
function loadMixin(globals: Record<string, unknown> = {}) {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const windowStub = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...(globals.window as object),
|
||||
} as Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
URLSearchParams,
|
||||
location: { search: '' },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: { addEventListener: vi.fn(), createElement: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
CodemanApp: FakeCodemanApp,
|
||||
_crashDiag: { log: vi.fn() },
|
||||
...globals,
|
||||
window: windowStub,
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, context, windowStub };
|
||||
}
|
||||
|
||||
function fakeClassList() {
|
||||
const values = new Set<string>();
|
||||
return {
|
||||
add: vi.fn((value: string) => values.add(value)),
|
||||
remove: vi.fn((value: string) => values.delete(value)),
|
||||
toggle: vi.fn((value: string, force?: boolean) => {
|
||||
const enabled = force === undefined ? !values.has(value) : force;
|
||||
if (enabled) values.add(value);
|
||||
else values.delete(value);
|
||||
return enabled;
|
||||
}),
|
||||
contains: (value: string) => values.has(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewHarness(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
createThrows?: boolean;
|
||||
omitGlobal?: boolean;
|
||||
themeForeground?: string;
|
||||
themeBackground?: string;
|
||||
themeGetterThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const compositionView = {
|
||||
style: {
|
||||
fontFamily: '"Fira Code"',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: '12px',
|
||||
height: '12px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
},
|
||||
};
|
||||
const helpers = {
|
||||
classList: fakeClassList(),
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
},
|
||||
};
|
||||
const createdControllers: Array<Record<string, any>> = [];
|
||||
const previewNodes: Array<Record<string, any>> = [];
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => {
|
||||
const node = {
|
||||
className: '',
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
remove: vi.fn(),
|
||||
};
|
||||
previewNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
const mobileImePreview = options.omitGlobal
|
||||
? undefined
|
||||
: {
|
||||
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
|
||||
create: vi.fn((callbacks: Record<string, unknown>) => {
|
||||
if (options.createThrows) throw new Error('controller unavailable');
|
||||
const controller = {
|
||||
destroy: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
consumeTerminalData: vi.fn(() => false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
callbacks,
|
||||
};
|
||||
createdControllers.push(controller);
|
||||
return controller;
|
||||
}),
|
||||
};
|
||||
const { mixin, windowStub } = loadMixin({
|
||||
document: documentStub,
|
||||
MobileImePreview: mobileImePreview,
|
||||
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
|
||||
});
|
||||
windowStub.codemanCurrentXtermTheme = () => {
|
||||
if (options.themeGetterThrows) throw new Error('theme unavailable');
|
||||
return { foreground: '#334455', background: '#223344' };
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
options: {
|
||||
theme:
|
||||
options.themeForeground || options.themeBackground
|
||||
? { foreground: options.themeForeground, background: options.themeBackground }
|
||||
: undefined,
|
||||
},
|
||||
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
});
|
||||
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
|
||||
}
|
||||
|
||||
describe('mobile IME preview delivery graph', () => {
|
||||
it('loads the controller after xterm and before terminal wiring', () => {
|
||||
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
|
||||
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
|
||||
});
|
||||
|
||||
it('is minified and content-hashed by the build', () => {
|
||||
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
|
||||
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
|
||||
});
|
||||
|
||||
it('scopes preview presentation and native composition suppression to touch ownership', () => {
|
||||
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
|
||||
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
|
||||
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
|
||||
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
|
||||
});
|
||||
|
||||
it('initializes the preview right after the terminal opens', () => {
|
||||
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME preview lifecycle', () => {
|
||||
it('fails open when the global is absent or create throws', () => {
|
||||
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
|
||||
const { app } = createPreviewHarness(options);
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a controller on unsupported input platforms', () => {
|
||||
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).not.toHaveBeenCalled();
|
||||
expect(previewNodes).toHaveLength(0);
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
});
|
||||
|
||||
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
|
||||
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
|
||||
// Keydown is observed on the terminal element (an ancestor of the
|
||||
// textarea), so it runs before xterm's own capture listener finalizes.
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
|
||||
expect(helpers.children).toEqual([previewNodes[0]]);
|
||||
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
|
||||
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
|
||||
});
|
||||
|
||||
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const first = createdControllers[0];
|
||||
app._initMobileImePreview();
|
||||
expect(first.destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(createdControllers).toHaveLength(2);
|
||||
expect(app._mobileImePreview).toBe(createdControllers[1]);
|
||||
// Window listeners are released with the controller that owned them.
|
||||
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
|
||||
'offline',
|
||||
'pagehide',
|
||||
]);
|
||||
});
|
||||
|
||||
it('destroy releases the controller, the node and the listeners', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app._destroyMobileImePreview();
|
||||
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
|
||||
expect(pagehide).toBeTypeOf('function');
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
|
||||
expect(previewNodes[0].remove).not.toHaveBeenCalled();
|
||||
expect(app._mobileImePreview).toBe(createdControllers[0]);
|
||||
// A second hide after the page came back from the cache still works.
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets the controller exactly once when the active session changes', () => {
|
||||
const { app, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app.activeSessionId = 'session-b';
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app.sessions = new Map();
|
||||
app._updateLocalEchoState();
|
||||
app._updateLocalEchoState();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders and clears owned preview state', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '你好', phase: 'provisional' });
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
|
||||
// cursor that places the span stays at the prompt start, under that text. The
|
||||
// overlay draws the composition instead. Real-xterm proof of the covering:
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
function withOverlay(app: App, visible = true) {
|
||||
const overlay = {
|
||||
composition: '',
|
||||
setComposition: vi.fn(function (this: { composition: string }, text: string) {
|
||||
this.composition = text;
|
||||
}),
|
||||
state: { visible },
|
||||
};
|
||||
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
|
||||
return overlay;
|
||||
}
|
||||
|
||||
it('routes the preview into the local echo overlay when local echo is on', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app);
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app, false);
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.composition).toBe('');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
|
||||
const off = createPreviewHarness();
|
||||
const offOverlay = withOverlay(off.app);
|
||||
Object.assign(off.app, { _localEchoEnabled: false });
|
||||
off.app._initMobileImePreview();
|
||||
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(offOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
|
||||
const passthrough = createPreviewHarness();
|
||||
const passOverlay = withOverlay(passthrough.app);
|
||||
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
|
||||
passthrough.app._initMobileImePreview();
|
||||
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(passOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
|
||||
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
|
||||
themeForeground: '#1f2328',
|
||||
themeBackground: '#fafafa',
|
||||
});
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({
|
||||
fontFamily: compositionView.style.fontFamily,
|
||||
fontSize: compositionView.style.fontSize,
|
||||
fontWeight: compositionView.style.fontWeight,
|
||||
fontStyle: compositionView.style.fontStyle,
|
||||
lineHeight: compositionView.style.lineHeight,
|
||||
height: compositionView.style.height,
|
||||
color: '#1f2328',
|
||||
backgroundColor: '#fafafa',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the current skin theme when the terminal options carry no theme', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
|
||||
});
|
||||
|
||||
it('keeps rendering with a safe foreground when the theme getter throws', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
|
||||
app._initMobileImePreview();
|
||||
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
|
||||
expect(previewNodes[0].textContent).toBe('安全');
|
||||
expect(previewNodes[0].style.color).toBe('#e0e0e0');
|
||||
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
|
||||
});
|
||||
|
||||
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
|
||||
'removes partial DOM ownership when %s fails',
|
||||
(failure) => {
|
||||
const removed = vi.fn();
|
||||
const owner = fakeClassList();
|
||||
const preview = new Proxy(
|
||||
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
|
||||
{
|
||||
set(target, property, value) {
|
||||
if (property === failure) throw new Error(`${failure} failed`);
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}
|
||||
);
|
||||
const helpers = {
|
||||
classList: owner,
|
||||
appendChild:
|
||||
failure === 'append'
|
||||
? () => {
|
||||
throw new Error('append failed');
|
||||
}
|
||||
: vi.fn(),
|
||||
};
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement:
|
||||
failure === 'create'
|
||||
? () => {
|
||||
throw new Error('create failed');
|
||||
}
|
||||
: () => preview,
|
||||
};
|
||||
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
|
||||
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
element: {
|
||||
querySelector:
|
||||
failure === 'query'
|
||||
? () => {
|
||||
throw new Error('query failed');
|
||||
}
|
||||
: () => helpers,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(MobileImePreview.create).not.toHaveBeenCalled();
|
||||
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
|
||||
* so these cases exercise the shipped routing rather than a copy of it.
|
||||
*/
|
||||
function loadHandleTerminalData(app: App, sent: string[]) {
|
||||
const marker = 'const handleTerminalData = (data) => {';
|
||||
const start = terminalSource.indexOf(marker);
|
||||
if (start < 0) throw new Error('handleTerminalData definition not found');
|
||||
const bodyStart = start + marker.length;
|
||||
const end = terminalSource.indexOf('\n };', bodyStart);
|
||||
if (end < 0) throw new Error('handleTerminalData boundary not found');
|
||||
const timers: Array<() => void> = [];
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
document: { activeElement: null, getElementById: vi.fn(() => null) },
|
||||
window: {
|
||||
cjkActive: false,
|
||||
CodemanTerminalInput: {
|
||||
BRACKETED_PASTE_START: '\x1b[200~',
|
||||
shouldSuppressTerminalQueryResponse: () => false,
|
||||
isTerminalFocusOrMouseReport: () => false,
|
||||
isComposerNavKey: () => false,
|
||||
},
|
||||
},
|
||||
_crashDiag: { log: vi.fn() },
|
||||
flushInput: () => {
|
||||
app._inputFlushTimeout = null;
|
||||
if (app._pendingInput && app.activeSessionId) {
|
||||
const input = app._pendingInput;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync(app.activeSessionId, input);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
|
||||
this: App,
|
||||
data: string
|
||||
) => void;
|
||||
return { handle: (data: string) => handler.call(app, data), timers };
|
||||
}
|
||||
|
||||
describe('mobile IME commit onData routing', () => {
|
||||
function onDataApp(options: {
|
||||
localEcho: boolean;
|
||||
tagged?: boolean;
|
||||
overlayMissing?: boolean;
|
||||
addThrows?: boolean;
|
||||
appendThrows?: boolean;
|
||||
consumeThrows?: boolean;
|
||||
}) {
|
||||
const { mixin } = loadMixin();
|
||||
const sent: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: options.consumeThrows
|
||||
? vi.fn(() => {
|
||||
throw new Error('consume failed');
|
||||
})
|
||||
: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.tagged ?? true)
|
||||
.mockReturnValue(false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: '',
|
||||
appendText: vi.fn((data: string) => {
|
||||
if (options.appendThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
addChar: vi.fn((data: string) => {
|
||||
if (options.addThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
activeSessionId: 'session-a',
|
||||
_localEchoEnabled: options.localEcho,
|
||||
_localEchoOverlay: options.overlayMissing ? null : overlay,
|
||||
_echoPassthroughSessions: new Set(),
|
||||
_flushedOffsets: new Map(),
|
||||
_flushedTexts: new Map(),
|
||||
_pendingInput: '',
|
||||
_inputFlushTimeout: null,
|
||||
_lastKeystrokeTime: 0,
|
||||
_terminalOutputSeq: 5,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
|
||||
}
|
||||
|
||||
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
|
||||
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
|
||||
handle('你好');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('你好');
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(app._mobileImeCommitOutputSeq).toBeNull();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
handle('\r');
|
||||
expect(sent).toEqual(['你好']);
|
||||
timers.shift()?.();
|
||||
expect(sent).toEqual(['你好', '\r']);
|
||||
});
|
||||
|
||||
it('moves a single-character commit into local echo through addChar', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
|
||||
handle('界');
|
||||
expect(overlay.addChar).toHaveBeenCalledWith('界');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['appendText throws', { appendThrows: true }, '失敗'],
|
||||
['addChar throws', { addThrows: true }, '字'],
|
||||
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
|
||||
// One code path with typed text: no send-on-throw fallback, which would
|
||||
// double-send if the overlay threw after appending.
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
|
||||
expect(() => handle(text)).toThrow('overlay failed');
|
||||
expect(sent).toEqual([]);
|
||||
// Nothing shows the text, so the preview keeps it until its fallback.
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
|
||||
expect(() => handle('日本')).not.toThrow();
|
||||
expect(sent).toEqual([]);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the commit only after the printable branch has put it in the overlay', () => {
|
||||
const { controller, overlay, handle } = onDataApp({ localEcho: true });
|
||||
controller.completeCommit.mockImplementation(() => {
|
||||
expect(overlay.pendingText).toBe('界');
|
||||
});
|
||||
handle('界');
|
||||
expect(controller.completeCommit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps an untagged paste on the existing local echo path', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
|
||||
handle('plain paste');
|
||||
expect(overlay.pendingText).toBe('plain paste');
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a commit once without local echo and holds the preview until output arrives', () => {
|
||||
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
|
||||
handle('日本語');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual(['日本語']);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(app._mobileImeCommitOutputSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('sends the original bytes exactly once when the controller throws', () => {
|
||||
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
|
||||
expect(() => handle('你好')).not.toThrow();
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME commit and authoritative terminal output', () => {
|
||||
function outputHarness(mode = 'claude') {
|
||||
const { mixin } = loadMixin();
|
||||
const parses: Array<() => void> = [];
|
||||
const written: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: vi.fn(() => true),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
pendingWrites: [],
|
||||
terminal: {
|
||||
rows: 24,
|
||||
write: vi.fn((data: string, callback?: () => void) => {
|
||||
written.push(data);
|
||||
if (callback) parses.push(callback);
|
||||
}),
|
||||
buffer: { active: { viewportY: 0 } },
|
||||
scrollToBottom: vi.fn(),
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode }]]),
|
||||
isTerminalAtBottom: () => true,
|
||||
_hasRecentUserScrollUp: () => false,
|
||||
_safeYield: vi.fn(),
|
||||
_localEchoOverlay: null,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
const flush = () => {
|
||||
app.writeFrameScheduled = false;
|
||||
app.flushPendingWrites();
|
||||
};
|
||||
const parseNext = () => parses.shift()?.();
|
||||
return { app, controller, written, flush, parseNext };
|
||||
}
|
||||
|
||||
it('clears the committed preview once output accepted after the commit is parsed', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output queued before the commit clear it, even when it parses after', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app.batchTerminalWrite('before');
|
||||
flush();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
|
||||
app.batchTerminalWrite('after');
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let a split chunk clear the commit before its remainder is written', () => {
|
||||
const { app, controller, written, flush, parseNext } = outputHarness('codex');
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('x'.repeat(40000));
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
flush();
|
||||
parseNext();
|
||||
expect(written.join('')).toBe('x'.repeat(40000));
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output parsed after a session switch clear the new session preview', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
app.activeSessionId = 'session-b';
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
|
||||
const { app, flush } = outputHarness();
|
||||
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
|
||||
app._localEchoOverlay = overlay;
|
||||
app.batchTerminalWrite('output that moves the prompt');
|
||||
flush();
|
||||
expect(overlay.rerender).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
|
||||
// The resize observer is a closure inside initTerminal(), so it is pinned
|
||||
// by source; the post-write follow runs against real xterm in
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
expect(terminalSource).toMatch(
|
||||
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
|
||||
);
|
||||
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
|
||||
});
|
||||
|
||||
it('notifies once per commit, never for later output', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
for (const chunk of ['a', 'b']) {
|
||||
app.batchTerminalWrite(chunk);
|
||||
flush();
|
||||
parseNext();
|
||||
}
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* The iOS IME preview controller against a REAL xterm 6 instance.
|
||||
*
|
||||
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
|
||||
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
|
||||
* `terminal.open()` registers xterm's keydown listener in the capture phase on
|
||||
* the helper textarea, and CompositionHelper.keydown finalizes a composition
|
||||
* there and emits the commit through onData synchronously. The controller must
|
||||
* observe that keydown first (capture phase on `terminal.element`), and must
|
||||
* finalize on exactly the keys xterm does.
|
||||
*
|
||||
* No server: a blank page loads the vendored xterm bundle and the controller.
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
type Step =
|
||||
| ['start']
|
||||
| ['update', string]
|
||||
| ['end', string]
|
||||
| ['key', number, string, boolean]
|
||||
| ['wait', number]
|
||||
| ['consume', string];
|
||||
|
||||
describe('mobile IME preview with real xterm', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
async function drive(steps: Step[]) {
|
||||
return page.evaluate(async (steps: Step[]) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal();
|
||||
term.open(host);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
const renders: Array<{ text: string; phase: string }> = [];
|
||||
const onData: Array<{ data: string; consumed: boolean }> = [];
|
||||
const controller = w.MobileImePreview.create({
|
||||
textarea,
|
||||
keydownTarget: term.element,
|
||||
render: (r: { text: string; phase: string }) => renders.push(r),
|
||||
clear: () => {},
|
||||
});
|
||||
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
|
||||
textarea.focus();
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
for (const step of steps) {
|
||||
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
if (step[0] === 'update') {
|
||||
textarea.value = step[1];
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
|
||||
}
|
||||
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
|
||||
if (step[0] === 'key') {
|
||||
const [, keyCode, key, isComposing] = step;
|
||||
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
if (step[0] === 'wait') await tick(step[1]);
|
||||
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
|
||||
}
|
||||
await tick(20);
|
||||
const { composing, awaitingCommit, committed, latest } = controller.state;
|
||||
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
|
||||
controller.destroy();
|
||||
term.dispose();
|
||||
return result;
|
||||
}, steps);
|
||||
}
|
||||
|
||||
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
|
||||
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
|
||||
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
|
||||
expect(result.onData).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
|
||||
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
|
||||
});
|
||||
|
||||
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'か'],
|
||||
['wait', 10],
|
||||
['key', 229, 'k', false],
|
||||
['update', 'かな'],
|
||||
]);
|
||||
expect(result.onData).toEqual([]);
|
||||
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
|
||||
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
|
||||
});
|
||||
|
||||
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'abc'],
|
||||
['update', ''],
|
||||
['end', ''],
|
||||
['wait', 2100],
|
||||
['consume', 'pasted'],
|
||||
]);
|
||||
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The preview with local echo ON, the default for Claude sessions on phones.
|
||||
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
|
||||
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
|
||||
* which is where the helper span sits, stays at the prompt start: under the
|
||||
* overlay's own opaque text. So a composition that follows text already in the
|
||||
* overlay must be drawn by the overlay itself, after that text.
|
||||
*
|
||||
* Loads the real pieces: xterm 6, the overlay bundled from its package source
|
||||
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
|
||||
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
|
||||
* `_initMobileImePreview` on a bare CodemanApp prototype.
|
||||
*/
|
||||
describe('mobile IME preview over the local echo overlay', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bundled = await build({
|
||||
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'XtermZerolagInput',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
const overlayBundle =
|
||||
bundled.outputFiles[0].text +
|
||||
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
|
||||
await page.setContent(
|
||||
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
|
||||
);
|
||||
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
|
||||
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ content: overlayBundle });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Types `pending` into the overlay (as the printable/paste branch does), then
|
||||
* composes `composing` and reports what is PAINTED at the cell right after
|
||||
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
|
||||
* with pointer-events forced on, since the overlay and the preview are
|
||||
* pointer-events:none.
|
||||
*/
|
||||
async function composeAfter(pending: string, composing: string, commit: boolean) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing, commit }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('\u276f ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
app.terminal = term;
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = new w.LocalEchoOverlay(term);
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
|
||||
// _syncMobileHelperTextareaToCursor places them.
|
||||
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
const dims = term._core._renderService.dimensions.css.cell;
|
||||
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
|
||||
term.element.style.setProperty('--xterm-helper-top', '0px');
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const force = document.createElement('style');
|
||||
force.textContent = '.xterm * { pointer-events: auto !important; }';
|
||||
document.head.appendChild(force);
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
|
||||
const paintedAt = (col: number) => {
|
||||
const el = document.elementFromPoint(
|
||||
rect.left + (col + 0.5) * dims.width,
|
||||
rect.top + 0.5 * dims.height
|
||||
) as HTMLElement | null;
|
||||
return {
|
||||
text: el?.textContent ?? null,
|
||||
composition: !!el?.closest?.('[data-zerolag-composition]'),
|
||||
preview: !!el?.closest?.('.codeman-ime-preview'),
|
||||
};
|
||||
};
|
||||
const afterPending = paintedAt(2 + widthOf(pending));
|
||||
force.remove();
|
||||
|
||||
let afterCommit = null;
|
||||
if (commit) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
|
||||
// What the printable/paste branch of terminal-ui.js's onData does.
|
||||
if (app._consumeMobileImeTerminalData(composing)) {
|
||||
app._localEchoOverlay.appendText(composing);
|
||||
app._transferMobileImeCommitToLocalEcho();
|
||||
}
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
afterCommit = {
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
|
||||
overlayText: app._localEchoOverlay._overlay?.textContent,
|
||||
};
|
||||
}
|
||||
const result = {
|
||||
afterPending,
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
afterCommit,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing, commit }
|
||||
);
|
||||
}
|
||||
|
||||
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
|
||||
const result = await composeAfter('', '今日は', false);
|
||||
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
|
||||
expect(result.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('a second composition is painted after the text already in the overlay, not under it', async () => {
|
||||
const result = await composeAfter('今日は', '天気', false);
|
||||
expect(result.afterPending.text).toBe('天');
|
||||
expect(result.afterPending.composition).toBe(true);
|
||||
// Provisional text is never taken into the overlay's pending (unsent) text.
|
||||
expect(result.pendingText).toBe('今日は');
|
||||
});
|
||||
|
||||
it('the commit lands once in the overlay and the composition tail is gone', async () => {
|
||||
const result = await composeAfter('今日は', '天気', true);
|
||||
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Composes `composing` after `pending`, then streams output through the REAL
|
||||
* write path (batchTerminalWrite, the scheduled flushPendingWrites, xterm's
|
||||
* async parse) that moves the ❯ row from 0 to 3, then one more frame that
|
||||
* leaves the prompt where it is (a status-line repaint). Reports the overlay
|
||||
* row after each frame.
|
||||
*
|
||||
* The post-write re-place runs right after terminal.write() returns, before
|
||||
* xterm parses that chunk, so it sees the buffer as of the previous frame: the
|
||||
* overlay reaches the new row on the frame after the move. That timing is the
|
||||
* same for pending text; the composition-only overlay used to never get there
|
||||
* because the re-place was gated on hasPending, which excludes it.
|
||||
*/
|
||||
async function composeThenMovePrompt(pending: string, composing: string) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('❯ ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
Object.assign(app, {
|
||||
terminal: term,
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: new w.LocalEchoOverlay(term),
|
||||
pendingWrites: [],
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode: 'claude' }]]),
|
||||
});
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const cellH = term._core._renderService.dimensions.css.cell.height;
|
||||
const overlayEl = app._localEchoOverlay._overlay as HTMLElement;
|
||||
const overlayRow = () =>
|
||||
overlayEl.style.display === 'none' ? null : Math.round(parseFloat(overlayEl.style.top) / cellH);
|
||||
// Output goes through the app's own scheduler; wait until it has been
|
||||
// flushed and parsed.
|
||||
const stream = async (data: string) => {
|
||||
app.batchTerminalWrite(data);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (!app.writeFrameScheduled && !app._terminalWriteInFlight && app.pendingWrites.length === 0) break;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
await new Promise<void>((r) => term.write('', () => r()));
|
||||
};
|
||||
|
||||
const before = overlayRow();
|
||||
await stream('\r\x1b[2Kline 1\r\nline 2\r\nline 3\r\n❯ ');
|
||||
const promptRow = app._localEchoOverlay.findPrompt()?.row ?? null;
|
||||
await stream('\x1b7\x1b[8;1Hworking\x1b8');
|
||||
const result = {
|
||||
before,
|
||||
promptRow,
|
||||
after: overlayRow(),
|
||||
composition: Array.from(term.element.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((el) => (el as HTMLElement).textContent)
|
||||
.join(''),
|
||||
hasPending: app._localEchoOverlay.hasPending,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing }
|
||||
);
|
||||
}
|
||||
|
||||
it('a composition on an empty prompt follows the prompt when output moves it', async () => {
|
||||
const result = await composeThenMovePrompt('', '今日');
|
||||
expect(result.before).toBe(0);
|
||||
expect(result.promptRow).toBe(3);
|
||||
// Nothing is pending: before the fix this stayed on row 0, over "line 1".
|
||||
expect(result.hasPending).toBe(false);
|
||||
expect(result.after).toBe(3);
|
||||
expect(result.composition).toBe('今日');
|
||||
});
|
||||
|
||||
it('a composition after pending text follows it the same way', async () => {
|
||||
const result = await composeThenMovePrompt('abc', '今日');
|
||||
expect(result).toEqual({ before: 0, promptRow: 3, after: 3, composition: '今日', hasPending: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
type ListenerOptions = boolean | { capture?: boolean };
|
||||
type RegisteredListener = { listener: Listener; capture: boolean };
|
||||
|
||||
/**
|
||||
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
|
||||
* ancestor's capture listeners, then the target's capture listeners, then the
|
||||
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
|
||||
* WebKit), then the ancestor's bubble listeners.
|
||||
*/
|
||||
class FakeNode {
|
||||
parent: FakeNode | null = null;
|
||||
protected listeners = new Map<string, RegisteredListener[]>();
|
||||
|
||||
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push({ listener, capture: options === true || options?.capture === true });
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const capture = options === true || options?.capture === true;
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
const index = listeners.findIndex(
|
||||
(registered) => registered.listener === listener && registered.capture === capture
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}
|
||||
|
||||
run(type: string, capture: boolean, event: Record<string, unknown>) {
|
||||
for (const registered of [...(this.listeners.get(type) ?? [])]) {
|
||||
if (registered.capture === capture) registered.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const full = { type, target: this, ...event };
|
||||
const ancestors: FakeNode[] = [];
|
||||
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
|
||||
for (const ancestor of ancestors) ancestor.run(type, true, full);
|
||||
this.run(type, true, full);
|
||||
this.run(type, false, full);
|
||||
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextarea extends FakeNode {
|
||||
value = 'unchanged';
|
||||
}
|
||||
|
||||
type Scheduled = { id: number; callback: () => void; delay?: number };
|
||||
|
||||
function harness(
|
||||
overrides: Record<string, unknown> = {},
|
||||
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
|
||||
) {
|
||||
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ navigator: {} });
|
||||
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
|
||||
const api = vm.runInContext('MobileImePreview', context);
|
||||
// The terminal element: an ancestor of the helper textarea, like xterm's
|
||||
// `.xterm` root is of `.xterm-helper-textarea`.
|
||||
const element = new FakeNode();
|
||||
const textarea = new FakeTextarea();
|
||||
textarea.parent = element;
|
||||
const frames: Scheduled[] = [];
|
||||
const timers: Scheduled[] = [];
|
||||
let nextId = 1;
|
||||
const render = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const scheduleFrame = vi.fn((callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.push({ id, callback });
|
||||
return id;
|
||||
});
|
||||
const cancelFrame = vi.fn((id: number) => {
|
||||
const index = frames.findIndex((frame) => frame.id === id);
|
||||
if (index >= 0) frames.splice(index, 1);
|
||||
});
|
||||
const setTimer = vi.fn((callback: () => void, delay: number) => {
|
||||
const id = nextId++;
|
||||
timers.push({ id, callback, delay });
|
||||
return id;
|
||||
});
|
||||
const clearTimer = vi.fn((id: number) => {
|
||||
const index = timers.findIndex((timer) => timer.id === id);
|
||||
if (index >= 0) timers.splice(index, 1);
|
||||
});
|
||||
let controller: Record<string, unknown> | undefined;
|
||||
beforeCreate?.(textarea, () => controller);
|
||||
controller = api.create({
|
||||
textarea,
|
||||
keydownTarget: element,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
...overrides,
|
||||
});
|
||||
const flushFrame = () => frames.shift()?.callback();
|
||||
const flushTimer = () => timers.shift()?.callback();
|
||||
|
||||
return {
|
||||
api,
|
||||
element,
|
||||
textarea,
|
||||
frames,
|
||||
timers,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
controller,
|
||||
flushFrame,
|
||||
flushTimer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileImePreview', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('collapses 500 composition updates into one latest-state frame', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
|
||||
|
||||
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.textarea.value).toBe('unchanged');
|
||||
});
|
||||
|
||||
test('replaces provisional text for replacement, backspace, and composing input', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
|
||||
h.textarea.dispatch('compositionupdate', { data: 'xy' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
|
||||
|
||||
h.textarea.dispatch('input', { data: '', isComposing: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps the preview without changing the terminal handoff value', () => {
|
||||
const h = harness();
|
||||
const value = '界'.repeat(2050);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: value });
|
||||
h.flushFrame();
|
||||
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
|
||||
h.textarea.dispatch('compositionend', { data: value });
|
||||
expect(h.controller.consumeTerminalData(value)).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('hands off only the first safe xterm onData value after finalization', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '日本語' });
|
||||
h.textarea.dispatch('compositionend', { data: '日本語' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
|
||||
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
|
||||
});
|
||||
|
||||
test('defers finalization rendering and updates the queued frame phase to committed', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
|
||||
expect(h.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(h.frames).toHaveLength(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
|
||||
});
|
||||
|
||||
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'teh' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
|
||||
expect(h.controller.consumeTerminalData('the')).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.onCommit).toHaveBeenCalledWith('the');
|
||||
expect(h.controller.consumeTerminalData('teh')).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
|
||||
'rejects non-printable or multiline terminal data %j without consuming the pending value',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: rejected });
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['line\u2028break', 'line\u2029break'])(
|
||||
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('safe')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('keydown can finalize composition before a late compositionend', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(true);
|
||||
h.textarea.dispatch('compositionend', { data: 'stale' });
|
||||
expect(h.controller.consumeTerminalData('stale')).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
|
||||
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
|
||||
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
|
||||
* composition there, emitting the commit through onData synchronously, for
|
||||
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
|
||||
* controller is created, exactly as terminal.open() precedes
|
||||
* _initMobileImePreview().
|
||||
*/
|
||||
function withXtermStandIn() {
|
||||
const emitted: Array<{ data: string; consumed: boolean }> = [];
|
||||
let composing = false;
|
||||
let composition = '';
|
||||
const h = harness({}, (textarea, getController) => {
|
||||
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
|
||||
textarea.addEventListener('compositionstart', () => {
|
||||
composing = true;
|
||||
composition = '';
|
||||
});
|
||||
textarea.addEventListener('compositionupdate', (event) => {
|
||||
composition = String(event.data ?? '');
|
||||
});
|
||||
textarea.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
|
||||
composing = false;
|
||||
emit(composition);
|
||||
}
|
||||
if (event.keyCode === 13) emit('\r');
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
return { ...h, emitted, isXtermComposing: () => composing };
|
||||
}
|
||||
|
||||
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.flushFrame();
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).toHaveBeenCalledWith('確定');
|
||||
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
|
||||
|
||||
// The next unrelated keystroke is ordinary input, not an IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
|
||||
// composing, so the controller must too.
|
||||
['the IME composition character', 'k', 229],
|
||||
['CapsLock', 'CapsLock', 20],
|
||||
['Shift', 'Shift', 16],
|
||||
['Control', 'Control', 17],
|
||||
['Alt', 'Alt', 18],
|
||||
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'か' });
|
||||
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
|
||||
expect(h.isXtermComposing()).toBe(true);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
|
||||
// The preview follows the composition instead of freezing on the old value.
|
||||
h.textarea.dispatch('compositionupdate', { data: 'かな' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores keydowns that did not target the helper textarea', () => {
|
||||
const h = harness();
|
||||
const sibling = new FakeNode();
|
||||
sibling.parent = h.element;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '漢字' });
|
||||
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
});
|
||||
|
||||
test('destroy stops the controller observing keydown on the terminal element', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.controller.destroy();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'later' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: 'later', consumed: false },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
describe('a commit that never reaches onData', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function realTimerHarness() {
|
||||
return harness({
|
||||
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
|
||||
[
|
||||
'a finalizing keydown',
|
||||
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
|
||||
],
|
||||
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
|
||||
finalize(h);
|
||||
h.clear.mockClear();
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(h.controller.state.awaitingCommit).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
// The next unrelated keystroke or paste is not adopted as the IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '日本' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(h.controller.consumeTerminalData('日本')).toBe(true);
|
||||
// The wait bound would have fired at 2000 ms; the committed bound runs
|
||||
// a full 2 s from the commit instead.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('a new composition cancels the previous wait bound', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'next' });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
|
||||
});
|
||||
});
|
||||
|
||||
test('generation fences stale frames and timers', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'old' });
|
||||
const staleFrame = h.frames[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
staleFrame();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
|
||||
h.textarea.dispatch('compositionend', { data: 'first' });
|
||||
h.controller.consumeTerminalData('first');
|
||||
const staleTimer = h.timers[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'new' });
|
||||
h.flushFrame();
|
||||
staleTimer();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
|
||||
});
|
||||
|
||||
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
|
||||
const predicted = harness();
|
||||
predicted.textarea.dispatch('compositionstart');
|
||||
predicted.clear.mockClear();
|
||||
predicted.textarea.dispatch('compositionend', { data: 'one' });
|
||||
predicted.controller.consumeTerminalData('one');
|
||||
expect(predicted.frames).toHaveLength(1);
|
||||
predicted.controller.completeCommit({ predicted: true });
|
||||
expect(predicted.clear).toHaveBeenCalledOnce();
|
||||
expect(predicted.frames).toHaveLength(0);
|
||||
expect(predicted.timers).toHaveLength(0);
|
||||
expect(predicted.controller.state.latest).toBe('');
|
||||
expect(predicted.controller.state.committed).toBe(false);
|
||||
|
||||
const fallback = harness();
|
||||
fallback.textarea.dispatch('compositionstart');
|
||||
fallback.clear.mockClear();
|
||||
fallback.textarea.dispatch('compositionend', { data: 'two' });
|
||||
fallback.controller.consumeTerminalData('two');
|
||||
fallback.controller.completeCommit({ predicted: false });
|
||||
expect(fallback.clear).not.toHaveBeenCalled();
|
||||
fallback.controller.noteAuthoritativeOutput();
|
||||
expect(fallback.clear).toHaveBeenCalledOnce();
|
||||
expect(fallback.frames).toHaveLength(0);
|
||||
expect(fallback.timers).toHaveLength(0);
|
||||
expect(fallback.controller.state.latest).toBe('');
|
||||
expect(fallback.controller.state.committed).toBe(false);
|
||||
|
||||
const ttl = harness();
|
||||
ttl.textarea.dispatch('compositionstart');
|
||||
ttl.clear.mockClear();
|
||||
ttl.textarea.dispatch('compositionend', { data: 'three' });
|
||||
ttl.controller.consumeTerminalData('three');
|
||||
ttl.controller.completeCommit({ predicted: false });
|
||||
ttl.flushTimer();
|
||||
expect(ttl.clear).toHaveBeenCalledOnce();
|
||||
expect(ttl.frames).toHaveLength(0);
|
||||
expect(ttl.timers).toHaveLength(0);
|
||||
expect(ttl.controller.state.latest).toBe('');
|
||||
expect(ttl.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
|
||||
let resetController: { reset(): void };
|
||||
const reset = harness({ onCommit: () => resetController.reset() });
|
||||
resetController = reset.controller;
|
||||
reset.textarea.dispatch('compositionstart');
|
||||
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
reset.textarea.dispatch('compositionend');
|
||||
expect(reset.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(reset.frames).toHaveLength(0);
|
||||
expect(reset.timers).toHaveLength(0);
|
||||
expect(reset.controller.state.latest).toBe('');
|
||||
|
||||
let destroyController: { destroy(): void };
|
||||
const destroy = harness({ onCommit: () => destroyController.destroy() });
|
||||
destroyController = destroy.controller;
|
||||
destroy.textarea.dispatch('compositionstart');
|
||||
destroy.textarea.dispatch('compositionend');
|
||||
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(destroy.textarea.listenerCount()).toBe(0);
|
||||
expect(destroy.element.listenerCount()).toBe(0);
|
||||
expect(destroy.frames).toHaveLength(0);
|
||||
expect(destroy.timers).toHaveLength(0);
|
||||
expect(destroy.controller.state.latest).toBe('');
|
||||
});
|
||||
|
||||
test('contains re-entrant render and clear callbacks', () => {
|
||||
let renderController: { reset(): void };
|
||||
const render = harness({ render: () => renderController.reset() });
|
||||
renderController = render.controller;
|
||||
render.textarea.dispatch('compositionstart');
|
||||
render.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
expect(() => render.flushFrame()).not.toThrow();
|
||||
expect(render.frames).toHaveLength(0);
|
||||
expect(render.timers).toHaveLength(0);
|
||||
expect(render.controller.state.latest).toBe('');
|
||||
|
||||
let clearController: { destroy(): void };
|
||||
const clear = harness({ clear: () => clearController?.destroy() });
|
||||
clearController = clear.controller;
|
||||
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
|
||||
expect(clear.textarea.listenerCount()).toBe(0);
|
||||
expect(clear.element.listenerCount()).toBe(0);
|
||||
expect(clear.frames).toHaveLength(0);
|
||||
expect(clear.timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fails open when frame or timer schedulers throw', () => {
|
||||
const frame = harness({
|
||||
scheduleFrame: () => {
|
||||
throw new Error('frame scheduler');
|
||||
},
|
||||
});
|
||||
frame.textarea.dispatch('compositionstart');
|
||||
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
|
||||
expect(frame.controller.state.framePending).toBe(false);
|
||||
|
||||
let lateFrame: (() => void) | undefined;
|
||||
const timer = harness({
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
lateFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
setTimer: () => {
|
||||
throw new Error('timer scheduler');
|
||||
},
|
||||
});
|
||||
timer.textarea.dispatch('compositionstart');
|
||||
timer.clear.mockClear();
|
||||
timer.textarea.dispatch('compositionend');
|
||||
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(timer.controller.state.committed).toBe(false);
|
||||
expect(timer.controller.state.latest).toBe('');
|
||||
expect(timer.controller.state.framePending).toBe(false);
|
||||
expect(timer.controller.state.timerPending).toBe(false);
|
||||
expect(timer.clear).toHaveBeenCalledOnce();
|
||||
lateFrame?.();
|
||||
expect(timer.render).not.toHaveBeenCalled();
|
||||
|
||||
const tokenlessTimer = harness({ setTimer: () => undefined });
|
||||
tokenlessTimer.textarea.dispatch('compositionstart');
|
||||
tokenlessTimer.clear.mockClear();
|
||||
tokenlessTimer.textarea.dispatch('compositionend');
|
||||
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
|
||||
expect(tokenlessTimer.controller.state.committed).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.latest).toBe('');
|
||||
expect(tokenlessTimer.controller.state.framePending).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
|
||||
expect(tokenlessTimer.frames).toHaveLength(0);
|
||||
expect(tokenlessTimer.timers).toHaveLength(0);
|
||||
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
|
||||
|
||||
const cancellation = harness({
|
||||
cancelFrame: () => {
|
||||
throw new Error('frame cancellation');
|
||||
},
|
||||
clearTimer: () => {
|
||||
throw new Error('timer cancellation');
|
||||
},
|
||||
});
|
||||
cancellation.textarea.dispatch('compositionstart');
|
||||
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
cancellation.textarea.dispatch('compositionend');
|
||||
cancellation.controller.consumeTerminalData('final');
|
||||
expect(() => cancellation.controller.reset()).not.toThrow();
|
||||
expect(cancellation.controller.state.framePending).toBe(false);
|
||||
expect(cancellation.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('authoritative output never clears active provisional composition', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'active' });
|
||||
h.flushFrame();
|
||||
h.controller.noteAuthoritativeOutput();
|
||||
expect(h.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur and reset cancel scheduled work and clear visual state', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
h.textarea.dispatch('blur');
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'again' });
|
||||
h.controller.reset();
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('destroy is idempotent and removes listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
expect(h.textarea.listenerCount()).toBe(5);
|
||||
expect(h.element.listenerCount()).toBe(1);
|
||||
h.controller.destroy();
|
||||
h.controller.destroy();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
expect(h.frames).toHaveLength(0);
|
||||
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
|
||||
expect(h.scheduleFrame).toHaveBeenCalledOnce();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.framePending).toBe(false);
|
||||
expect(h.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('contains render, clear, and commit callback exceptions', () => {
|
||||
const h = harness({
|
||||
render: vi.fn(() => {
|
||||
throw new Error('render');
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
throw new Error('clear');
|
||||
}),
|
||||
onCommit: vi.fn(() => {
|
||||
throw new Error('commit');
|
||||
}),
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'safe' });
|
||||
expect(() => h.flushFrame()).not.toThrow();
|
||||
h.textarea.dispatch('compositionend', { data: 'safe' });
|
||||
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
|
||||
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
|
||||
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
|
||||
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
||||
});
|
||||
|
||||
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||
// fields would drop the effort here without a word.
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'peon',
|
||||
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
|
||||
});
|
||||
|
||||
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
|
||||
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
|
||||
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
|
||||
* config are reported as unsupported.
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
|
||||
* creates. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
||||
|
||||
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
|
||||
// is installed, so only a CLI whose config file exists takes part.
|
||||
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
|
||||
// (after taking the lock) until released, so a second POST deterministically overlaps it.
|
||||
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
|
||||
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
|
||||
return {
|
||||
...actual,
|
||||
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
|
||||
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
|
||||
const parked = new Promise<void>((resolve) => (hold.release = resolve));
|
||||
hold.entered?.();
|
||||
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
|
||||
await parked;
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Nothing is installed unless a test adds the id here.
|
||||
const installed = vi.hoisted(() => new Set<string>());
|
||||
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
|
||||
probeStockCliAvailability: async () => ({}),
|
||||
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
|
||||
}));
|
||||
|
||||
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
|
||||
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
|
||||
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
|
||||
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
|
||||
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
|
||||
return envVar ? [envVar] : [];
|
||||
});
|
||||
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
|
||||
afterAll(() => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
const home = () => homedir();
|
||||
const write = (rel: string, text: string) => {
|
||||
const f = join(home(), rel);
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const setEnabled = (on: boolean | undefined) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
|
||||
};
|
||||
const disable = (...ids: string[]) => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
|
||||
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
};
|
||||
|
||||
const CLAUDE = '.claude.json';
|
||||
const CODEX = '.codex/config.toml';
|
||||
const GEMINI = '.gemini/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of RELOCATION_VARS) delete process.env[k];
|
||||
installed.clear();
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
|
||||
rmSync(join(home(), d), { recursive: true, force: true });
|
||||
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
// Codex and Gemini have been set up on this machine (their config files exist).
|
||||
write(CODEX, 'model = "gpt-5"\n');
|
||||
write(GEMINI, '{}');
|
||||
setEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync — opt-in', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
expect(res.json().error).toMatch(/disabled/i);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the toggle on the next request, with no restart', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
setEnabled(false);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync', () => {
|
||||
it('GET previews without writing', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.applied).toBe(false);
|
||||
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('POST adds the server to every enabled, set-up CLI', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.json().data.applied).toBe(true);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
|
||||
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
|
||||
expect(opencode.status).toBe('absent');
|
||||
expect(existsSync(join(home(), '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches a CLI that is disabled in the registry', async () => {
|
||||
disable('codex');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
|
||||
expect(ids).not.toContain('codex');
|
||||
expect(ids).toContain('gemini');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
|
||||
installed.add('grok').add('pi');
|
||||
disable('pi');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
|
||||
expect(unsupported).toEqual(['Grok']);
|
||||
});
|
||||
|
||||
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
|
||||
const codexHome = join(home(), 'relocated/codex');
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(codex.added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
|
||||
process.env.CODEX_HOME = 'relative/codex';
|
||||
installed.add('codex');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME/);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('never echoes the text of a config file it cannot parse', async () => {
|
||||
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(res.body).not.toContain('lin_SECRET_abc');
|
||||
});
|
||||
|
||||
it('never returns env values or headers', async () => {
|
||||
write(
|
||||
CLAUDE,
|
||||
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
|
||||
);
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.body).not.toContain('sekrit-value');
|
||||
});
|
||||
|
||||
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
hold.release = () => undefined;
|
||||
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
|
||||
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
await entered; // the first apply now holds the lock
|
||||
const release = hold.release;
|
||||
hold.release = null; // the overlapping request goes straight to the real function
|
||||
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
release?.();
|
||||
expect((await first).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('multi-user: an admin is allowed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
|
||||
*
|
||||
* Claude's model reaches disk only through `modelOverride`, which writes it into the
|
||||
* case's `.claude/settings.local.json` for every later run there. `model` is the
|
||||
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
|
||||
* default, and writes nothing. What the tests read is the model the session hands the
|
||||
* mux when it starts, which is what becomes the `--model` flag.
|
||||
*
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions model', () => {
|
||||
let workingDir: string;
|
||||
let harness: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Creates a session and starts it, then returns the model it handed the mux. */
|
||||
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = JSON.parse(res.body);
|
||||
const id = (parsed.data?.session ?? parsed.session).id as string;
|
||||
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
|
||||
const calls = harness.ctx.mux.createSession.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return (calls[calls.length - 1][0] as { model?: string }).model;
|
||||
}
|
||||
|
||||
it('launches a Claude session on the model the caller names', async () => {
|
||||
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('wins over the app-wide default model', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves the app-wide default in charge when the caller names none', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
|
||||
// Sent together, each lands where it belongs: the persistent default in the case's
|
||||
// settings.local.json, and this session's model on its launch line. A route that wrote
|
||||
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
|
||||
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(settings.model).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('reads an empty model as no model, as modelOverride does', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('refuses a model for a CLI that takes its model in its own config object', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('rejects a model with characters the launch pattern refuses', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a flag-shaped model, since the value lands in argv', async () => {
|
||||
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model },
|
||||
});
|
||||
expect(res.statusCode, model).toBe(400);
|
||||
}
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
|
||||
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
|
||||
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['model', { model: 'opus' }],
|
||||
['advisorModel', { advisorModel: 'opus' }],
|
||||
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
|
||||
// Refused before the host is looked up, so no remote host needs to exist.
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
mode: 'claude',
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('publishes the launch model on the created claude session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
const session = parsed.data?.session ?? parsed.session;
|
||||
expect(session.model).toBe('claude-fable-5-1');
|
||||
expect(session.advisorModel).toBe('opus');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -165,6 +167,74 @@ describe('session-routes', () => {
|
||||
expect(argv).toContain('-H');
|
||||
});
|
||||
|
||||
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
|
||||
const sentHex = async (mode: string, key: string): Promise<string[]> => {
|
||||
execFile.mockReset();
|
||||
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
|
||||
cb(null)
|
||||
);
|
||||
const session = harness.ctx._session as unknown as { mode: string };
|
||||
const before = session.mode;
|
||||
session.mode = mode;
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/send-key',
|
||||
payload: { key },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
session.mode = before;
|
||||
}
|
||||
const argv = execFile.mock.calls[0][1] as string[];
|
||||
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
|
||||
};
|
||||
|
||||
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('sends a line feed to Codex too: no stock CLI declares a chord', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
describe('a CLI that declares esc-enter (here via a user clis.json override of codex)', () => {
|
||||
beforeEach(() => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, clis: { codex: { capabilities: { newline: 'esc-enter' } } } }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
reloadCliRegistry();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('sends Esc+Enter for Shift+Enter, and only to that CLI', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('still sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('always sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('falls back to a line feed for a mode the registry does not know', async () => {
|
||||
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
|
||||
execFile.mockReset();
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
|
||||
* is never returned and the routes are admin only in multi-user mode.
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
|
||||
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
|
||||
|
||||
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
|
||||
const h = await createRouteTestHarness(
|
||||
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, notifier };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
|
||||
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/webhook', () => {
|
||||
it('starts disabled with no URL', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(res.json().data).toEqual({
|
||||
enabled: false,
|
||||
kind: 'ntfy',
|
||||
scope: 'attention',
|
||||
hasUrl: false,
|
||||
urlMasked: '',
|
||||
lastResult: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/webhook', () => {
|
||||
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
|
||||
const { app } = await harness();
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/webhook',
|
||||
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
|
||||
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
|
||||
});
|
||||
|
||||
it('an empty url clears it', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
|
||||
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
|
||||
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
|
||||
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
|
||||
['clearing the URL while enabled', null, /Add a webhook URL/],
|
||||
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
|
||||
const { app } = await harness();
|
||||
if (payload === null) {
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
return;
|
||||
}
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
|
||||
});
|
||||
|
||||
it('rejects unknown keys and bad enums (strict schema)', async () => {
|
||||
const { app } = await harness();
|
||||
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/test', () => {
|
||||
it('refuses with 400 until a URL is saved', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one message with the saved config, even while notifications are disabled', async () => {
|
||||
const { app, notifier } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
|
||||
host: 'codeman:test',
|
||||
event: 'webhook:test',
|
||||
});
|
||||
expect(notifier.lastResult?.ok).toBe(true);
|
||||
expect(res.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
|
||||
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
|
||||
expect(get.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user', () => {
|
||||
it.each([
|
||||
['GET', '/api/webhook'],
|
||||
['PUT', '/api/webhook'],
|
||||
['POST', '/api/webhook/test'],
|
||||
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'root', role: 'admin' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview A session's launch model survives recovery.
|
||||
*
|
||||
* `Session._model` is what becomes `claude --model <id>`: the caller's per-session `model`
|
||||
* from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a
|
||||
* relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and
|
||||
* both recovery constructors hand it back. Without that, a recovered session silently
|
||||
* relaunches on the account default.
|
||||
*
|
||||
* `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where
|
||||
* `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every
|
||||
* workspace before building a Session in its route tests. The two constructors are therefore
|
||||
* pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the
|
||||
* round trip itself is driven through a real `Session` against the in-memory tmux layer.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager, buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, RespawnPaneOptions, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import type { EffortLevel } from '../src/types.js';
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
describe('the launch model survives recovery', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('persists the model in the session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('relaunches a session rebuilt from that state on the same model', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
|
||||
// Rebuilt the way both recovery paths build one, from the persisted record.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir,
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
model: state.model,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' }));
|
||||
});
|
||||
|
||||
it('is handed back by both recovery constructors', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
expect(server).toMatch(/model:\s*savedState\?\.model,/);
|
||||
expect(reboot).toMatch(/model:\s*saved\.model,/);
|
||||
});
|
||||
|
||||
it('is neither published nor persisted for a CLI that keeps its model in its own config', () => {
|
||||
// Cron hands the app-wide default (a Claude id) to every CLI that has a model at all.
|
||||
// codex never launches on the top-level field, so publishing it would report a model the
|
||||
// session never ran on, and recovery would carry that wrong value forward.
|
||||
for (const mode of ['codex', 'opencode', 'shell'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, model: 'claude-fable-5-1' });
|
||||
sessions.push(session);
|
||||
expect(session.toState().model, mode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 (`advisorModel` → the launch's ONE
|
||||
* `--settings` JSON) landed together and touch the same launch and recovery code. A claude
|
||||
* session carrying both, with or without ultracode (whose blob shares that `--settings`
|
||||
* object), must relaunch with both on every recovery path.
|
||||
*/
|
||||
describe('a launch model and an advisor survive recovery together', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const ADVISOR = 'opus';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of sessions.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
/** The settings JSON exactly as a shell would hand it to claude. */
|
||||
function settingsOf(cmd: string): unknown {
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
const tail = cmd.slice(cmd.indexOf('--settings '));
|
||||
return JSON.parse(execFileSync('bash', ['-c', `set -- ${tail}; printf '%s' "$2"`]).toString());
|
||||
}
|
||||
|
||||
/** Render what tmux-manager hands buildSpawnCommand for these options. */
|
||||
function launchLine(options: Pick<RespawnPaneOptions, 'sessionId' | 'model' | 'effort' | 'advisorModel'>): string {
|
||||
return buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: options.sessionId,
|
||||
model: options.model,
|
||||
effort: options.effort,
|
||||
advisorModel: options.advisorModel,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
function expectBoth(cmd: string, effort: EffortLevel | undefined): void {
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
expect(settingsOf(cmd)).toEqual(
|
||||
effort === 'ultracode' ? { ultracode: true, advisorModel: ADVISOR } : { advisorModel: ADVISOR }
|
||||
);
|
||||
if (effort && effort !== 'ultracode') expect(cmd).toContain(`--effort '${effort}'`);
|
||||
}
|
||||
|
||||
function persistedRecord(effort: EffortLevel | undefined) {
|
||||
const original = new Session({ workingDir: '/tmp', mode: 'claude', model: MODEL, advisorModel: ADVISOR, effort });
|
||||
sessions.push(original);
|
||||
const state = original.toState();
|
||||
expect(state.model).toBe(MODEL);
|
||||
expect(state.advisorModel).toBe(ADVISOR);
|
||||
return state;
|
||||
}
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'reboot restore (fresh pane) relaunches on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
// The reboot-restore constructor: no muxSession, so startInteractive() creates a pane.
|
||||
const mux = new TmuxManager();
|
||||
const createSession = vi.spyOn(mux, 'createSession');
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
const options = createSession.mock.calls[0][0];
|
||||
expect(options).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(options), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([['ultracode'], ['high'], [undefined]] as const)(
|
||||
'restoreMuxSessions onto a dead pane respawns on both, effort %s',
|
||||
async (effort) => {
|
||||
const state = persistedRecord(effort);
|
||||
const respawns: RespawnPaneOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
isPaneDead: () => true,
|
||||
setAttached: () => {},
|
||||
respawnPane: async (options: RespawnPaneOptions) => {
|
||||
respawns.push(options);
|
||||
return 4242;
|
||||
},
|
||||
} as unknown as TerminalMultiplexer;
|
||||
// The restoreMuxSessions() constructor: an existing muxSession, whose pane is dead, so
|
||||
// startInteractive() takes the dead-pane respawn.
|
||||
const rebuilt = new Session({
|
||||
id: state.id,
|
||||
workingDir: '/tmp',
|
||||
mode: state.mode,
|
||||
mux,
|
||||
useMux: true,
|
||||
muxSession: { muxName: 'codeman-aaaa', sessionId: state.id } as unknown as MuxSession,
|
||||
effort: state.effort,
|
||||
model: state.model,
|
||||
advisorModel: state.advisorModel,
|
||||
});
|
||||
sessions.push(rebuilt);
|
||||
await rebuilt.startInteractive();
|
||||
|
||||
expect(respawns).toHaveLength(1);
|
||||
expect(respawns[0]).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
|
||||
expectBoth(launchLine(respawns[0]), effort);
|
||||
}
|
||||
);
|
||||
|
||||
it('both recovery constructors hand back model, advisorModel and effort', () => {
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
|
||||
for (const field of ['effort', 'model', 'advisorModel']) {
|
||||
expect(server).toMatch(new RegExp(`\\b${field}:\\s*savedState\\?\\.${field},`));
|
||||
expect(reboot).toMatch(new RegExp(`\\b${field}:\\s*saved\\.${field},`));
|
||||
}
|
||||
});
|
||||
|
||||
it('tmux-manager forwards model, effort and advisorModel to both launch builders', () => {
|
||||
// createSession() and respawnPane() each build the pane command. The tests above stop at
|
||||
// the options a Session hands them, so this pins the last hop to the builder.
|
||||
const tmux = readFileSync(join(SRC, 'tmux-manager.ts'), 'utf-8');
|
||||
const calls = [...tmux.matchAll(/buildSpawnCommand\(\{([^}]*)\}\)/g)].map((m) => m[1]);
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
for (const field of ['model', 'effort', 'advisorModel']) expect(call).toMatch(new RegExp(`\\b${field},`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
// Regression guard: xterm runs the custom key handler for keydown AND keypress.
|
||||
// It discards a keypress carrying Ctrl/Alt but not one carrying only Shift, so
|
||||
// a handler that returns false for keydown alone lets Shift+Enter's keypress
|
||||
// through as a bare \r (submit). The Enter gate must therefore not be keyed on
|
||||
// ev.type === 'keydown'; only the send-key fetch is.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||
|
||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||
|
||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||
expect(src).toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\)\) \{/);
|
||||
expect(src).not.toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\) && ev\.type === 'keydown'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview Shift/Ctrl+Enter against the SHIPPED key handlers, with real keystrokes in Chromium.
|
||||
*
|
||||
* xterm runs the custom key handler for keydown, keypress AND keyup, and drops a keypress that
|
||||
* carries Ctrl/Alt but NOT a Shift-only one. A handler that returns false for keydown alone
|
||||
* therefore lets Shift+Enter's keypress fall through to onData as a bare \r, which went out over
|
||||
* the WebSocket ahead of the async send-key fetch and submitted the prompt (#520). Ctrl+Enter
|
||||
* worked all along because Chromium fires no keypress for it.
|
||||
*
|
||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other Playwright suites:
|
||||
* npm run test:browser -- test/shift-enter-keypress.browser.test.ts
|
||||
* The CI gate's guard on the same handlers is the source check in
|
||||
* test/shift-enter-keypress-swallowed.test.ts.
|
||||
*
|
||||
* Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3267;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const MAIN_ID = 'shift-enter-probe-main';
|
||||
const PANE_B_ID = 'shift-enter-probe-pane-b';
|
||||
|
||||
interface Pressed {
|
||||
/** What xterm emitted through onData, i.e. what would have been written to the PTY. */
|
||||
data: string[];
|
||||
/** The send-key POSTs the handler made, as `<session id> <key>`. */
|
||||
sendKeys: string[];
|
||||
/** Whether xterm's own textarea held focus, so a key that emits nothing was really delivered. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be swallowed too)', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(
|
||||
({ paneBId }) => {
|
||||
const w = window as any;
|
||||
// Record send-key instead of reaching the server (no tmux pane exists behind these ids).
|
||||
w.__sendKeys = [] as string[];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const m = /\/api\/sessions\/([^/]+)\/send-key$/.exec(url);
|
||||
if (m) {
|
||||
w.__sendKeys.push(`${m[1]} ${JSON.parse(String(init?.body ?? '{}')).key}`);
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
);
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
|
||||
// Pane B, built by the real class. connect() installs the key handler synchronously,
|
||||
// before its first await; the buffer fetch and WebSocket for the missing session just fail.
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(mount);
|
||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
||||
void pane.connect().catch(() => {});
|
||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||
w.__paneBData = [] as string[];
|
||||
pane.terminal.onData((d: string) => w.__paneBData.push(d));
|
||||
w.__paneB = pane;
|
||||
|
||||
// The bare xterm the old keydown-only gate is reproduced on (see the first test).
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;left:420px;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(host);
|
||||
const old = new w.Terminal();
|
||||
old.open(host);
|
||||
w.__oldData = [] as string[];
|
||||
old.onData((d: string) => w.__oldData.push(d));
|
||||
old.attachCustomKeyEventHandler(
|
||||
(ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown')
|
||||
);
|
||||
w.__oldTerm = old;
|
||||
},
|
||||
{ paneBId: PANE_B_ID }
|
||||
);
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* Press `key` in the main pane. `sessionId` null is the welcome screen; otherwise the app thinks
|
||||
* that session is active, local echo is off (the desktop default) and its real send path,
|
||||
* `_sendInputAsync`, is recorded instead of posting.
|
||||
*/
|
||||
async function pressMain(key: string, sessionId: string | null): Promise<Pressed> {
|
||||
await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
w.__mainSaved ??= { id: app.activeSessionId, echo: app._localEchoEnabled, send: app._sendInputAsync };
|
||||
w.__mainData = [] as string[];
|
||||
w.__sendKeys.length = 0;
|
||||
app.activeSessionId = id;
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync = (_sid: string, chunk: string) => void w.__mainData.push(chunk);
|
||||
app.terminal.focus();
|
||||
}, sessionId);
|
||||
const focused = await page.evaluate(() => document.activeElement === (window as any).app.terminal.textarea);
|
||||
await page.keyboard.press(key);
|
||||
// The main pane batches onData through a short flush timer before _sendInputAsync.
|
||||
await page.waitForTimeout(150);
|
||||
return page.evaluate((f) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const out = { data: [...w.__mainData], sendKeys: [...w.__sendKeys], focused: f };
|
||||
app.activeSessionId = w.__mainSaved.id;
|
||||
app._localEchoEnabled = w.__mainSaved.echo;
|
||||
app._sendInputAsync = w.__mainSaved.send;
|
||||
app._pendingInput = '';
|
||||
return out;
|
||||
}, focused);
|
||||
}
|
||||
|
||||
async function pressIn(which: 'paneB' | 'old', key: string): Promise<Pressed> {
|
||||
await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
w[k === 'paneB' ? '__paneBData' : '__oldData'].length = 0;
|
||||
w.__sendKeys.length = 0;
|
||||
(k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).focus();
|
||||
}, which);
|
||||
const focused = await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
return document.activeElement === (k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).textarea;
|
||||
}, which);
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForTimeout(50);
|
||||
return page.evaluate(
|
||||
({ k, f }) => {
|
||||
const w = window as any;
|
||||
return { data: [...w[k === 'paneB' ? '__paneBData' : '__oldData']], sendKeys: [...w.__sendKeys], focused: f };
|
||||
},
|
||||
{ k: which, f: focused }
|
||||
);
|
||||
}
|
||||
|
||||
it('reproduces the leak on a bare xterm with the old keydown-only gate (the behaviour the fix depends on)', async () => {
|
||||
// Not shipped code: this pins the xterm behaviour, so a future xterm that stops emitting the
|
||||
// Shift-only keypress shows up here instead of silently turning the tests below vacuous.
|
||||
const shift = await pressIn('old', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual(['\r']);
|
||||
expect((await pressIn('old', 'Control+Enter')).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('main pane (terminal-ui.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key exactly once', async () => {
|
||||
const shift = await pressMain('Shift+Enter', MAIN_ID);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
// Once, from the keydown: the swallowed keypress and keyup must not send again.
|
||||
expect(shift.sendKeys).toEqual([`${MAIN_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressMain('Control+Enter', MAIN_ID);
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${MAIN_ID} C-Enter`]);
|
||||
});
|
||||
|
||||
it('main pane: plain Enter still reaches the send path as \\r, and Alt+Enter as ESC CR', async () => {
|
||||
const enter = await pressMain('Enter', MAIN_ID);
|
||||
expect(enter.focused).toBe(true);
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
|
||||
const alt = await pressMain('Alt+Enter', MAIN_ID);
|
||||
expect(alt.data).toEqual(['\x1b\r']);
|
||||
expect(alt.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('main pane on the welcome screen: Shift+Enter sends nothing and posts no send-key', async () => {
|
||||
// With no active session the app's onData drops input anyway, so this pins the send-key
|
||||
// guard (`this.activeSessionId`), not the keypress swallow; the tests above pin that.
|
||||
const shift = await pressMain('Shift+Enter', null);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([`${PANE_B_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressIn('paneB', 'Control+Enter');
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${PANE_B_ID} C-Enter`]);
|
||||
|
||||
const enter = await pressIn('paneB', 'Enter');
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ type PaneUnderTest = {
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
@@ -80,9 +82,27 @@ function loadSplitTerminalPane() {
|
||||
// compares it with the pane's own row count.
|
||||
window: {
|
||||
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
|
||||
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
|
||||
// The primary pane's capture budget (constants.js): the full-capture default.
|
||||
CodemanFetchDeadline: { terminalFetchDeadlineMs: () => 45_000 },
|
||||
AbortController: class {
|
||||
signal = { aborted: false };
|
||||
abort() {
|
||||
this.signal.aborted = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
performance: { now: () => clock },
|
||||
// Deadline timers (>= 1 s) are recorded, never run: a test fires one by hand
|
||||
// and reads what it aborted. Anything shorter (xterm chunk pacing) is real.
|
||||
setTimeout: (fn: () => void, ms?: number) => {
|
||||
if ((ms ?? 0) < 1000) return setTimeout(fn, ms);
|
||||
deadlines.push({ fn, ms: ms as number, cleared: false });
|
||||
return -deadlines.length; // negative: never collides with a real timer id
|
||||
},
|
||||
clearTimeout: (id: unknown) => {
|
||||
if (typeof id === 'number' && id < 0) deadlines[-id - 1].cleared = true;
|
||||
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
|
||||
},
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
@@ -126,6 +146,27 @@ function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {
|
||||
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A response whose headers have landed but whose body has not: the window in
|
||||
* which the pull's live queue is open and nothing else has happened yet.
|
||||
* `release(buffer)` delivers the body; `fail()` errors the body read.
|
||||
*/
|
||||
function headersOnly() {
|
||||
let release!: (body: ReturnType<typeof jsonResponse> | Error) => void;
|
||||
const body = new Promise<{ data: Record<string, unknown> }>((resolve, reject) => {
|
||||
release = (value) => {
|
||||
if (value instanceof Error) reject(value);
|
||||
else void value.json().then(resolve);
|
||||
};
|
||||
});
|
||||
return {
|
||||
response: { json: () => body },
|
||||
release: (terminalBuffer: string, extra: Record<string, unknown> = {}) =>
|
||||
release(jsonResponse(terminalBuffer, extra)),
|
||||
fail: () => release(new Error('body read failed')),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
@@ -142,6 +183,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
deadlines.length = 0;
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
@@ -301,10 +343,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// With a deadline: live output is held for as long as the pull runs, so a
|
||||
// request that never answers would freeze the pane.
|
||||
// With a deadline, so a request that never answers cannot pin the pane.
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: { timeoutMs: 10_000 },
|
||||
signal: expect.objectContaining({ aborted: false }),
|
||||
});
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
@@ -463,12 +504,14 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(pane._liveQueue).toEqual([]);
|
||||
// The queue opens with the response, not the request.
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
|
||||
// Arrives before the response does: it is IN the capture already.
|
||||
// Arrives before the response does: written straight through (the pane
|
||||
// keeps painting during the round trip), and the replay then replaces it.
|
||||
clock = 1;
|
||||
pane._onLiveOutput('early');
|
||||
expect(term.write).not.toHaveBeenCalledWith('early');
|
||||
expect(term.write).toHaveBeenCalledWith('early');
|
||||
await settle();
|
||||
|
||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||
@@ -490,7 +533,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
await settle();
|
||||
|
||||
const written = term.write.mock.calls.map((call) => call[0]);
|
||||
expect(written).not.toContain('early');
|
||||
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
|
||||
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
|
||||
expect(written.filter((w) => w === 'early')).toHaveLength(1);
|
||||
expect(written.at(-1)).toBe('late');
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -498,26 +543,29 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
it('writes every held frame when the pull ends without replaying', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
pane._onLiveOutput('held');
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('held');
|
||||
held.release(rowsOf(30)); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
// Nothing replaced the terminal, so the frame is news even though it
|
||||
// arrived before the response did.
|
||||
// Nothing replaced the terminal, so the held frame is news.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
held.fail(); // the body read dies with the queue open
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -554,17 +602,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
done?.();
|
||||
});
|
||||
term.clear.mockImplementation(() => order.push('clear'));
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('before');
|
||||
pane._onLiveClear();
|
||||
pane._onLiveOutput('after');
|
||||
// Held: clearing now would wipe a half-written snapshot.
|
||||
expect(order).toEqual([]);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
held.release(rowsOf(30)); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
||||
@@ -583,25 +632,27 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
clock = 1;
|
||||
pane._onLiveClear(); // already reflected in the capture
|
||||
pane._onLiveClear(); // before the response: applied now, already in the capture
|
||||
expect(term.clear).toHaveBeenCalledTimes(1);
|
||||
clock = 2;
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.clear).not.toHaveBeenCalled();
|
||||
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture
|
||||
});
|
||||
|
||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
pane.destroy();
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
held.release(rowsOf(100));
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -613,10 +664,13 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
|
||||
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
pane._onLiveOutput('held');
|
||||
held.fail(); // the deadline aborts the body read
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
@@ -698,18 +752,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
|
||||
// The close lands while the capture is in flight, so the HTTP pull still
|
||||
it('ends on the disconnected marker if the socket closes mid-fetch, before the queue opens', async () => {
|
||||
// The close lands while the request is in flight, so the HTTP pull still
|
||||
// succeeds (a Codeman restart drops the WS while the tmux session, and so
|
||||
// the pull, survives) and the replay that follows is what the marker must
|
||||
// end up below.
|
||||
// the pull, survives). The marker waits for the load to finish, then goes
|
||||
// below the replay.
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
pane._onSocketClosed();
|
||||
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await pull;
|
||||
|
||||
@@ -718,25 +772,105 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('a close mid-fetch that ends without a replay still writes exactly one marker', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onSocketClosed();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // already held in full: no replay
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a close during a refresh load lands the marker below the replay, not above it', async () => {
|
||||
// A {t:'r'} refresh clears and refetches; a close mid-fetch used to write
|
||||
// the marker at once, and the replay then landed underneath it.
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
pane._onSocketClosed();
|
||||
response.resolve(jsonResponse('refreshed'));
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.indexOf('refreshed')).toBeLessThan(writes.findIndex(isMarker));
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => {
|
||||
// R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to
|
||||
// R2 instead of stamping it: in real xterm R1's write would still be queued
|
||||
// when R2's synchronous clear() runs, and would land above R2's replay. The
|
||||
// write mock records every stamp whatever clear() does, so counting marker
|
||||
// writes pins that R1 never stamps (the async-parse test below shows why).
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
const second = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
|
||||
pane._refreshBuffer(); // R1
|
||||
pane._refreshBuffer(); // coalesced into the trailing R2
|
||||
first.resolve(jsonResponse('first'));
|
||||
await settle();
|
||||
// R1 settled its marker, then R2 cleared and is still fetching.
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
second.resolve(jsonResponse('second'));
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.at(-1)).toSatisfy(isMarker);
|
||||
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the pull gives the request the long budget and the body read the short one', async () => {
|
||||
const pane = makePane('shell');
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
expect(deadlines).toHaveLength(1);
|
||||
expect(deadlines[0].ms).toBe(45_000);
|
||||
await settle(); // headers landed
|
||||
expect(deadlines).toHaveLength(2);
|
||||
expect(deadlines[0].cleared).toBe(true);
|
||||
expect(deadlines[1].ms).toBe(10_000);
|
||||
|
||||
held.release(rowsOf(30));
|
||||
await pull;
|
||||
expect(deadlines[1].cleared).toBe(true); // nothing left to abort a settled pull
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
|
||||
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
|
||||
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
|
||||
['a skip', 40, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(30))],
|
||||
['a downgrade', 500, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(5))],
|
||||
['a failed body read', 40, (h: ReturnType<typeof headersOnly>) => h.fail()],
|
||||
])(
|
||||
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
|
||||
async (_label, rowsHeld, mockFetch) => {
|
||||
'a close with the queue open that ends in %s writes the marker last, after the held frames',
|
||||
async (_label, rowsHeld, finish) => {
|
||||
// No replay ever runs here, so nothing would wipe a marker written at the
|
||||
// close; written straight away it sat ABOVE the output the pull was still
|
||||
// holding, which the finally block then flushed underneath it.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = rowsHeld;
|
||||
mockFetch();
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
await settle(); // the response landed: the queue is open
|
||||
pane._onLiveOutput('frame-A');
|
||||
pane._onLiveOutput('frame-B');
|
||||
pane._onSocketClosed();
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
finish(held);
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
@@ -771,6 +905,96 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => {
|
||||
// The refresh's clear() runs after the pull's finally block has written the
|
||||
// marker, so without a re-stamp the dead pane would look current again.
|
||||
const pane = makePane('shell');
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
await settle();
|
||||
pane._refreshBuffer(); // coalesced into one trailing re-run
|
||||
pane._onSocketClosed(); // deferred: the queue is open
|
||||
held.release(rowsOf(30)); // no replay
|
||||
await pull;
|
||||
await settle();
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(writes).toContain('refreshed');
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
|
||||
// The pull left the owed marker to the refresh rather than stamping it too.
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'back-to-back refreshes',
|
||||
async (pane: PaneUnderTest) => {
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('first')).mockResolvedValueOnce(jsonResponse('second'));
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer(); // coalesced into the trailing re-run
|
||||
},
|
||||
],
|
||||
[
|
||||
'a pull with a queued refresh and a close mid-pull',
|
||||
async (pane: PaneUnderTest) => {
|
||||
const held = headersOnly();
|
||||
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('second'));
|
||||
void pane._pullHistory();
|
||||
await settle(); // the response landed: the queue is open
|
||||
pane._refreshBuffer(); // coalesced into the trailing re-run
|
||||
pane._onSocketClosed();
|
||||
held.release(rowsOf(30)); // no replay
|
||||
},
|
||||
],
|
||||
])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => {
|
||||
// Real xterm queues write() and parses it on a later tick (WriteBuffer's
|
||||
// setTimeout), while clear() rewrites the buffer at once. The default fake
|
||||
// applies writes synchronously and so cannot show a marker overtaken by a
|
||||
// trailing refresh's clear(): parsed after it, that marker sat above the
|
||||
// refresh's replay as a second, stale copy.
|
||||
const pane = makePane('shell');
|
||||
const screen: string[] = [];
|
||||
const pending: Array<{ data: string; done?: () => void }> = [];
|
||||
pane.terminal.write = vi.fn((data: string, done?: () => void) => {
|
||||
if (pending.length === 0) {
|
||||
setTimeout(() => {
|
||||
for (const entry of pending.splice(0)) {
|
||||
if (entry.data === '\x1bc') screen.length = 0;
|
||||
else if (entry.data) screen.push(entry.data);
|
||||
entry.done?.();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
pending.push({ data, done });
|
||||
});
|
||||
pane.terminal.clear = vi.fn(() => {
|
||||
screen.length = 0;
|
||||
});
|
||||
|
||||
await drive(pane);
|
||||
for (let i = 0; i < 5; i++) await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(screen.filter(isMarker)).toHaveLength(1);
|
||||
expect(screen.at(-1)).toSatisfy(isMarker);
|
||||
expect(screen.indexOf('second')).toBe(screen.length - 2);
|
||||
});
|
||||
|
||||
it('a refresh on an open socket does not stamp a marker', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the socket is still open', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @fileoverview Real-Chromium coverage for tab-row activation and the grouped rail tree.
|
||||
*
|
||||
* A tab row is one activation target: its status dot, mode chip, name and unused
|
||||
* padding all select it, while its own controls (gear, detach, close, the rail's
|
||||
* overflow button, a web tab's gear and close) run only their own action. The
|
||||
* controls must also keep a stable hit target: revealing one on hover may not
|
||||
* slide its neighbours out from under a pointer already aiming at them.
|
||||
*
|
||||
* DOM emulation cannot answer either question (hit testing, hover reveal and
|
||||
* layout are Chromium's), which is why this runs in a real browser. The shipping
|
||||
* app.js, webview-tabs.js, terminal-ui.js (for the touch keyboard dismissal) and
|
||||
* styles.css are loaded into a page; the CodemanApp instance gets stub actions
|
||||
* that only record what ran.
|
||||
*
|
||||
* Port: none (page.setContent, no server).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
describe('tab row activation in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
// A real origin, so localStorage (per-device collapse) works as it does in the app.
|
||||
await page.route('http://codeman.test/', (route) =>
|
||||
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
||||
);
|
||||
await page.goto('http://codeman.test/');
|
||||
await page.setContent(`<!doctype html>
|
||||
<html data-tab-orientation="horizontal" data-tab-rail-sort="manual">
|
||||
<head><style>${read('styles.css')}</style></head>
|
||||
<body>
|
||||
<header class="header"><div id="sessionTabsHost"></div></header>
|
||||
<main class="main" style="width:100%;height:700px">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation"></aside>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
await page.addScriptTag({
|
||||
content:
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||
});
|
||||
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||
await page.addScriptTag({ content: read('terminal-ui.js') });
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = Object.create(w.CodemanApp.prototype);
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['one', { id: 'one', name: 'One', status: 'idle', mode: 'shell' }],
|
||||
['two', { id: 'two', name: 'Two', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['one', 'two'];
|
||||
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
|
||||
app.webviewOrder = ['web'];
|
||||
app.activeSessionId = 'one';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => true;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => undefined;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app.updateConnectionLines = () => undefined;
|
||||
app._applyTabEntrances = () => undefined;
|
||||
app._scrollActiveTabIntoView = () => undefined;
|
||||
app.applySidebarFilter = () => undefined;
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => undefined;
|
||||
app._stopSidebarRichClock = () => undefined;
|
||||
const record = (key: string, value: string) => () => {
|
||||
w[key] = value;
|
||||
};
|
||||
app.selectSession = (id: string) => {
|
||||
w.__activation = `session:${id}`;
|
||||
};
|
||||
app.openWebview = (id: string) => {
|
||||
w.__activation = `webview:${id}`;
|
||||
};
|
||||
app.openSessionOptions = record('__action', 'settings');
|
||||
app.detachSession = record('__action', 'detach');
|
||||
app.requestCloseSession = record('__action', 'close');
|
||||
app.openTabRailActionMenu = record('__action', 'overflow');
|
||||
app.showWebviewModal = record('__action', 'web-settings');
|
||||
app.closeWebviewTab = record('__action', 'web-close');
|
||||
w.__setApp(app);
|
||||
w.__app = app;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
/** Render the strip/rail in a given shape, from scratch. */
|
||||
async function render(orientation: 'horizontal' | 'vertical', grouped = false) {
|
||||
await page.evaluate(
|
||||
({ orientation, grouped }) => {
|
||||
const w = window as any;
|
||||
document.documentElement.setAttribute('data-tab-orientation', orientation);
|
||||
const host =
|
||||
orientation === 'vertical'
|
||||
? document.getElementById('tabRail')!
|
||||
: document.getElementById('sessionTabsHost')!;
|
||||
document.getElementById('sessionTabs')?.remove();
|
||||
host.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
||||
);
|
||||
w.__app._tabKeydownHandler = null;
|
||||
w.__app.activeSessionId = 'one';
|
||||
w.__app.activeWebviewId = null;
|
||||
w.__app.tabLayout = grouped
|
||||
? {
|
||||
version: 1,
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
name: 'Group',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'one' },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'two' }],
|
||||
}
|
||||
: null;
|
||||
w.__app._fullRenderSessionTabs();
|
||||
w.__activation = null;
|
||||
w.__action = null;
|
||||
},
|
||||
{ orientation, grouped }
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
|
||||
const result = () =>
|
||||
page.evaluate(() => ({ activation: (window as any).__activation, action: (window as any).__action }));
|
||||
const reset = () => page.evaluate(() => ((window as any).__activation = (window as any).__action = null));
|
||||
|
||||
for (const [label, orientation, grouped] of [
|
||||
['horizontal strip', 'horizontal', false],
|
||||
['flat vertical rail', 'vertical', false],
|
||||
['grouped vertical rail', 'vertical', true],
|
||||
] as const) {
|
||||
it(`activates a row from its status, mode, name and padding (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"] .tab-status', 'session:two'],
|
||||
['[data-id="two"] .tab-name', 'session:two'],
|
||||
['[data-id="one"] .tab-mode', 'session:one'],
|
||||
['[data-webview-id="web"] .tab-name', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect((await result()).activation, selector).toBe(expected);
|
||||
}
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"]', 'session:two'],
|
||||
['[data-webview-id="web"]', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const box = (await page.locator(selector).boundingBox())!;
|
||||
await page.mouse.click(box.x + 2, box.y + box.height / 2);
|
||||
expect((await result()).activation, `${selector} leading padding`).toBe(expected);
|
||||
// The cursor advertises the whole row: a pointer, or the grab hand where
|
||||
// the row is also a drag-reorder handle (the flat lists).
|
||||
const { cursor, draggable } = await page
|
||||
.locator(selector)
|
||||
.evaluate((el) => ({ cursor: getComputedStyle(el).cursor, draggable: el.getAttribute('draggable') }));
|
||||
expect(cursor, selector).toBe(draggable === 'true' ? 'grab' : 'pointer');
|
||||
}
|
||||
});
|
||||
|
||||
it(`runs a control's own action without selecting the row (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="one"] .tab-gear', 'settings'],
|
||||
['[data-id="one"] .tab-detach', 'detach'],
|
||||
['[data-id="one"] .tab-close', 'close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const control = page.locator(selector);
|
||||
expect(await control.isVisible(), selector).toBe(true);
|
||||
expect(await control.evaluate((el) => getComputedStyle(el).cursor), selector).toBe('pointer');
|
||||
await control.click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
if (orientation === 'vertical') {
|
||||
await reset();
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
(window as any).__app.activeWebviewId = 'web';
|
||||
(window as any).__app._fullRenderSessionTabs();
|
||||
});
|
||||
for (const [selector, expected] of [
|
||||
['[data-webview-id="web"] .tab-gear', 'web-settings'],
|
||||
['[data-webview-id="web"] .tab-close', 'web-close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
});
|
||||
|
||||
it(`keeps action controls anchored under the pointer when a row is hovered (${label})`, async () => {
|
||||
// Revealing a control on hover must not reflow its row: a button that
|
||||
// toggled `display` on hover once slid the gear 28px left, so the click
|
||||
// aimed at the gear landed on the close button instead. Assert the
|
||||
// geometry, not a CSS property, so any reflow-on-hover rewrite fails here.
|
||||
await render(orientation, grouped);
|
||||
for (const selector of ['[data-id="one"] .tab-gear', '[data-id="one"] .tab-close']) {
|
||||
await page.mouse.move(1, 1);
|
||||
const control = page.locator(selector);
|
||||
const before = (await control.boundingBox())!;
|
||||
const aim = { x: Math.round(before.x + before.width / 2), y: Math.round(before.y + before.height / 2) };
|
||||
await page.mouse.move(aim.x, aim.y);
|
||||
await page.waitForTimeout(250);
|
||||
const after = (await control.boundingBox())!;
|
||||
expect(Math.abs(after.x - before.x), selector).toBeLessThanOrEqual(4);
|
||||
const stillOnTarget = await page.evaluate(
|
||||
({ point, selector }) => !!document.elementFromPoint(point.x, point.y)?.closest(selector),
|
||||
{ point: aim, selector }
|
||||
);
|
||||
expect(stillOnTarget, selector).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('toggles a group from its header without selecting anything', async () => {
|
||||
await render('vertical', true);
|
||||
const header = page.locator('[data-tab-group-header="g"]');
|
||||
await header.click();
|
||||
expect(await result()).toEqual({ activation: null, action: null });
|
||||
expect(await header.getAttribute('aria-expanded')).toBe('false');
|
||||
await page.locator('[data-tab-group-header="g"]').click();
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('walks the grouped rail tree from the keyboard with one tab stop', async () => {
|
||||
await render('vertical', true);
|
||||
const tree = page.locator('#sessionTabs[role="tree"]');
|
||||
expect(await tree.count()).toBe(1);
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
// The roving tab stop starts on the selected row.
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('false');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
await page.keyboard.press('End');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('two');
|
||||
await page.keyboard.press('Home');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-webview-id'))).toBe('web');
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await result()).activation).toBe('webview:web');
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves Enter on a focused in-row control to that control in the tree', async () => {
|
||||
await render('vertical', true);
|
||||
// A click focuses the overflow button (the action menu hands focus back the
|
||||
// same way on Escape). Enter there must run the button, not re-select the row.
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the touch keyboard up when a tree header or unselected row is tapped', async () => {
|
||||
await render('vertical', true);
|
||||
const state = await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = w.__app;
|
||||
const input = document.createElement('textarea');
|
||||
input.className = 'xterm-helper-textarea';
|
||||
document.body.appendChild(input);
|
||||
app._installMobileKeyboardDismiss();
|
||||
// A finger that lifts where it landed (no travel): the handler reads only
|
||||
// `touches`, so a plain Event carrying them stands in for a TouchEvent.
|
||||
const tap = (target: Element) => {
|
||||
const at = (type: string, touches: Array<{ clientX: number; clientY: number }>) => {
|
||||
const ev = new Event(type, { bubbles: true });
|
||||
Object.defineProperty(ev, 'touches', { value: touches });
|
||||
target.dispatchEvent(ev);
|
||||
};
|
||||
at('touchstart', [{ clientX: 10, clientY: 10 }]);
|
||||
at('touchend', []);
|
||||
};
|
||||
const keptAfterTap = (selector: string) => {
|
||||
input.focus();
|
||||
tap(document.querySelector(selector)!);
|
||||
return document.activeElement === input;
|
||||
};
|
||||
const out = {
|
||||
header: keptAfterTap('[data-tab-group-header="g"] .tab-layout-group-name'),
|
||||
unselectedRow: keptAfterTap('[data-webview-id="web"] .tab-name'),
|
||||
inertChrome: false,
|
||||
};
|
||||
// Inert chrome still dismisses, so the handler is live.
|
||||
out.inertChrome = !keptAfterTap('.tab-layout-ungrouped-header');
|
||||
document.removeEventListener('touchstart', app._mobileKeyboardDismissStart);
|
||||
document.removeEventListener('touchmove', app._mobileKeyboardDismissMove);
|
||||
document.removeEventListener('touchend', app._mobileKeyboardDismissHandler);
|
||||
app._mobileKeyboardDismissHandler = null;
|
||||
input.remove();
|
||||
return out;
|
||||
});
|
||||
expect(state).toEqual({ header: true, unselectedRow: true, inertChrome: true });
|
||||
});
|
||||
|
||||
it('rings a collapsed header in the tab alert colour of the row it hides', async () => {
|
||||
await render('vertical', true);
|
||||
const ring = await page.evaluate(() => {
|
||||
const app = (window as any).__app;
|
||||
// "two" is the selection, so collapsing the group hides "one".
|
||||
app.activeSessionId = 'two';
|
||||
app.tabAlerts.set('one', 'action');
|
||||
app.toggleTabGroupCollapsed('g', true);
|
||||
const header = document.querySelector('[data-tab-group-header="g"]')!;
|
||||
const before = getComputedStyle(header, '::before');
|
||||
const out = {
|
||||
hidden: !document.querySelector('[data-id="one"]'),
|
||||
className: header.classList.contains('tab-alert-action'),
|
||||
content: before.content,
|
||||
borderColor: before.borderTopColor,
|
||||
};
|
||||
app.tabAlerts.clear();
|
||||
app.toggleTabGroupCollapsed('g', false);
|
||||
return out;
|
||||
});
|
||||
expect(ring.hidden).toBe(true);
|
||||
expect(ring.className).toBe(true);
|
||||
expect(ring.content).not.toBe('none');
|
||||
// --red, whatever the skin resolves it to: a real colour, not transparent.
|
||||
expect(ring.borderColor).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/);
|
||||
});
|
||||
|
||||
it('keeps tab semantics and no tree roles without groups', async () => {
|
||||
await render('vertical', false);
|
||||
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
|
||||
expect(
|
||||
await page
|
||||
.locator('#sessionTabs [role="tree"], #sessionTabs [role="treeitem"], #sessionTabs [role="group"]')
|
||||
.count()
|
||||
).toBe(0);
|
||||
expect(await page.locator('#sessionTabs .session-tab[role="tab"]').count()).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* @fileoverview Browser projection of the owner tab layout (tab-layout-browser.js).
|
||||
*
|
||||
* Pins the pure half of the grouped vertical rail: which live rows land in which
|
||||
* group, what a collapsed group hides (and the one row it must keep showing),
|
||||
* the per-device collapse storage, the grouped markup, and newest-wins loading.
|
||||
*
|
||||
* Port: none (vm-loaded module + static wiring assertions).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(new URL('../src/web/public/tab-layout-browser.js', import.meta.url), 'utf8');
|
||||
const APP_SOURCE = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf8');
|
||||
const INDEX_SOURCE = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
|
||||
const BUILD_SOURCE = readFileSync(new URL('../scripts/build.mjs', import.meta.url), 'utf8');
|
||||
|
||||
type Ref = { kind: 'session' | 'webview'; id: string };
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
|
||||
return (context.window as any).CodemanTabLayout;
|
||||
}
|
||||
|
||||
const escape = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const layout = (version = 4) => ({
|
||||
version,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
{ kind: 'webview', id: 'saved-closed' },
|
||||
{ kind: 'session', id: 'gone' },
|
||||
],
|
||||
},
|
||||
{ id: 'g2', name: 'Later', refs: [{ kind: 'session', id: 's3' }] },
|
||||
],
|
||||
ungrouped: [
|
||||
{ kind: 'webview', id: 'w2' },
|
||||
{ kind: 'session', id: 's4' },
|
||||
{ kind: 'session', id: 's2' },
|
||||
],
|
||||
});
|
||||
|
||||
const ids = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('projection', () => {
|
||||
it('returns null for a layout without groups, so the caller keeps the flat rail', () => {
|
||||
const h = loadHelper();
|
||||
expect(h.project({ version: 1, groups: [], ungrouped: [{ kind: 'session', id: 's1' }] }, {})).toBeNull();
|
||||
expect(h.project(null, {})).toBeNull();
|
||||
expect(h.hasGroups(h.normalizeLayout({ version: 1 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('places stored refs in their groups, skips gone/unopened refs, and deduplicates', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
});
|
||||
expect(result.sections.map((s: any) => [s.id, ids(s.refs), s.count])).toEqual([
|
||||
['g1', ['session:s2', 'webview:w1', 'session:s1'], 3],
|
||||
['g2', ['session:s3'], 1],
|
||||
[null, ['webview:w2', 'session:s4'], 2],
|
||||
]);
|
||||
expect(new Set(ids(result.visibleRefs)).size).toBe(result.visibleRefs.length);
|
||||
});
|
||||
|
||||
it('appends live sessions and open web tabs the layout has not placed, in caller order', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), {
|
||||
liveSessionIds: ['new-b', 's1', 'new-a'],
|
||||
openWebviewIds: ['w-local'],
|
||||
});
|
||||
expect(ids(result.sections.at(-1).refs)).toEqual(['session:new-b', 'session:new-a', 'webview:w-local']);
|
||||
});
|
||||
|
||||
it('omits an empty ungrouped section', () => {
|
||||
const h = loadHelper();
|
||||
const result = h.project(layout(), { liveSessionIds: ['s1', 's3'], openWebviewIds: [] });
|
||||
expect(result.sections.map((s: any) => s.id)).toEqual(['g1', 'g2']);
|
||||
});
|
||||
|
||||
it('hides a collapsed group but keeps its highlighted row, and records where hidden rows went', () => {
|
||||
const h = loadHelper();
|
||||
const common = {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g1'],
|
||||
};
|
||||
const collapsed = h.project(layout(), { ...common, activeSessionId: 's1' });
|
||||
expect(ids(collapsed.sections[0].refs)).toEqual(['session:s1']);
|
||||
expect(collapsed.sections[0].count).toBe(3);
|
||||
expect(collapsed.sections[0].collapsed).toBe(true);
|
||||
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
|
||||
expect(ids(collapsed.sections[0].hidden)).toEqual(['session:s2', 'webview:w1']);
|
||||
// Every placed row knows its section, shown or hidden (null = Ungrouped).
|
||||
expect(collapsed.sectionByRef).toEqual({
|
||||
'session:s2': 'g1',
|
||||
'webview:w1': 'g1',
|
||||
'session:s1': 'g1',
|
||||
'session:s3': 'g2',
|
||||
'webview:w2': null,
|
||||
'session:s4': null,
|
||||
});
|
||||
|
||||
// An active web tab owns the highlight even while a session stays selected.
|
||||
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
|
||||
expect(ids(web.sections[0].refs)).toEqual(['webview:w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structure key', () => {
|
||||
it('changes with every structural input and ignores everything else', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
const base = h.structureKey(layout(), h.project(layout(), opts), []);
|
||||
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
|
||||
base
|
||||
);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
|
||||
const renamed = { ...layout(), groups: [{ ...layout().groups[0], name: 'Renamed' }, layout().groups[1]] };
|
||||
expect(h.structureKey(renamed, h.project(renamed, opts), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), null, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a version bump that moves nothing, so a lifecycle broadcast costs no rebuild', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).toBe(
|
||||
h.structureKey(layout(4), h.project(layout(4), opts), [])
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when a collapse hides a different row, even with equal counts and shown rows', () => {
|
||||
const h = loadHelper();
|
||||
const two = (refs: Ref[][]) => ({
|
||||
version: 1,
|
||||
groups: [
|
||||
{ id: 'a', name: 'A', refs: refs[0] },
|
||||
{ id: 'b', name: 'B', refs: refs[1] },
|
||||
],
|
||||
ungrouped: [],
|
||||
});
|
||||
const s = (id: string): Ref => ({ kind: 'session', id });
|
||||
const opts = { liveSessionIds: ['x', 'y'], openWebviewIds: [], collapsedGroupIds: ['a', 'b'] };
|
||||
const before = two([[s('x')], [s('y')]]);
|
||||
const after = two([[s('y')], [s('x')]]);
|
||||
// Lineage anchors a hidden row to its header, so who hides where is structure.
|
||||
expect(h.structureKey(after, h.project(after, opts), ['a', 'b'])).not.toBe(
|
||||
h.structureKey(before, h.project(before, opts), ['a', 'b'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapsed header alerts', () => {
|
||||
it('reports the most urgent alert among the session rows a collapse hides', () => {
|
||||
const h = loadHelper();
|
||||
const alerts = new Map([
|
||||
['s2', 'idle'],
|
||||
['s1', 'action'],
|
||||
['s3', 'idle'],
|
||||
['s4', 'action'],
|
||||
]);
|
||||
const alertOf = (id: string) => alerts.get(id);
|
||||
const common = { liveSessionIds: ['s1', 's2', 's3', 's4'], openWebviewIds: ['w1', 'w2'] };
|
||||
// s1 is the kept selection, so it draws its own ring: only s2 (idle) is behind g1.
|
||||
const kept = h.project(layout(), { ...common, collapsedGroupIds: ['g1', 'g2'], activeSessionId: 's1' });
|
||||
expect(h.hiddenGroupAlerts(kept, alertOf)).toEqual({ g1: 'idle', g2: 'idle' });
|
||||
// With s1 hidden too, the red one wins over the yellow one.
|
||||
const hidden = h.project(layout(), { ...common, collapsedGroupIds: ['g1'], activeSessionId: 's4' });
|
||||
expect(h.hiddenGroupAlerts(hidden, alertOf)).toEqual({ g1: 'action' });
|
||||
// Expanded groups and the ungrouped section never report (their rows are on screen).
|
||||
expect(h.hiddenGroupAlerts(h.project(layout(), common), alertOf)).toEqual({});
|
||||
expect(h.hiddenGroupAlerts(null, alertOf)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device collapse storage', () => {
|
||||
function storage(initial: string | null = null) {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== null) values.set('codeman:tab-groups-collapsed', initial);
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, next: string) => {
|
||||
values.set(key, next);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('round-trips collapsed group ids', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage();
|
||||
expect(h.saveCollapsedGroupIds(local, ['g2', 'g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
|
||||
});
|
||||
|
||||
it('garbage-collects ids of groups that no longer exist', () => {
|
||||
const h = loadHelper();
|
||||
const local = storage('["gone","g1"]');
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
|
||||
});
|
||||
|
||||
it('reports failure (all-expanded) on unreadable or unwritable storage', () => {
|
||||
const h = loadHelper();
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('quota');
|
||||
},
|
||||
};
|
||||
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
});
|
||||
|
||||
it('reads a malformed stored value as nothing collapsed and repairs it, so collapse keeps working', () => {
|
||||
const h = loadHelper();
|
||||
for (const bad of ['{"g1":true}', 'not json', '"g1"', 'null']) {
|
||||
const local = storage(bad);
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: [], ok: true });
|
||||
expect(local.setItem, bad).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '[]');
|
||||
expect(h.saveCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
}
|
||||
// Without a group list to validate against, nothing is written.
|
||||
const untouched = storage('{"g1":true}');
|
||||
expect(h.loadCollapsedGroupIds(untouched)).toEqual({ ids: [], ok: true });
|
||||
expect(untouched.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped markup', () => {
|
||||
it('renders escaped group sections as a tree skeleton around caller-rendered rows', () => {
|
||||
const h = loadHelper();
|
||||
const projection = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
openWebviewIds: ['w1', 'w2'],
|
||||
collapsedGroupIds: ['g2'],
|
||||
});
|
||||
const html = h.renderProjection(
|
||||
projection,
|
||||
(ref: Ref) => `<div class="row" data-ref="${ref.kind}:${ref.id}"></div>`,
|
||||
escape
|
||||
);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Later',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
// Sections are layout only; the caller's container is the tree.
|
||||
expect([...doc.querySelectorAll('section')].every((el) => el.getAttribute('role') === 'presentation')).toBe(true);
|
||||
|
||||
// A named header is a level-1 treeitem that toggles and OWNS its rows' group.
|
||||
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
|
||||
expect(g1.getAttribute('role')).toBe('treeitem');
|
||||
expect(g1.getAttribute('tabindex')).toBe('-1');
|
||||
expect(g1.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(g1.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
const owned = doc.getElementById(g1.getAttribute('aria-owns')!)!;
|
||||
expect(owned.getAttribute('role')).toBe('group');
|
||||
expect(doc.getElementById(owned.getAttribute('aria-labelledby')!)?.textContent).toBe('<Core & Ops>');
|
||||
expect(owned.querySelectorAll('.row')).toHaveLength(3);
|
||||
// No interactive element nested inside a treeitem.
|
||||
expect(g1.querySelectorAll('button, [tabindex]')).toHaveLength(0);
|
||||
|
||||
// A collapsed header owns nothing, so its kept row cannot read as the child of a closed node.
|
||||
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
|
||||
expect(g2.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(g2.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(g2.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe('presentation');
|
||||
|
||||
// The ungrouped heading is a visual divider: nothing to collapse, nothing to announce.
|
||||
const ungrouped = doc.querySelector('.tab-layout-ungrouped-header')!;
|
||||
expect(ungrouped.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(ungrouped.hasAttribute('role')).toBe(false);
|
||||
expect(ungrouped.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
|
||||
ids(projection.visibleRefs)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty groups', () => {
|
||||
it('renders a group with no open rows as a tree leaf, expanded or collapsed', () => {
|
||||
const h = loadHelper();
|
||||
for (const collapsedGroupIds of [[], ['g2']]) {
|
||||
const projection = h.project(layout(), { liveSessionIds: ['s1', 's2'], openWebviewIds: [], collapsedGroupIds });
|
||||
expect(projection.sections[1]).toMatchObject({ id: 'g2', count: 0, refs: [] });
|
||||
const html = h.renderProjection(projection, () => '', escape);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
const header = doc.querySelector('[data-tab-group-header="g2"]')!;
|
||||
expect(header.getAttribute('role')).toBe('treeitem');
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(header.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
// The populated group is unaffected.
|
||||
expect(doc.querySelector('[data-tab-group-header="g1"]')!.getAttribute('aria-expanded')).toBe('true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('load coordination', () => {
|
||||
it('applies only the newest response when loads overlap', async () => {
|
||||
const h = loadHelper();
|
||||
const pending: Array<(value: unknown) => void> = [];
|
||||
const applied: number[] = [];
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => new Promise((resolve) => pending.push(resolve)),
|
||||
applyLayout: (value: any) => applied.push(value.version),
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: vi.fn(),
|
||||
});
|
||||
const first = coordinator.load();
|
||||
const second = coordinator.load();
|
||||
pending[1](layout(2));
|
||||
await second;
|
||||
pending[0](layout(1));
|
||||
await first;
|
||||
expect(applied).toEqual([2]);
|
||||
});
|
||||
|
||||
it('falls back on failure, schedules one retry, and lets a successful retry replace the fallback', async () => {
|
||||
const h = loadHelper();
|
||||
const applyLayout = vi.fn();
|
||||
const applyFallback = vi.fn();
|
||||
let retry: (() => Promise<boolean>) | undefined;
|
||||
let attempt = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
attempt++;
|
||||
if (attempt === 1) throw new Error('offline');
|
||||
return layout(7);
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback,
|
||||
scheduleRetry: (fn: () => Promise<boolean>) => {
|
||||
retry = fn;
|
||||
return 42;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
expect(applyFallback).toHaveBeenCalledTimes(1);
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
await retry!();
|
||||
expect(applyLayout).toHaveBeenCalledWith(layout(7));
|
||||
});
|
||||
|
||||
it('backs off failed retries, stops after the cap, and starts over after a success', async () => {
|
||||
const h = loadHelper();
|
||||
const delays: number[] = [];
|
||||
let pending: (() => Promise<boolean>) | null = null;
|
||||
let failing = true;
|
||||
const applyFallback = vi.fn();
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
if (failing) throw new Error('offline');
|
||||
return layout(1);
|
||||
},
|
||||
applyLayout: vi.fn(),
|
||||
applyFallback,
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 30000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (fn: () => Promise<boolean>, delay: number) => {
|
||||
delays.push(delay);
|
||||
pending = fn;
|
||||
return delays.length;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
// Bounded drain: an uncapped retry must fail here, not spin forever (the
|
||||
// loop only awaits microtasks, so vitest's own timeout could never fire).
|
||||
for (let drained = 0; pending && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = pending;
|
||||
pending = null;
|
||||
await next();
|
||||
}
|
||||
// Four retries (5 s doubling, capped at 30 s), then nothing more is scheduled.
|
||||
expect(delays).toEqual([5000, 10000, 20000, 30000]);
|
||||
expect(applyFallback).toHaveBeenCalledTimes(5);
|
||||
|
||||
// An outside load (SSE init, tab:layoutChanged) still tries, and a success
|
||||
// resets the count, so the next outage gets the full schedule again.
|
||||
failing = false;
|
||||
expect(await coordinator.load()).toBe(true);
|
||||
failing = true;
|
||||
await coordinator.load();
|
||||
expect(delays.at(-1)).toBe(5000);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
|
||||
const h = loadHelper();
|
||||
let resolve!: (value: unknown) => void;
|
||||
const applyLayout = vi.fn();
|
||||
const cancelRetry = vi.fn();
|
||||
let calls = 0;
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
return new Promise((r) => (resolve = r));
|
||||
},
|
||||
applyLayout,
|
||||
applyFallback: vi.fn(),
|
||||
scheduleRetry: () => 7,
|
||||
cancelRetry,
|
||||
});
|
||||
await coordinator.load();
|
||||
const second = coordinator.load();
|
||||
expect(cancelRetry).toHaveBeenCalledWith(7);
|
||||
coordinator.dispose();
|
||||
resolve(layout(3));
|
||||
await second;
|
||||
expect(applyLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser wiring', () => {
|
||||
it('loads the helper before app.js, ships it through the build, and wires GET + SSE', () => {
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeGreaterThan(-1);
|
||||
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeLessThan(
|
||||
INDEX_SOURCE.indexOf('<script defer src="app.js"')
|
||||
);
|
||||
expect(BUILD_SOURCE).toContain("'tab-layout-browser.js'");
|
||||
expect(APP_SOURCE).toContain("this._apiJson('/api/tab-layout')");
|
||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||
});
|
||||
|
||||
it('never writes the layout from the browser in this slice', () => {
|
||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
||||
expect(SOURCE).not.toContain('fetch(');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* @fileoverview The grouped vertical rail, driven through the shipping CodemanApp.
|
||||
*
|
||||
* Covers the app.js half of the owner-tab-layout frontend: the rail renders the
|
||||
* owner's groups only when it is vertical AND there is at least one group (every
|
||||
* other case must be byte-for-byte the flat rail), collapse is per-device and
|
||||
* keeps the active row, a structural change escapes the incremental patch path,
|
||||
* drag-reorder and cross-group Ctrl+Shift moves are withheld, a collapsed
|
||||
* header carries the alert it hides, layout reads rebuild only on a structural
|
||||
* change and back off when they fail, lineage arcs to a collapse-hidden session
|
||||
* anchor to its group header, and the grouped rail (only) is an ARIA tree with
|
||||
* one roving tab stop, a tree keyboard model and focus restored across rebuilds.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document`, `localStorage` and `window` below are that window's, not Node's.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
let localStorage: Storage;
|
||||
let localStorageDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
// Evaluate after DOMContentLoaded so app.js's own bootstrap (new CodemanApp()
|
||||
// on that event) never runs: each test builds the instance it needs.
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
localStorage = window.localStorage;
|
||||
localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n' +
|
||||
read('session-lineage.js') +
|
||||
'\n;window.__GroupedRailCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__GroupedRailCodemanApp;
|
||||
});
|
||||
|
||||
const layout = {
|
||||
version: 8,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'group-x',
|
||||
name: '<Core & Ops>',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's2' },
|
||||
{ kind: 'webview', id: 'w1' },
|
||||
{ kind: 'session', id: 's1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 's3' }],
|
||||
};
|
||||
|
||||
function makeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML = '<div id="sessionTabs"></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'One', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'Two', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'Three', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = 'tabLayout' in options ? options.tabLayout : layout;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._tabCollapseStorageFailed = false;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const rowIds = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.dataset.webviewId || tab.dataset.id
|
||||
);
|
||||
const badges = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
delete window.__codemanUser;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
document.body.innerHTML = '';
|
||||
localStorage.removeItem('codeman:tab-groups-collapsed');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (localStorageDescriptor) Object.defineProperty(window, 'localStorage', localStorageDescriptor);
|
||||
else delete window.localStorage;
|
||||
});
|
||||
|
||||
describe('flat rail is unchanged without groups', () => {
|
||||
it('renders identical markup for no layout, a failed read and a layout with zero groups', () => {
|
||||
const none = makeApp({ tabLayout: null });
|
||||
none._fullRenderSessionTabs();
|
||||
const flat = document.getElementById('sessionTabs')!.innerHTML;
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
|
||||
const empty = makeApp({ tabLayout: { version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] } });
|
||||
empty._fullRenderSessionTabs();
|
||||
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(flat);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(document.getElementById('sessionTabs')!.classList.contains('session-tabs--grouped')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the horizontal strip flat even when the owner has groups', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped vertical rail', () => {
|
||||
it('draws escaped sections in layout order while badges keep their Alt+N slot', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
expect([...document.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
|
||||
'<Core & Ops>',
|
||||
'Ungrouped',
|
||||
]);
|
||||
expect(document.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
// sessionOrder is s1,s2,s3 and the web tab follows every session: the badge
|
||||
// names the Alt+N key, never the row position.
|
||||
expect(badges()).toEqual(['2', '4', '1', '3']);
|
||||
expect(document.querySelectorAll('.session-tab.active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('withholds drag-reorder in the grouped rail only', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const draggable = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.getAttribute('draggable'));
|
||||
expect(draggable()).toEqual(['false', 'false', 'false']);
|
||||
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
expect(draggable()).toEqual(['true', 'true', 'true']);
|
||||
});
|
||||
|
||||
it('collapses from the header button, persists per device, and keeps the active row', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const button = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The inline onclick calls the GLOBAL app with the button's own dataset.
|
||||
expect(button.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
app.toggleTabGroupCollapsed(button.dataset.tabGroupHeader);
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
const header = document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(header.querySelector('.tab-layout-group-count')?.textContent).toBe('3');
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(app._hiddenTabGroupByRef.get('session:s1')).toBe('group-x');
|
||||
|
||||
app.toggleTabGroupCollapsed('group-x');
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
});
|
||||
|
||||
it('reveals a hidden session when it is selected, without expanding its group', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(rowIds()).toEqual(['s1', 's3']);
|
||||
expect(badges()).toEqual(['1', '3']);
|
||||
expect(document.querySelector('.session-tab.active')?.getAttribute('data-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('patches rows in place while the structure holds, and rebuilds when it changes', () => {
|
||||
const app = makeApp();
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
|
||||
app.sessions.get('s3').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[data-id="s3"] .tab-status')?.className).toBe('tab-status busy');
|
||||
|
||||
// Same rows on screen, different grouping: s3 moves into group-x. The id
|
||||
// sets still match, so only the structure key can catch this.
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._fullRenderSessionTabs();
|
||||
full.mockClear();
|
||||
app.tabLayout = {
|
||||
...layout,
|
||||
version: 9,
|
||||
groups: [{ ...layout.groups[0], refs: [...layout.groups[0].refs, { kind: 'session', id: 's3' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(1);
|
||||
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
});
|
||||
|
||||
it('falls back to all-expanded when collapse cannot be stored', () => {
|
||||
const app = makeApp();
|
||||
const setItem = vi.spyOn(window.Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
setItem.mockRestore();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(app._tabCollapseStorageFailed).toBe(true);
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('survives localStorage itself being unavailable', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
throw new window.DOMException('denied', 'SecurityError');
|
||||
},
|
||||
});
|
||||
expect(() => app._applyTabLayout(layout)).not.toThrow();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps collapse working after a malformed stored value, and repairs it', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '{"group-x":true}');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect(app._tabCollapseStorageFailed).toBe(false);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
expect(app.toggleTabGroupCollapsed('group-x', true)).toBe(true);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('lights a collapsed header with the most urgent alert it hides, on both render paths', () => {
|
||||
const app = makeApp();
|
||||
app.tabAlerts.set('s1', 'idle');
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const header = () => document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
const ring = () => ['tab-alert-action', 'tab-alert-idle'].filter((c) => header().classList.contains(c));
|
||||
// s1 is hidden (s2 is the kept selection): its yellow alert moves to the header.
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(ring()).toEqual(['tab-alert-idle']);
|
||||
|
||||
// A red alert arriving later is patched in place, without a rebuild.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(ring()).toEqual(['tab-alert-action']);
|
||||
|
||||
// The kept selection draws its own ring, so its alert never reaches the header.
|
||||
app.tabAlerts.clear();
|
||||
app.tabAlerts.set('s2', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(ring()).toEqual([]);
|
||||
expect(document.querySelector('[data-id="s2"]')!.classList.contains('tab-alert-action')).toBe(true);
|
||||
|
||||
// Expanded, every row shows its own alert and the header shows none.
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app.toggleTabGroupCollapsed('group-x', false);
|
||||
expect(ring()).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets Ctrl+Shift moves swap only within the active session's own section", () => {
|
||||
const app = makeApp();
|
||||
app.saveSessionOrder = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
// s2's neighbours in sessionOrder (s1,s2,s3): s3 is Ungrouped, s1 shares group-x.
|
||||
app.moveActiveTabRight();
|
||||
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
|
||||
expect(app.saveSessionOrder).not.toHaveBeenCalled();
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A row a collapse hides is still in its section.
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).not.toContain('s1');
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
|
||||
// The flat rail (and the strip) keep moving across every neighbour.
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat.saveSessionOrder = vi.fn();
|
||||
flat._fullRenderSessionTabs();
|
||||
flat.moveActiveTabRight();
|
||||
expect(flat.sessionOrder).toEqual(['s1', 's3', 's2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout adoption', () => {
|
||||
it('garbage-collects stale collapse ids and renders on adoption; a failed read renders flat', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '["gone","group-x"]');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(app.tabLayout).toBeNull();
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
|
||||
it('rebuilds the strip on adoption only when what the rail draws changed', () => {
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
const flatRender = vi.spyOn(flat, '_fullRenderSessionTabs');
|
||||
// The flat rail (every owner without groups) never rebuilds for a layout read.
|
||||
flat._applyTabLayout({ version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] });
|
||||
flat._applyTabLayout({ version: 4, groups: [], ungrouped: [] });
|
||||
flat._applyTabLayout(null);
|
||||
expect(flatRender).not.toHaveBeenCalled();
|
||||
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app._applyTabLayout(layout);
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A version bump that moves nothing (the broadcast after any session event).
|
||||
app._applyTabLayout({ ...layout, version: 9 });
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A rename does change the rail.
|
||||
app._applyTabLayout({ ...layout, version: 10, groups: [{ ...layout.groups[0], name: 'Ops' }] });
|
||||
expect(render).toHaveBeenCalledTimes(2);
|
||||
expect(document.querySelector('.tab-layout-group-name')?.textContent).toBe('Ops');
|
||||
// Grouped to flat (a failed read) rebuilds once.
|
||||
app._applyTabLayout(null);
|
||||
expect(render).toHaveBeenCalledTimes(3);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('backs off failed layout reads and stops, without rebuilding a flat rail', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
const realSetTimeout = window.setTimeout;
|
||||
const delays: number[] = [];
|
||||
let queued: (() => Promise<boolean>) | null = null;
|
||||
window.setTimeout = (fn: () => Promise<boolean>, ms: number) => {
|
||||
delays.push(ms);
|
||||
queued = fn;
|
||||
return delays.length;
|
||||
};
|
||||
try {
|
||||
await app._loadTabLayout();
|
||||
// Bounded: an uncapped retry must fail the assertion below, not hang.
|
||||
for (let drained = 0; queued && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = queued;
|
||||
queued = null;
|
||||
await next();
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout = realSetTimeout;
|
||||
}
|
||||
expect(delays).toEqual([5000, 10000, 20000, 40000]);
|
||||
expect(app._apiJson).toHaveBeenCalledTimes(5);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
|
||||
it('ignores an older layout than the one it already shows', () => {
|
||||
const app = makeApp();
|
||||
app.tabLayout = { ...layout, version: 9 };
|
||||
app._applyTabLayout({ ...layout, version: 8, groups: [] });
|
||||
expect(app.tabLayout.version).toBe(9);
|
||||
});
|
||||
|
||||
it('re-reads on tab:layoutChanged only for its own owner and a newer version', () => {
|
||||
const app = makeApp();
|
||||
app._loadTabLayout = vi.fn();
|
||||
app.tabLayout = { version: 8, groups: [], ungrouped: [] };
|
||||
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 8 });
|
||||
expect(app._loadTabLayout).not.toHaveBeenCalled();
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 9 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.__codemanUser = { username: 'alice', multiUser: true };
|
||||
app._onTabLayoutChanged({ owner: 'bob', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
|
||||
app._onTabLayoutChanged({ owner: 'alice', version: 20 });
|
||||
expect(app._loadTabLayout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('loads through GET /api/tab-layout and adopts the envelope payload', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => ({ layout }));
|
||||
await app._loadTabLayout();
|
||||
expect(app._apiJson).toHaveBeenCalledWith('/api/tab-layout');
|
||||
expect(app.tabLayout.version).toBe(8);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineage in the grouped rail', () => {
|
||||
function rect(top: number) {
|
||||
return () => ({ left: 32, top, right: 292, bottom: top + 36, width: 260, height: 36, x: 32, y: top }) as DOMRect;
|
||||
}
|
||||
|
||||
it('anchors a collapse-hidden endpoint to its group header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s3').status = 'working';
|
||||
app.sessions.get('s3').parentSessionId = 's1';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).toEqual(['s3']);
|
||||
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) as DOMRect;
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.getBoundingClientRect = rect(20);
|
||||
document.querySelector<HTMLElement>('[data-id="s3"]')!.getBoundingClientRect = rect(180);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
const line = svg.querySelector('.lineage-line');
|
||||
expect(line?.classList.contains('lineage-line--proxied')).toBe(true);
|
||||
expect(line?.getAttribute('data-parent-tab')).toBe('s1');
|
||||
expect(svg.querySelector('.lineage-line-dot')?.classList.contains('lineage-line-dot--proxied')).toBe(true);
|
||||
expect(app._lineageEdgeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('draws nothing when both endpoints hide behind the same header', () => {
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 's3';
|
||||
app.sessions.get('s1').parentSessionId = 's2';
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._lineageLinesEnabled = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
document.getElementById('sessionTabs')!.getBoundingClientRect = rect(0);
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
|
||||
app._appendLineageConnectionLines(svg, new Map());
|
||||
|
||||
expect(svg.querySelectorAll('.lineage-line')).toHaveLength(0);
|
||||
expect(app._lineageEdgeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped rail tree semantics', () => {
|
||||
const tabs = () => document.getElementById('sessionTabs')!;
|
||||
const press = (key: string, init: Record<string, unknown> = {}) =>
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
);
|
||||
const focused = () => {
|
||||
const el = document.activeElement as HTMLElement;
|
||||
return el.dataset.tabGroupHeader ? `group:${el.dataset.tabGroupHeader}` : el.dataset.webviewId || el.dataset.id;
|
||||
};
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-id="${id}"], [data-webview-id="${id}"]`)!;
|
||||
|
||||
function makeTreeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = makeApp(options);
|
||||
// The container as index.html ships it.
|
||||
tabs().setAttribute('role', 'tablist');
|
||||
tabs().setAttribute('aria-label', 'Session tabs');
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app.openTabRailActionMenu = vi.fn();
|
||||
app.showWebviewModal = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('is a tree only while grouped, and the flat list returns byte-identical as a tablist', () => {
|
||||
const app = makeTreeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const flat = tabs().innerHTML;
|
||||
expect(tabs().querySelectorAll('[role="tree"], [role="treeitem"], [role="group"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="tab"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tree');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Sessions');
|
||||
expect(tabs().querySelectorAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="treeitem"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Session tabs');
|
||||
expect(tabs().innerHTML).toBe(flat);
|
||||
|
||||
// The horizontal strip never becomes a tree, groups or not.
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().querySelectorAll('[role="treeitem"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('nests group rows under their owning header with levels and positions', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
const group = document.getElementById(header.getAttribute('aria-owns')!)!;
|
||||
expect(group.getAttribute('role')).toBe('group');
|
||||
expect(
|
||||
[...group.querySelectorAll<HTMLElement>('[role="treeitem"]')].map((el) => el.dataset.webviewId || el.dataset.id)
|
||||
).toEqual(['s2', 'w1', 's1']);
|
||||
const aria = (el: HTMLElement) => ['aria-level', 'aria-posinset', 'aria-setsize'].map((a) => el.getAttribute(a));
|
||||
// Level 1: the group header and the ungrouped row.
|
||||
expect(aria(header)).toEqual(['1', '1', '2']);
|
||||
expect(aria(row('s3'))).toEqual(['1', '2', '2']);
|
||||
expect(aria(row('s2'))).toEqual(['2', '1', '3']);
|
||||
expect(aria(row('w1'))).toEqual(['2', '2', '3']);
|
||||
expect(aria(row('s1'))).toEqual(['2', '3', '3']);
|
||||
// aria-selected follows the active row, exactly once.
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has exactly one tab stop, on the selected row, and no tabbable control inside rows', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const stops = [...tabs().querySelectorAll<HTMLElement>('[tabindex="0"]')];
|
||||
expect(stops).toEqual([row('s2')]);
|
||||
const controls = [...tabs().querySelectorAll<HTMLElement>('.session-tab button, .session-tab [tabindex]')];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(controls.every((el) => el.tabIndex === -1)).toBe(true);
|
||||
|
||||
// Collapse keeps a single stop (the selection stays visible as a level-1 item).
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
expect(row('s2').getAttribute('aria-level')).toBe('1');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.hasAttribute('aria-owns')).toBe(false);
|
||||
});
|
||||
|
||||
it('walks Up/Down/Home/End, collapses and enters groups with Left/Right, and activates with Enter/Space', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('s3');
|
||||
press('Home');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('End');
|
||||
expect(focused()).toBe('s3');
|
||||
// An ungrouped row has no parent to climb to.
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(focused()).toBe('s3');
|
||||
|
||||
row('w1').focus();
|
||||
press('Enter');
|
||||
expect(app.openWebview).toHaveBeenCalledWith('w1');
|
||||
row('s1').focus();
|
||||
press(' ');
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s1', { forceReload: true });
|
||||
expect(app.selectSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
press('ArrowLeft');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was re-rendered; focus and the tab stop moved to the new node.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.getAttribute('tabindex')).toBe('0');
|
||||
press('ArrowRight');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(false);
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowRight');
|
||||
expect(focused()).toBe('s2');
|
||||
press('Home');
|
||||
press('Enter');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was rebuilt by the toggle; focus followed it, never to <body>.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves keys pressed on a control inside a row to that control', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
// The overflow button gets focus from a click, or back from its action menu.
|
||||
const more = row('s2').querySelector<HTMLElement>('.tab-more')!;
|
||||
more.focus();
|
||||
expect(press('Enter')).toBe(true);
|
||||
expect(press(' ')).toBe(true);
|
||||
expect(press('ArrowDown')).toBe(true);
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(document.activeElement).toBe(more);
|
||||
});
|
||||
|
||||
it('treats a group with no open rows as a leaf that Left/Right cannot open or close', () => {
|
||||
const app = makeTreeApp({
|
||||
tabLayout: { ...layout, groups: [...layout.groups, { id: 'empty', name: 'Empty', refs: [] }] },
|
||||
});
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="empty"]')!;
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
header.focus();
|
||||
press('ArrowRight');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(focused()).toBe('group:empty');
|
||||
});
|
||||
|
||||
it('keeps tree positions in painted order when the sorted rail re-sorts rows in place', () => {
|
||||
const app = makeTreeApp();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
let order = new Map([
|
||||
['s2', 0],
|
||||
['s1', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._tabRailSortOrder = () => order;
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
app._applyTabTreePositions(tabs());
|
||||
const pos = (id: string) => row(id).getAttribute('aria-posinset');
|
||||
expect([pos('s2'), pos('s1'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
|
||||
// s1 goes working: the incremental pass moves its card up with `order` only.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
order = new Map([
|
||||
['s1', 0],
|
||||
['s2', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(row('s1').style.order).toBe('0');
|
||||
expect([pos('s1'), pos('s2'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
expect(row('s1').getAttribute('aria-setsize')).toBe('3');
|
||||
});
|
||||
|
||||
it('exempts tree items from the touch keyboard dismissal, like every other control', () => {
|
||||
// terminal-ui.js publishes the selector on window.CodemanTerminalInput; its
|
||||
// top level only needs a window and a CodemanApp prototype to assign to.
|
||||
const context = vm.createContext({ window: {}, CodemanApp: function CodemanApp() {} });
|
||||
vm.runInContext(readFileSync(join(PUBLIC, 'terminal-ui.js'), 'utf8'), context);
|
||||
const selector = (context.window as any).CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR as string;
|
||||
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The roving stop sits on s2: the header and the other rows are tabindex=-1.
|
||||
expect(header.tabIndex).toBe(-1);
|
||||
expect(row('s1').tabIndex).toBe(-1);
|
||||
for (const target of [header, header.querySelector('.tab-layout-group-name')!, row('s1'), row('w1')]) {
|
||||
expect(target.closest(selector), (target as HTMLElement).className).not.toBeNull();
|
||||
}
|
||||
// Inert rail chrome still dismisses.
|
||||
expect(document.querySelector('.tab-layout-ungrouped-header')!.closest(selector)).toBeNull();
|
||||
});
|
||||
|
||||
it('opens row actions from the keyboard, since its controls left the tab order', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('F10', { shiftKey: true });
|
||||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
||||
row('w1').focus();
|
||||
press('ContextMenu');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
||||
press('F10');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores focus by identity across a background rebuild and follows pointer focus', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').focus();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(focused()).toBe('w1');
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('w1')]);
|
||||
|
||||
// Focus arriving by pointer (or any other route) takes the tab stop with it.
|
||||
row('s3').focus();
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('s3')]);
|
||||
|
||||
// A rebuild never pulls focus into the rail when it was elsewhere.
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('keeps aria-selected in step when the selection changes without a rebuild', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks a sorted rail in painted order: per group in the tree, across the list when flat', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '0';
|
||||
row('s2').style.order = '1';
|
||||
row('w1').style.order = '9999';
|
||||
row('s3').style.order = '2';
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.focus();
|
||||
const walk = () =>
|
||||
Array.from({ length: 4 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
});
|
||||
expect(walk()).toEqual(['s1', 's2', 'w1', 's3']);
|
||||
|
||||
const flat = makeTreeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '2';
|
||||
row('s2').style.order = '0';
|
||||
row('s3').style.order = '1';
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
row('s2').focus();
|
||||
expect(
|
||||
Array.from({ length: 3 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
})
|
||||
).toEqual(['s3', 's1', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat list keyboard activation', () => {
|
||||
it('opens a web tab with Enter/Space instead of selecting an undefined session', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
const web = document.querySelector<HTMLElement>('[data-webview-id="w1"]')!;
|
||||
web.focus();
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
|
||||
expect(app.openWebview).toHaveBeenCalledTimes(2);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
|
||||
const s2 = document.querySelector<HTMLElement>('[data-id="s2"]')!;
|
||||
s2.focus();
|
||||
s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
// @vitest-environment node
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildWebhookRequest,
|
||||
DEFAULT_WEBHOOK_CONFIG,
|
||||
maskWebhookUrl,
|
||||
readWebhookConfig,
|
||||
sendWebhook,
|
||||
shouldSendWebhook,
|
||||
webhookConfigPath,
|
||||
WebhookNotifier,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookFetch,
|
||||
type WebhookMessage,
|
||||
} from '../src/webhook-notify.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
import { webviewFetch, WebviewEgressBlockedError } from '../src/web/webview-egress.js';
|
||||
|
||||
const MSG: WebhookMessage = {
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
host: 'codeman:box',
|
||||
};
|
||||
const CFG: WebhookConfig = {
|
||||
enabled: true,
|
||||
kind: 'generic',
|
||||
url: 'https://hooks.example.com/T0/B0/secret',
|
||||
scope: 'attention',
|
||||
};
|
||||
// A 204 (like a 3xx with no body) must be built without one.
|
||||
const ok = (status = 200) => new Response(status === 204 ? null : '', { status });
|
||||
|
||||
describe('webhookUrlProblem', () => {
|
||||
it.each([
|
||||
'https://ntfy.sh/mytopic',
|
||||
'https://hooks.slack.com/services/T/B/x',
|
||||
'http://localhost:8080/t',
|
||||
'http://192.168.1.5/hook',
|
||||
])('accepts %s (loopback and LAN are the point of a local ntfy)', (url) => expect(webhookUrlProblem(url)).toBeNull());
|
||||
|
||||
it.each([
|
||||
['ftp://example.com/x', /http and https/],
|
||||
['file:///etc/passwd', /http and https/],
|
||||
['https://user:pw@example.com/x', /credentials/],
|
||||
['not a url', /valid URL/],
|
||||
['http://169.254.169.254/latest/meta-data', /link-local|metadata/],
|
||||
['http://metadata.google.internal/computeMetadata/v1/', /metadata/],
|
||||
['http://[fd00:ec2::254]/', /metadata|link-local/],
|
||||
[`https://example.com/${'a'.repeat(2100)}`, /too long/],
|
||||
])('rejects %s', (url, why) => expect(webhookUrlProblem(url)).toMatch(why));
|
||||
});
|
||||
|
||||
describe('maskWebhookUrl', () => {
|
||||
it('keeps scheme and host and drops the secret path and query', () => {
|
||||
const masked = maskWebhookUrl('https://hooks.slack.com/services/T0/B0/XXXXSECRET?token=abc');
|
||||
expect(masked).toBe('https://hooks.slack.com/•••');
|
||||
expect(masked).not.toMatch(/SECRET|token|T0/);
|
||||
});
|
||||
it('is empty for nothing or garbage', () => {
|
||||
expect(maskWebhookUrl('')).toBe('');
|
||||
expect(maskWebhookUrl('nope')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSendWebhook', () => {
|
||||
it('needs enabled and a url', () => {
|
||||
expect(shouldSendWebhook({ ...CFG, enabled: false }, 'critical')).toBe(false);
|
||||
expect(shouldSendWebhook({ ...CFG, url: '' }, 'critical')).toBe(false);
|
||||
expect(shouldSendWebhook(CFG, 'critical')).toBe(true);
|
||||
});
|
||||
it('scope attention skips "response complete" (info); scope all sends it', () => {
|
||||
expect(shouldSendWebhook(CFG, 'warning')).toBe(true);
|
||||
expect(shouldSendWebhook(CFG, 'info')).toBe(false);
|
||||
expect(shouldSendWebhook({ ...CFG, scope: 'all' }, 'info')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebhookRequest', () => {
|
||||
it('ntfy: plain-text body, priority and tag by urgency, host-prefixed title', () => {
|
||||
const r = buildWebhookRequest('ntfy', MSG);
|
||||
expect(r.body).toBe('[w1-app] Tool: Bash');
|
||||
expect(r.headers.Title).toBe('codeman:box: Permission Required');
|
||||
expect(r.headers.Priority).toBe('5');
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'info' }).headers.Priority).toBe('3');
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, urgency: 'warning' }).headers.Priority).toBe('4');
|
||||
});
|
||||
|
||||
it('ntfy: a non-ASCII or multi-line title can never break the header (RFC 2047 encoded)', () => {
|
||||
const r = buildWebhookRequest('ntfy', { ...MSG, title: 'Prüfung\r\nX-Injected: 1', host: undefined });
|
||||
expect(r.headers.Title).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
expect(Buffer.from(r.headers.Title.slice(10, -2), 'base64').toString('utf8')).toBe('Prüfung X-Injected: 1');
|
||||
expect(Object.keys(r.headers)).not.toContain('X-Injected');
|
||||
});
|
||||
|
||||
it('slack: control characters are escaped so agent text cannot ping a channel', () => {
|
||||
const r = JSON.parse(
|
||||
buildWebhookRequest('slack', { ...MSG, body: '<!channel> <@U123> <https://evil|click> & more' }).body
|
||||
);
|
||||
expect(r.text).not.toMatch(/<[!@h]/);
|
||||
expect(r.text).toContain('<!channel>');
|
||||
expect(r.text).toContain('& more');
|
||||
});
|
||||
|
||||
it('discord: mentions are disabled and the content is length-capped', () => {
|
||||
const r = JSON.parse(buildWebhookRequest('discord', { ...MSG, body: '@everyone ' + 'x'.repeat(5000) }).body);
|
||||
expect(r.allowed_mentions).toEqual({ parse: [] });
|
||||
expect(r.content.length).toBeLessThanOrEqual(1900);
|
||||
});
|
||||
|
||||
it('generic: structured JSON with the session and a timestamp', () => {
|
||||
const r = JSON.parse(buildWebhookRequest('generic', MSG, new Date('2026-10-02T12:00:00Z')).body);
|
||||
expect(r).toEqual({
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
host: 'codeman:box',
|
||||
at: '2026-10-02T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates a long body for every kind', () => {
|
||||
const long = 'y'.repeat(2000);
|
||||
expect(buildWebhookRequest('ntfy', { ...MSG, body: long }).body.length).toBeLessThanOrEqual(500);
|
||||
expect(JSON.parse(buildWebhookRequest('generic', { ...MSG, body: long }).body).body.length).toBeLessThanOrEqual(
|
||||
500
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('returns the defaults for a missing or corrupt file', async () => {
|
||||
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
|
||||
writeFileSync(webhookConfigPath(dir), '{ nope');
|
||||
expect(await readWebhookConfig(dir)).toEqual(DEFAULT_WEBHOOK_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips and writes the file readable by its owner only', async () => {
|
||||
await writeWebhookConfig(dir, CFG);
|
||||
expect(await readWebhookConfig(dir)).toEqual(CFG);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('tightens an existing world-readable file instead of keeping its mode', async () => {
|
||||
writeFileSync(webhookConfigPath(dir), '{}', { mode: 0o644 });
|
||||
await writeWebhookConfig(dir, CFG);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('coerces unknown kinds and scopes back to the defaults', async () => {
|
||||
writeFileSync(
|
||||
webhookConfigPath(dir),
|
||||
JSON.stringify({ enabled: true, kind: 'telegram', scope: 'nope', url: 'https://x.test/h' })
|
||||
);
|
||||
const cfg = await readWebhookConfig(dir);
|
||||
expect(cfg).toEqual({ enabled: true, kind: 'ntfy', scope: 'attention', url: 'https://x.test/h' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWebhook', () => {
|
||||
it('posts the built request with redirects off and a timeout signal', async () => {
|
||||
const fetchImpl = vi.fn<WebhookFetch>(async () => ok(204));
|
||||
const r = await sendWebhook(CFG, MSG, fetchImpl);
|
||||
expect(r).toMatchObject({ ok: true, status: 204 });
|
||||
const [target, init] = fetchImpl.mock.calls[0];
|
||||
expect(target.href).toBe(CFG.url);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.redirect).toBe('manual');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('reports an HTTP failure by status, a redirect as such, and never echoes the URL', async () => {
|
||||
const bad = await sendWebhook(CFG, MSG, async () => ok(404));
|
||||
expect(bad).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const redirect = await sendWebhook(CFG, MSG, async () => ok(302));
|
||||
expect(redirect.ok).toBe(false);
|
||||
expect(redirect.error).toMatch(/redirects/);
|
||||
for (const r of [bad, redirect]) expect(JSON.stringify(r)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('turns network errors into short messages that do not contain the URL', async () => {
|
||||
const cases: [unknown, RegExp][] = [
|
||||
[Object.assign(new Error('x'), { name: 'TimeoutError' }), /Timed out/],
|
||||
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }), /Host not found/],
|
||||
[Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }), /refused/],
|
||||
[new TypeError('fetch failed https://hooks.example.com/T0/B0/secret'), /Network error/],
|
||||
];
|
||||
for (const [err, re] of cases) {
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw err;
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(re);
|
||||
expect(JSON.stringify(r)).not.toContain('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a blocked URL without fetching', async () => {
|
||||
const fetchImpl = vi.fn<WebhookFetch>(async () => ok());
|
||||
const r = await sendWebhook({ ...CFG, url: 'http://169.254.169.254/latest' }, MSG, fetchImpl);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recognises an egress refusal by its code anywhere in the cause chain, not by message text', async () => {
|
||||
// A DNS name resolving into a blocked range is refused by the connect-time lookup, and undici
|
||||
// hands that back wrapped (`fetch failed` -> connect error -> the refusal), so it can sit deep.
|
||||
const blocked = new WebviewEgressBlockedError('cloud metadata');
|
||||
const deep = new TypeError('fetch failed', { cause: new Error('connect failed', { cause: blocked }) });
|
||||
for (const err of [blocked, deep]) {
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw err;
|
||||
});
|
||||
expect(r.error).toBe('Refused: target is a link-local or cloud-metadata address');
|
||||
}
|
||||
// Words alone (an upstream error that happens to mention them) are not a refusal.
|
||||
const r = await sendWebhook(CFG, MSG, async () => {
|
||||
throw new TypeError('fetch failed', { cause: new Error('link-local EGRESS hiccup') });
|
||||
});
|
||||
expect(r.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery through the real egress-guarded fetch', () => {
|
||||
let server: Server;
|
||||
let received: { headers: IncomingMessage['headers']; body: string } | null;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
received = null;
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
received = { headers: req.headers, body };
|
||||
res.statusCode = req.url === '/redirect' ? 302 : 200;
|
||||
if (req.url === '/redirect') res.setHeader('Location', 'http://169.254.169.254/');
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
it('delivers to a local server (loopback is allowed) with the ntfy headers', async () => {
|
||||
const r = await sendWebhook({ kind: 'ntfy', url: `http://127.0.0.1:${port}/topic` }, MSG, webviewFetch);
|
||||
expect(r).toMatchObject({ ok: true, status: 200 });
|
||||
expect(received?.body).toBe('[w1-app] Tool: Bash');
|
||||
expect(received?.headers.priority).toBe('5');
|
||||
});
|
||||
|
||||
it('does not follow a redirect to a metadata address', async () => {
|
||||
const r = await sendWebhook({ kind: 'generic', url: `http://127.0.0.1:${port}/redirect` }, MSG, webviewFetch);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/redirects/);
|
||||
});
|
||||
|
||||
it('refuses a metadata address at the fetch layer too', async () => {
|
||||
await expect(webviewFetch(new URL('http://169.254.169.254/'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebhookNotifier', () => {
|
||||
const make = (cfg: Partial<WebhookConfig> = {}, fetchImpl: WebhookFetch = async () => ok(), now?: () => number) => {
|
||||
const sent = vi.fn(fetchImpl);
|
||||
const notifier = new WebhookNotifier(async () => ({ ...CFG, ...cfg }), sent, now);
|
||||
return { notifier, sent };
|
||||
};
|
||||
|
||||
it('sends an event that needs attention and records the result', async () => {
|
||||
const { notifier, sent } = make();
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.lastResult).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('sends nothing when disabled, without a url, or for info under scope attention', async () => {
|
||||
for (const cfg of [{ enabled: false }, { url: '' }]) {
|
||||
const { notifier, sent } = make(cfg);
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
}
|
||||
const { notifier, sent } = make();
|
||||
await notifier.notify({ ...MSG, urgency: 'info' });
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the same event for the same session once per window, then again', async () => {
|
||||
let t = 1_000_000;
|
||||
const { notifier, sent } = make(
|
||||
{},
|
||||
async () => ok(),
|
||||
() => t
|
||||
);
|
||||
await notifier.notify(MSG);
|
||||
t += 1000;
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
await notifier.notify({ ...MSG, sessionId: 's2' });
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
t += 5000;
|
||||
await notifier.notify(MSG);
|
||||
expect(sent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('caps what is in flight so a hung endpoint cannot pile up requests', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const { notifier, sent } = make({}, async () => (await gate, ok()));
|
||||
const pending = Array.from({ length: 12 }, (_, i) => notifier.notify({ ...MSG, sessionId: `s${i}` }));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(sent).toHaveBeenCalledTimes(5);
|
||||
release();
|
||||
await Promise.all(pending);
|
||||
});
|
||||
|
||||
it('a test send ignores enabled and scope, bypasses dedupe, and records the result', async () => {
|
||||
const { notifier, sent } = make({ enabled: false });
|
||||
const r1 = await notifier.sendTest(CFG, 'codeman:box');
|
||||
const r2 = await notifier.sendTest(CFG);
|
||||
expect(r1.ok && r2.ok).toBe(true);
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
expect(notifier.lastResult).toBe(r2);
|
||||
expect(JSON.parse(sent.mock.calls[0][1].body as string).host).toBe('codeman:box');
|
||||
});
|
||||
|
||||
it('never throws on a failing endpoint', async () => {
|
||||
const { notifier } = make({}, async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
await expect(notifier.notify(MSG)).resolves.toBeUndefined();
|
||||
expect(notifier.lastResult).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @fileoverview The glue between `WebServer.sendPushNotifications()` and the webhook channel.
|
||||
*
|
||||
* The notifier and the routes are tested on their own (webhook-notify.test.ts,
|
||||
* routes/webhook-routes.test.ts); this file pins the one line that makes the feature exist: the
|
||||
* webhook fires even when there is NO Web Push subscription, which is exactly the headless server
|
||||
* it was built for. Moving the call below the "no subscriptions" early return passes every other
|
||||
* test and silently kills the feature, so it is asserted here end to end: a real WebServer (never
|
||||
* started), an empty push store, `webhook.json` in the instance data dir, and a local HTTP receiver
|
||||
* reached through the real egress-guarded fetch.
|
||||
*
|
||||
* Port: N/A (no server start; the receiver binds an ephemeral 127.0.0.1 port).
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeWebhookConfig, type WebhookMessage, type WebhookNotifier } from '../src/webhook-notify.js';
|
||||
import type { WebhookConfig } from '../src/types/push.js';
|
||||
|
||||
interface Received {
|
||||
url?: string;
|
||||
title?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type PushSender = { sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void> };
|
||||
|
||||
let receiver: Server;
|
||||
let receiverUrl: string;
|
||||
const got: Received[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
receiver = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
// Recorded before the response goes out, so a resolved notify() means it is already here.
|
||||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => receiver.listen(0, '127.0.0.1', resolve));
|
||||
receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/codeman-topic`;
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => receiver.close(() => resolve())));
|
||||
|
||||
beforeEach(() => {
|
||||
got.length = 0;
|
||||
sendNotification.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A headless server: no push subscription at all. The real notifier stays in place; its `notify`
|
||||
* is only wrapped to collect the promises `sendPushNotifications` fires and forgets, so a test can
|
||||
* await delivery instead of sleeping.
|
||||
*/
|
||||
async function headlessServer(cfg: Partial<WebhookConfig>) {
|
||||
await writeWebhookConfig(getDataDir(), { enabled: true, kind: 'ntfy', scope: 'attention', url: receiverUrl, ...cfg });
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', 'box');
|
||||
(server as unknown as { pushStore: { getAll: () => never[] } }).pushStore = { getAll: () => [] };
|
||||
const notifier = (server as unknown as { webhookNotifier: WebhookNotifier }).webhookNotifier;
|
||||
const pending: Promise<void>[] = [];
|
||||
const realNotify = notifier.notify.bind(notifier);
|
||||
notifier.notify = (msg: WebhookMessage) => {
|
||||
const p = realNotify(msg);
|
||||
pending.push(p);
|
||||
return p;
|
||||
};
|
||||
const send = async (event: string, data: Record<string, unknown>) => {
|
||||
await (server as unknown as PushSender).sendPushNotifications(event, data);
|
||||
await Promise.all(pending.splice(0));
|
||||
};
|
||||
return { send };
|
||||
}
|
||||
|
||||
describe('sendPushNotifications -> webhook (the headless case)', () => {
|
||||
it('delivers a permission prompt to the webhook with zero push subscriptions', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]).toEqual({
|
||||
url: '/codeman-topic',
|
||||
title: 'codeman:box: Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
});
|
||||
// Web Push had nobody to send to, and the webhook did not need it.
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedupes an immediate repeat of the same event for the same session', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
await send('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2-app' });
|
||||
expect(got).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips "response complete" under scope attention and sends it under scope all', async () => {
|
||||
const attention = await headlessServer({ scope: 'attention' });
|
||||
await attention.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
|
||||
const all = await headlessServer({ scope: 'all' });
|
||||
await all.send('hook:stop', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].title).toBe('codeman:box: Response Complete');
|
||||
});
|
||||
|
||||
it('sends nothing while disabled, and nothing for an event Web Push does not carry', async () => {
|
||||
const disabled = await headlessServer({ enabled: false });
|
||||
await disabled.send('hook:permission_prompt', { sessionId: 's1', sessionName: 'w1-app', tool_name: 'Bash' });
|
||||
const enabled = await headlessServer({});
|
||||
await enabled.send('session:created', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
expect(got).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/** @fileoverview Settings → Notifications → Webhook, end to end: real server, real Chromium, a local receiver. */
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
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 = 3195;
|
||||
const SECRET = 'SUPERSECRET-topic-123';
|
||||
|
||||
describe('Webhook settings in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let receiver: Server;
|
||||
let receiverPort: number;
|
||||
let respondWith = 200;
|
||||
const got: { url?: string; title?: string; body: string }[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
receiver = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
got.push({ url: req.url, title: req.headers.title as string | undefined, body });
|
||||
res.statusCode = respondWith;
|
||||
res.end('x');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => receiver.listen(0, '127.0.0.1', r));
|
||||
receiverPort = (receiver.address() as AddressInfo).port;
|
||||
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.waitForSelector('#webhookGroup', { state: 'attached' });
|
||||
await page.waitForFunction(() => document.getElementById('webhookGroup')!.style.display !== 'none');
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
await new Promise<void>((r) => receiver.close(() => r()));
|
||||
}, 60000);
|
||||
|
||||
const result = () => page.textContent('#webhookResult');
|
||||
|
||||
// The checkbox sits behind a styled slider, so click the switch like a user does.
|
||||
const setSwitch = async (on: boolean) => {
|
||||
if ((await page.isChecked('#webhookEnabled')) !== on) await page.click('label.switch:has(#webhookEnabled)');
|
||||
expect(await page.isChecked('#webhookEnabled')).toBe(on);
|
||||
};
|
||||
|
||||
it('shows the group, starts empty, and refuses to enable without a URL', async () => {
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||||
await setSwitch(true);
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/Add a webhook URL/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
await setSwitch(false);
|
||||
});
|
||||
|
||||
it('refuses a cloud-metadata URL with the server’s reason', async () => {
|
||||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a URL, shows only scheme and host, and empties the secret field', async () => {
|
||||
await page.selectOption('#webhookKind', 'ntfy');
|
||||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/${SECRET}`);
|
||||
await setSwitch(true);
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe(`Saved: http://127.0.0.1:${receiverPort}/•••`);
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||||
expect(await page.content()).not.toContain(SECRET);
|
||||
// ...and GET /api/webhook never returns it either.
|
||||
const body = await page.evaluate(async () => (await fetch('/api/webhook')).text());
|
||||
expect(body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('sends a test message that reaches the receiver with the ntfy headers', async () => {
|
||||
got.length = 0;
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].url).toBe(`/${SECRET}`);
|
||||
expect(got[0].title).toMatch(/Codeman test notification/);
|
||||
expect(got[0].body).toMatch(/webhook notifications are working/);
|
||||
});
|
||||
|
||||
it('reports a failing endpoint without exposing the URL', async () => {
|
||||
respondWith = 500;
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() =>
|
||||
/Delivery failed: HTTP 500/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
expect(await result()).not.toContain(SECRET);
|
||||
respondWith = 200;
|
||||
});
|
||||
|
||||
it('keeps the saved URL when only the service changes', async () => {
|
||||
got.length = 0;
|
||||
await page.selectOption('#webhookKind', 'generic');
|
||||
await page.click('#webhookSaveBtn');
|
||||
await page.waitForFunction(() => /Saved\./.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(JSON.parse(got[0].body)).toMatchObject({ event: 'webhook:test', urgency: 'info' });
|
||||
});
|
||||
|
||||
const savedWebhook = () => page.evaluate(async () => (await (await fetch('/api/webhook')).json()).data);
|
||||
const modalOpen = () =>
|
||||
page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
|
||||
it('the main Settings Save also saves a pending webhook edit', async () => {
|
||||
await page.selectOption('#webhookScope', 'all');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() => !document.getElementById('appSettingsModal')!.classList.contains('active'));
|
||||
expect(await savedWebhook()).toMatchObject({ scope: 'all', kind: 'generic', enabled: true, hasUrl: true });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
await page.waitForFunction(() => (window as any).app._webhookLoaded?.scope === 'all');
|
||||
});
|
||||
|
||||
it('a refused webhook keeps the modal open with the pasted URL, instead of a silent success', async () => {
|
||||
await page.fill('#webhookUrl', 'http://169.254.169.254/latest');
|
||||
await page.click('#appSettingsModal .set-foot .btn-primary');
|
||||
await page.waitForFunction(() =>
|
||||
/metadata|link-local/.test(document.getElementById('webhookResult')?.textContent ?? '')
|
||||
);
|
||||
expect(await modalOpen()).toBe(true);
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('http://169.254.169.254/latest');
|
||||
expect((await savedWebhook()).urlMasked).toBe(`http://127.0.0.1:${receiverPort}/•••`);
|
||||
await page.fill('#webhookUrl', '');
|
||||
});
|
||||
|
||||
it('Send test saves a newly pasted URL first, so it never tests the old one', async () => {
|
||||
got.length = 0;
|
||||
await page.fill('#webhookUrl', `http://127.0.0.1:${receiverPort}/other-topic`);
|
||||
await page.click('#webhookTestBtn');
|
||||
await page.waitForFunction(() => /Test sent/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].url).toBe('/other-topic');
|
||||
expect(await page.inputValue('#webhookUrl')).toBe('');
|
||||
});
|
||||
|
||||
it('Remove URL deletes the saved secret and turns the channel off', async () => {
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(true);
|
||||
page.once('dialog', (d) => void d.accept());
|
||||
await page.click('#webhookClearBtn');
|
||||
await page.waitForFunction(() => /removed/.test(document.getElementById('webhookResult')?.textContent ?? ''));
|
||||
expect(await page.textContent('#webhookUrlHint')).toBe('Nothing saved yet.');
|
||||
expect(await page.isChecked('#webhookEnabled')).toBe(false);
|
||||
expect(await page.isVisible('#webhookClearBtn')).toBe(false);
|
||||
expect(await savedWebhook()).toMatchObject({ hasUrl: false, enabled: false, urlMasked: '' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user