diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 13c50a7a..ca6a07c5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9624582d..e5766306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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=`), 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 diff --git a/CLAUDE.md b/CLAUDE.md index 6feba5bd..f1c29cd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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,7 +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 `/.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 /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 ` 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` -- **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 `/.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 `/.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 `, 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`. @@ -244,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 `.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-` 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:"` (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) @@ -254,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) @@ -319,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) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The 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) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The 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 ``; 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) @@ -367,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. @@ -379,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). @@ -430,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//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//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. diff --git a/config/test-suites.ts b/config/test-suites.ts index 76d7f94e..c859f3a8 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -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,9 +32,13 @@ 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/spreadsheet-preview.browser.test.ts', + 'test/mobile-ime-preview.browser.test.ts', ]; /** diff --git a/docs/api-reference.md b/docs/api-reference.md index c0f7235d..7b534ddc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 `.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 diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cbac82ab..327c9783 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config tui.animations=` from `animations`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. @@ -191,7 +191,7 @@ Further detail (current geometry and colors, superseding the dip numbers above w **Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w-` / `s-`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. -**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session. +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. @@ -296,7 +296,16 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. -⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. +⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe: + +- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing. +- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`). +- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. +- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible. +- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. +- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. +- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. +- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. @@ -657,11 +666,13 @@ Matching semantics: a query containing `/` matches the relative path, otherwise **Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case ` + + + + @@ -2172,6 +2188,19 @@ +
+
+ Advisor + A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor. +
+
+ +
@@ -2474,6 +2503,30 @@ + +
+

MCP servers

synced
+
+
+
+ Enable MCP server sync + 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. +
+ +
+ + +
+
@@ -2601,6 +2654,57 @@ + + @@ -3738,10 +3842,14 @@ + + + + diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js new file mode 100644 index 00000000..42826c8a --- /dev/null +++ b/src/web/public/mobile-ime-preview.js @@ -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); diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index 33938801..7c31c2d8 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, { const ralphGlobalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings); const effort = this.getEffortSetting(ralphGlobalSettings); + const advisorModel = this.getAdvisorSetting(ralphGlobalSettings); const res = await fetch('/api/ralph-loop/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, { planItems: enabledItems?.length ? enabledItems : undefined, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), + ...(advisorModel ? { advisorModel } : {}), }), }); const data = await res.json(); diff --git a/src/web/public/session-lineage.js b/src/web/public/session-lineage.js index 6a518131..c32fb349 100644 --- a/src/web/public/session-lineage.js +++ b/src/web/public/session-lineage.js @@ -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); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 307e2ff3..27a74ec8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, { return valid.includes(effort) ? effort : undefined; }, + /** + * Resolve the advisor model for new Claude sessions from global settings. + * Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own + * /advisor choice). Sent as the `advisorModel` payload field; the backend merges it + * into the launch's `claude --settings` JSON, so /advisor still switches it in-session. + */ + getAdvisorSetting(globalSettings) { + const advisor = globalSettings?.claudeAdvisorModel; + return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined; + }, + // ═══════════════════════════════════════════════════════════════ // Quick Start // ═══════════════════════════════════════════════════════════════ @@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, { const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const hasEnvOverrides = Object.keys(envOverrides).length > 0; const effort = this.getEffortSetting(globalSettings); + const advisorModel = this.getAdvisorSetting(globalSettings); // Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus // toggles; both flow as `modelOverride` → the case's .claude/settings.local.json const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; @@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, { workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), + ...(advisorModel ? { advisorModel } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}), }) }).then(r => r.json()) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6c5208e8..91efc5d3 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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 = @@ -527,6 +534,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; + document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? ''; // CPU Priority settings const niceSettings = settings.nice || {}; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; @@ -627,6 +635,7 @@ Object.assign(CodemanApp.prototype, { this._syncSettingsChips(); this._syncModelCards(); this._syncEffortSegment(); + this._syncAdvisorSegment(); // Back to the top of the document (one scroll, not a tab reset). Updates is // first now: the version this install is running, and whether a newer one is // waiting, are the two things worth seeing before any preference. The rest of @@ -718,6 +727,7 @@ Object.assign(CodemanApp.prototype, { if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return; this._buildModelCards(); this._buildEffortSegment(); + this._buildAdvisorSegment(); // Rebuilt on every open: admin-ui.js appends its Users entry to the rail // after the first open, and the menu must not drift from the rail. this._buildSettingsJumpMenu(); @@ -993,8 +1003,28 @@ Object.assign(CodemanApp.prototype, { }, _buildEffortSegment() { - const select = document.getElementById('appSettingsThinkingEffort'); - const seg = document.getElementById('appSettingsEffortSegment'); + this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); + }, + + _syncEffortSegment() { + this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment'); + }, + + _buildAdvisorSegment() { + this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); + }, + + _syncAdvisorSegment() { + this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment'); + }, + + /** + * Build a radio segment as a view over a hidden 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; } @@ -2159,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, @@ -2214,6 +2465,7 @@ Object.assign(CodemanApp.prototype, { opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked, thinkingEffort: document.getElementById('appSettingsThinkingEffort').value, + claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value, // CPU Priority settings nice: { enabled: document.getElementById('appSettingsNiceEnabled').checked, @@ -2431,6 +2683,7 @@ Object.assign(CodemanApp.prototype, { sessionLineageLines: _sll, ...serverSettings } = settings; + let webhookError = ''; try { const res = await this._apiPut('/api/settings', { ...serverSettings, @@ -2455,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) { @@ -2466,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. @@ -4106,4 +4372,5 @@ Object.assign(CodemanApp.prototype, { document.addEventListener?.('codeman:me', () => { window.app?._applyCustomModelAdminGate?.(); window.app?._applyCliManagementAdminGate?.(); + window.app?._applyMcpSyncAdminGate?.(); }); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 672be78d..9ff199a6 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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) { diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js new file mode 100644 index 00000000..de5b529d --- /dev/null +++ b/src/web/public/tab-layout-browser.js @@ -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 + * (`:`) 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 ( + '` + ); + } + 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 ( + `` + ); + }) + .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); diff --git a/src/web/public/terminal-split.js b/src/web/public/terminal-split.js index f8d352c7..7a8e96c2 100644 --- a/src/web/public/terminal-split.js +++ b/src/web/public/terminal-split.js @@ -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(); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 657e1624..a03bc4d1 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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; } } @@ -3155,6 +3399,7 @@ Object.assign(CodemanApp.prototype, { const globalSettings = this.loadAppSettingsFromStorage(); const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const effort = this.getEffortSetting(globalSettings); + const advisorModel = this.getAdvisorSetting(globalSettings); // `resumeSessionId` is a Claude conversation UUID (server reads it from // ~/.claude/projects); an external-CLI row has no such thing, so sending // it there gets silently ignored while the OMITTED `mode` field defaults @@ -3204,6 +3449,8 @@ Object.assign(CodemanApp.prototype, { ...modeConfig, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), ...(effort ? { effort } : {}), + // The advisor is a claude-only feature; other CLIs would carry it inertly. + ...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}), }), }); const createData = await createRes.json(); @@ -3522,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. @@ -3585,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(); }, @@ -3616,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; @@ -3646,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(); @@ -3851,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 { @@ -3868,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; @@ -3897,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 diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 01682157..efdd9a82 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.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(``); - idx++; - } - return parts.join(''); + `; }, _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 ───────────────────────────────────────────────────── diff --git a/src/web/reboot-restore-registry.ts b/src/web/reboot-restore-registry.ts index 94a7c63f..bc38d833 100644 --- a/src/web/reboot-restore-registry.ts +++ b/src/web/reboot-restore-registry.ts @@ -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 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 diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 2a7e41b5..a8886041 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -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, diff --git a/src/web/routes/mcp-sync-routes.ts b/src/web/routes/mcp-sync-routes.ts new file mode 100644 index 00000000..51f8d7ae --- /dev/null +++ b/src/web/routes/mcp-sync-routes.ts @@ -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 { + const settings = await readJsonConfig>(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): 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[] { + return enabledClis() + .filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability)) + .map((e) => e.label); +} + +async function gate(req: FastifyRequest): Promise | 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> => { + 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)); +} diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 24b08a48..ae068b28 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -293,6 +293,7 @@ export function registerRalphRoutes( planItems, envOverrides, effort, + advisorModel, } = parseBody(RalphLoopStartSchema, req.body); // Multi-user: cases live in the requesting user's space. @@ -343,6 +344,7 @@ export function registerRalphRoutes( allowedTools: rlClaudeModeConfig.allowedTools, envOverrides, effort, + advisorModel, owner: rlOwner, }); diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index fb6cec97..f060c539 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -195,6 +195,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes (saved as { __envOverrides?: Record }).__envOverrides ), effort: saved.effort, + model: saved.model, + advisorModel: saved.advisorModel, attachmentHistory: (saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ?? saved.attachmentHistory, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad..bb4bdbe0 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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 - // /.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 + // /.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. @@ -1130,6 +1157,7 @@ export function registerSessionRoutes( resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, + advisorModel: body.advisorModel, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, remote, owner, @@ -2092,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; const key = typeof body?.key === 'string' ? body.key : ''; - // Map key names to hex byte sequences - const KEY_HEX_MAP: Record = { - '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}`); } @@ -2116,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 = { + '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). @@ -3393,6 +3428,7 @@ export function registerSessionRoutes( ompConfig, envOverrides, effort, + advisorModel, parentSessionId, agentOrigin, customModel, @@ -3440,6 +3476,7 @@ export function registerSessionRoutes( if ( (envOverrides && Object.keys(envOverrides).length > 0) || effort || + advisorModel || modelOverride !== undefined || codexConfig || geminiConfig || @@ -3453,7 +3490,7 @@ export function registerSessionRoutes( ) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, - 'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.' + 'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.' ); } @@ -3510,6 +3547,7 @@ export function registerSessionRoutes( if ( (envOverrides && Object.keys(envOverrides).length > 0) || effort || + advisorModel || codexConfig || geminiConfig || antigravityConfig || @@ -3522,7 +3560,7 @@ export function registerSessionRoutes( ) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, - 'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.' + 'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.' ); } @@ -3977,6 +4015,7 @@ export function registerSessionRoutes( ompConfig: qsResolvedOmpConfig, envOverrides: qsCustomModelEnvOverrides, effort, + advisorModel, remote, docker, resumeSessionId: dockerResumeId, diff --git a/src/web/routes/webhook-routes.ts b/src/web/routes/webhook-routes.ts new file mode 100644 index 00000000..f30b62ed --- /dev/null +++ b/src/web/routes/webhook-routes.ts @@ -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 | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + const status = async (): Promise => { + 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> => { + const no = denied(req, reply); + if (no) return no; + return { success: true, data: await status() }; + }); + + app.put('/api/webhook', async (req, reply): Promise> => { + 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> => { + 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()) }; + }); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9..48845fd0 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,11 +18,14 @@ 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'; import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js'; import type { SessionMode } from '../types.js'; +import { isAdvisorModel } from '../types/session.js'; // ========== Path Validation ========== @@ -251,6 +254,20 @@ const safeEnvOverridesSchema = z */ const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional(); +/** + * Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of + * those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`, + * a soft default that /advisor still switches in-session. The allowlist is also the injection + * guard for the single-quoted `--settings` argument. + */ +const advisorModelSchema = z + .string() + .max(64) + .refine((value) => isAdvisorModel(value), { + message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id', + }) + .optional(); + // ========== Session Routes ========== /** @@ -298,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) @@ -524,8 +542,25 @@ export const CreateSessionSchema = z.object({ envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, + /** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */ + 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 `; 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, @@ -1055,6 +1090,8 @@ export const QuickStartSchema = z.object({ envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, + /** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */ + advisorModel: advisorModelSchema, /** * Who is spawning this worker (`codeman-skill` from the packaged agent skill), or, * equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are @@ -1327,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 @@ -1374,6 +1418,12 @@ export const SettingsUpdateSchema = z // auto-reattached. remoteAutoReconnect: z.boolean().optional(), thinkingEffort: z.string().max(20).optional(), + /** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */ + claudeAdvisorModel: z + .string() + .max(64) + .refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' }) + .optional(), // UI visibility showFontControls: z.boolean().optional(), showSystemStats: z.boolean().optional(), @@ -1846,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 */ @@ -1862,6 +1926,8 @@ export const RalphLoopStartSchema = z.object({ envOverrides: safeEnvOverridesSchema, /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort: effortLevelSchema, + /** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */ + advisorModel: advisorModelSchema, planItems: z .array( z.object({ diff --git a/src/web/server.ts b/src/web/server.ts index d70577e6..b9f40556 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 | null = null; private userFailures: StaleExpirationMap | 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, 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,8 @@ 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 // the launch conversation until the user types again, even though diff --git a/src/webhook-notify.ts b/src/webhook-notify.ts new file mode 100644 index 00000000..03ef7b3f --- /dev/null +++ b/src/webhook-notify.ts @@ -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 ``, `<@U123>` and ``; escaping the three control characters turns them to text. */ +const slackEscape = (s: string): string => s.replace(/&/g, '&').replace(//g, '>'); + +const NTFY_PRIORITY: Record = { critical: '5', warning: '4', info: '3' }; +const NTFY_TAGS: Record = { + critical: 'rotating_light', + warning: 'bell', + info: 'white_check_mark', +}; + +export interface WebhookRequest { + method: 'POST'; + headers: Record; + 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; + 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 { + 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 { + 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; + +/** + * 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, + msg: WebhookMessage, + fetchImpl: WebhookFetch +): Promise { + 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(); + private inFlight = 0; + private last: WebhookResult | null = null; + + constructor( + private readonly load: () => Promise, + private readonly fetchImpl: WebhookFetch, + private readonly now: () => number = Date.now + ) {} + + get lastResult(): WebhookResult | null { + return this.last; + } + + async notify(msg: WebhookMessage): Promise { + 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, host?: string): Promise { + 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; + } +} diff --git a/test/advisor-model.test.ts b/test/advisor-model.test.ts new file mode 100644 index 00000000..39f94b05 --- /dev/null +++ b/test/advisor-model.test.ts @@ -0,0 +1,304 @@ +/** + * @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor) + * carried as a per-session `advisorModel`. + * + * The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key, + * never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses + * (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the + * settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with + * `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5". + * + * `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the + * assertions see exactly what a spawned pane would. + */ + +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'; +import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js'; +import { Session } from '../src/session.js'; +import { + CreateSessionSchema, + QuickStartSchema, + RalphLoopStartSchema, + SettingsUpdateSchema, +} from '../src/web/schemas.js'; + +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(); + 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); + const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString(); + return JSON.parse(out); +} + +describe('isAdvisorModel', () => { + it('accepts the documented aliases', () => { + for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true); + }); + + it('accepts full model ids in the advisor-capable families', () => { + expect(isAdvisorModel('claude-opus-5-5')).toBe(true); + expect(isAdvisorModel('claude-fable-5-1')).toBe(true); + expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true); + }); + + it('rejects haiku, which can call an advisor but never act as one', () => { + expect(isAdvisorModel('haiku')).toBe(false); + expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false); + }); + + it('rejects anything that could break out of the quoted --settings argument', () => { + for (const bad of [ + '', + 'OPUS', + 'opus[1m]', + "opus'; rm -rf /; '", + 'opus"}', + 'claude-opus-5-5 --dangerously-skip-permissions', + `claude-opus-${'5-'.repeat(40)}5`, + undefined, + null, + 42, + ]) { + expect(isAdvisorModel(bad)).toBe(false); + } + }); +}); + +describe('buildAdvisorSettings', () => { + it('returns the settings key for a valid model and nothing otherwise', () => { + expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' }); + expect(buildAdvisorSettings(undefined)).toEqual({}); + expect(buildAdvisorSettings('haiku')).toEqual({}); + }); +}); + +describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => { + it('rides --settings as the advisorModel key, never the --advisor flag', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' }); + expect(cmd).not.toContain('--advisor'); + expect(cmd.match(/--settings/g)).toHaveLength(1); + expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' }); + }); + + it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'ultracode', + advisorModel: 'fable', + statusLineCommand: EXPORTER_CMD, + }); + expect(cmd.match(/--settings/g)).toHaveLength(1); + expect(extractSettingsJson(cmd)).toEqual({ + ultracode: true, + advisorModel: 'fable', + statusLine: { type: 'command', command: EXPORTER_CMD }, + }); + }); + + it('keeps a regular --effort flag beside the advisor settings', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' }); + expect(cmd).toContain("--effort 'high'"); + expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' }); + }); + + it('carries the advisor on the resume variant too', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + resumeSessionId: '11111111-2222-3333-4444-555555555555', + advisorModel: 'opus', + }); + expect(cmd).toContain('--resume'); + expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' }); + }); + + it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => { + for (const base of [ + { mode: 'claude', sessionId: 'sid-1' }, + { mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD }, + ]) { + const without = buildSpawnCommand(base); + expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without); + expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without); + } + }); + + it('is inert for a CLI that has no --settings carrier', () => { + const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' }); + expect(cmd).not.toContain('advisorModel'); + }); +}); + +describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => { + const settingsOf = (args: string[]) => { + expect(args.filter((a) => a === '--settings')).toHaveLength(1); + return JSON.parse(args[args.indexOf('--settings') + 1]); + }; + + it('adds a --settings object holding only the advisor', () => { + const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus'); + expect(args).not.toContain('--advisor'); + expect(settingsOf(args)).toEqual({ advisorModel: 'opus' }); + }); + + it("folds the advisor into ultracode's --settings object", () => { + const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable'); + expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' }); + }); + + it('keeps --effort beside it for a regular level', () => { + const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet'); + expect(args).toEqual(expect.arrayContaining(['--effort', 'max'])); + expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' }); + }); + + it('is unchanged without an advisor', () => { + expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual( + buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null) + ); + }); +}); + +/** + * #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying + * both must launch with `--model ` 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' }); + expect(session.toState().advisorModel).toBe('opus'); + }); + + it('drops an invalid value instead of forwarding it to the launch', () => { + expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined(); + expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined(); + }); +}); + +describe('advisorModel request validation', () => { + it.each([ + ['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }], + ['QuickStartSchema', QuickStartSchema, {}], + ['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }], + ] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => { + expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true); + expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true); + expect(schema.safeParse({ ...base }).success).toBe(true); + expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false); + expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false); + }); + + it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => { + expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true); + expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false); + }); +}); diff --git a/test/cli-newline-capability.test.ts b/test/cli-newline-capability.test.ts new file mode 100644 index 00000000..156431ad --- /dev/null +++ b/test/cli-newline-capability.test.ts @@ -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).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).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).newline; + expect(CliEntrySchema.safeParse(e).success).toBe(true); + }); +}); diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 72efee78..77a8cf41 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -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', () => { diff --git a/test/codex-reasoning-effort-schema.test.ts b/test/codex-reasoning-effort-schema.test.ts new file mode 100644 index 00000000..d018a485 --- /dev/null +++ b/test/codex-reasoning-effort-schema.test.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview `codexConfig.reasoningEffort` on the create routes. + * + * The level becomes part of a `--config model_reasoning_effort=` 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(); + } + }); +}); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 2f65bd9f..502a21b2 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -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 }); diff --git a/test/key-tester.browser.test.ts b/test/key-tester.browser.test.ts new file mode 100644 index 00000000..edc6eb5e --- /dev/null +++ b/test/key-tester.browser.test.ts @@ -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 + // , 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(''); + }); +}); diff --git a/test/mcp-sync-registry.test.ts b/test/mcp-sync-registry.test.ts new file mode 100644 index 00000000..10dddba1 --- /dev/null +++ b/test/mcp-sync-registry.test.ts @@ -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).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); + }); +}); diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts new file mode 100644 index 00000000..e3907095 --- /dev/null +++ b/test/mcp-sync.test.ts @@ -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 = {}) => ({ + ...TARGETS.find((t) => t.id === id)!, + ...patch, +}); +const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id)); +const result = (r: Awaited>, 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).polluted).toBeUndefined(); + expect(({} as Record).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> }; + 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; + } + }); +}); diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts new file mode 100644 index 00000000..73f87211 --- /dev/null +++ b/test/mobile-ime-preview-structure.test.ts @@ -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; +type App = Record; + +function loadMixin(globals: Record = {}) { + const FakeCodemanApp = function () {} as unknown as { prototype: Record }; + const windowStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + ...(globals.window as object), + } as Record; + 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(); + 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>, + querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null), + appendChild(node: Record) { + this.children.push(node); + }, + }; + const createdControllers: Array> = []; + const previewNodes: Array> = []; + const documentStub = { + addEventListener: vi.fn(), + createElement: vi.fn(() => { + const node = { + className: '', + hidden: false, + textContent: '', + dataset: {} as Record, + style: {} as Record, + attributes: {} as Record, + 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) => { + 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 }) => 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(''); + expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js')); + expect(at).toBeLessThan(indexSource.indexOf('')); + expect(at).toBeLessThan(indexSource.indexOf('')); + }); + + 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(); + }); +}); diff --git a/test/mobile-ime-preview.browser.test.ts b/test/mobile-ime-preview.browser.test.ts new file mode 100644 index 00000000..f684b76c --- /dev/null +++ b/test/mobile-ime-preview.browser.test.ts @@ -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('
'); + 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( + '
' + ); + 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((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((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((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 }); + }); +}); diff --git a/test/mobile-ime-preview.test.ts b/test/mobile-ime-preview.test.ts new file mode 100644 index 00000000..cfdec9a3 --- /dev/null +++ b/test/mobile-ime-preview.test.ts @@ -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) => 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(); + + 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) { + for (const registered of [...(this.listeners.get(type) ?? [])]) { + if (registered.capture === capture) registered.listener(event); + } + } + + dispatch(type: string, event: Record = {}) { + 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 = {}, + beforeCreate?: (textarea: FakeTextarea, getController: () => Record | 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 | 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) => clearTimeout(id), + }); + } + + test.each([ + ['compositionend', (h: ReturnType) => h.textarea.dispatch('compositionend', { data: '' })], + [ + 'a finalizing keydown', + (h: ReturnType) => 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); + }); +}); diff --git a/test/resume-history-mode-fidelity.test.ts b/test/resume-history-mode-fidelity.test.ts index 3f71d27e..ede2789f 100644 --- a/test/resume-history-mode-fidelity.test.ts +++ b/test/resume-history-mode-fidelity.test.ts @@ -55,6 +55,7 @@ function makeApp() { getCaseSettings: () => ({}), buildEnvOverrides: () => ({}), getEffortSetting: () => undefined, + getAdvisorSetting: () => undefined, selectSession: vi.fn(async () => {}), }; } diff --git a/test/routes/external-cli-bypass-clamp.test.ts b/test/routes/external-cli-bypass-clamp.test.ts index 226f7fda..22cf06ec 100644 --- a/test/routes/external-cli-bypass-clamp.test.ts +++ b/test/routes/external-cli-bypass-clamp.test.ts @@ -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(); diff --git a/test/routes/mcp-sync-routes.test.ts b/test/routes/mcp-sync-routes.test.ts new file mode 100644 index 00000000..e38f07b8 --- /dev/null +++ b/test/routes/mcp-sync-routes.test.ts @@ -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(); + return { + ...actual, + syncMcpServers: async (...args: Parameters) => { + if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args); + const parked = new Promise((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()); +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((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); + }); +}); diff --git a/test/routes/session-routes-claude-model.test.ts b/test/routes/session-routes-claude-model.test.ts new file mode 100644 index 00000000..d825f35c --- /dev/null +++ b/test/routes/session-routes-claude-model.test.ts @@ -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 `, 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 { + 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): Promise { + 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'); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index cc9c9422..11b1e755 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -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 => { + 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 " + }; + + 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({ diff --git a/test/routes/webhook-routes.test.ts b/test/routes/webhook-routes.test.ts new file mode 100644 index 00000000..58fa2132 --- /dev/null +++ b/test/routes/webhook-routes.test.ts @@ -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>; + +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(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); + }); +}); diff --git a/test/session-model-recovery.test.ts b/test/session-model-recovery.test.ts new file mode 100644 index 00000000..6c90dc3e --- /dev/null +++ b/test/session-model-recovery.test.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview A session's launch model survives recovery. + * + * `Session._model` is what becomes `claude --model `: 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): 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},`)); + } + }); +}); diff --git a/test/shift-enter-keypress-swallowed.test.ts b/test/shift-enter-keypress-swallowed.test.ts new file mode 100644 index 00000000..1ca5f311 --- /dev/null +++ b/test/shift-enter-keypress-swallowed.test.ts @@ -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'/); + }); +}); diff --git a/test/shift-enter-keypress.browser.test.ts b/test/shift-enter-keypress.browser.test.ts new file mode 100644 index 00000000..c13aead8 --- /dev/null +++ b/test/shift-enter-keypress.browser.test.ts @@ -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 ` `. */ + 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 { + 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 { + 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([]); + }); +}); diff --git a/test/split-pane-terminal-unit.test.ts b/test/split-pane-terminal-unit.test.ts index 9f86c384..b408283e 100644 --- a/test/split-pane-terminal-unit.test.ts +++ b/test/split-pane-terminal-unit.test.ts @@ -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[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 = { 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 | Error) => void; + const body = new Promise<{ data: Record }>((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 = {}) => + release(jsonResponse(terminalBuffer, extra)), + fail: () => release(new Error('body read failed')), + }; +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((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>(); - 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>(); - 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>(); - 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>(); 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>(); + 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>(); + 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>(); + const second = deferred>(); + 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) => h.release(rowsOf(30))], + ['a downgrade', 500, (h: ReturnType) => h.release(rowsOf(5))], + ['a failed body read', 40, (h: ReturnType) => 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))); diff --git a/test/tab-activation.browser.test.ts b/test/tab-activation.browser.test.ts new file mode 100644 index 00000000..5da8d446 --- /dev/null +++ b/test/tab-activation.browser.test.ts @@ -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: '' }) + ); + await page.goto('http://codeman.test/'); + await page.setContent(` + + + +
+
+ +
+ + `); + 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', + '
' + ); + 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); + }); +}); diff --git a/test/tab-layout-browser.test.ts b/test/tab-layout-browser.test.ts new file mode 100644 index 00000000..a97c6b00 --- /dev/null +++ b/test/tab-layout-browser.test.ts @@ -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, '"'); + +const layout = (version = 4) => ({ + version, + updatedAt: '2026-08-16T00:00:00.000Z', + groups: [ + { + id: 'g1', + name: '', + 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(); + 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) => `
`, + escape + ); + const doc = new JSDOM(`
${html}
`).window.document; + expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([ + '', + '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('[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(''); + 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('[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('.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(`
${html}
`).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) | 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) => { + 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) | 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, 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 = 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('