mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9466acfc1a | ||
|
|
e899af4305 | ||
|
|
299a21d5f5 | ||
|
|
d8e85285c9 | ||
|
|
0f955327b2 | ||
|
|
d3f2ec0220 | ||
|
|
6ef71ec3b9 | ||
|
|
d47f93abdb | ||
|
|
dcf9437308 | ||
|
|
aa13af1f7f | ||
|
|
9a2e14a93a | ||
|
|
ecb95b5d67 | ||
|
|
a7452dc046 | ||
|
|
46d8b92049 | ||
|
|
0b3e086334 | ||
|
|
fafef0aa00 | ||
|
|
3152ec801d | ||
|
|
2e3e245cc6 | ||
|
|
165cfb52d6 | ||
|
|
6c8bd6c606 | ||
|
|
8d3bde5469 | ||
|
|
78dcb0aa24 | ||
|
|
7fc66e8161 | ||
|
|
1678386f50 | ||
|
|
3859506f9b | ||
|
|
33b2605815 | ||
|
|
d0a887d98a | ||
|
|
24a92c8f3e | ||
|
|
f7852081b7 | ||
|
|
f0e24d8ce2 | ||
|
|
2724c922ce | ||
|
|
57406f6c14 | ||
|
|
8903a72662 | ||
|
|
ba7b8b7bef | ||
|
|
8c73128cd6 | ||
|
|
2abf328db8 | ||
|
|
fa8bb13a27 | ||
|
|
6f64e557e5 | ||
|
|
97a1238c85 | ||
|
|
aa0521602d | ||
|
|
2bc16d5fd9 | ||
|
|
d60a164025 | ||
|
|
727817410c | ||
|
|
28d3bd7da8 | ||
|
|
d0f9bdd251 | ||
|
|
ff98006471 | ||
|
|
5f1be90ae9 | ||
|
|
3da8bb7046 | ||
|
|
ac574d6c64 | ||
|
|
d9e6ebb20a | ||
|
|
88e5b7b200 | ||
|
|
73607663fd | ||
|
|
458ca578e7 | ||
|
|
884713cca5 | ||
|
|
a220c28a14 | ||
|
|
0761de3dae | ||
|
|
c2eaba990b | ||
|
|
e214691429 | ||
|
|
773b405429 | ||
|
|
2df9355367 | ||
|
|
cd64b0a3f7 | ||
|
|
2d573d8a34 | ||
|
|
55a80eab86 | ||
|
|
b1db5515d7 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
|
||||
@@ -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.31.0",
|
||||
"version": "1.32.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d47f93a: feat(custom-model): the model picker puts the ready model first
|
||||
|
||||
When a custom endpoint has more than one model, the Run menu's picker now promotes one row to the top instead of showing raw discovery order: the model llama-swap reports loaded and ready right now (tagged "Currently loaded", the one a launch attaches to with zero wait), else the model you last launched on that harness and endpoint (tagged "Last used", remembered per device). The endpoint's default keeps its own pill, nothing is ever auto-chosen, and a plain OpenAI-compatible server or an endpoint that does not answer within a second simply keeps the old order. The probe is bounded on the client too, so a GPU box that is off no longer holds the picker closed for five seconds.
|
||||
|
||||
- d47f93a: feat(split-pane): view two live sessions side by side
|
||||
|
||||
A new Split button in the header (opt-in in App Settings, off by default, desktop only at 1180px and wider) opens a picker and shows a second live session beside the active one: its own terminal, its own WebSocket, and a divider you can drag. When either session ends the view collapses back to one pane, with Pane B promoted to the primary when it is Pane A that ended. Nothing is persisted on purpose in this first cut, so a page reload always returns to a single pane. Pane B is deliberately plainer than the primary pane (no local-echo overlay, CJK input, touch handling or keyboard accessory bar); the design and the v2 boundaries are in discussion #452.
|
||||
|
||||
- 72d437a: Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
|
||||
- d47f93a: feat(mobile): a Compose key for writing prompts on a phone
|
||||
|
||||
The agent keyboard bars on phones replace their Paste key with Compose: a real multiline editor with autocorrect and spellcheck, per-session drafts kept in memory only, image attach that never writes into the terminal early, and a Send that delivers the text as one paste followed by Enter, so a long prompt no longer has to be typed blind into the terminal composer. Anything you had already typed into the terminal is picked up into the editor. Shell sessions keep the direct Paste key. This is the manual first slice from #359; the auto-open setting and terminal tap routing are a separate follow-up.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d47f93a: refactor(run-menu): one table-driven launcher for every external CLI
|
||||
|
||||
The eight near-identical per-CLI launch functions in the Run menu collapsed into one launcher driven by a table that a CI test keeps in step with the CLI registry, and a second no-id-branching guard now covers the frontend the way the backend guard covers the server. No behaviour change: the refactor was verified byte-identical across 288 launch permutations against the previous code.
|
||||
|
||||
- e899af4: Maintainer fixes applied while landing the above. The model picker's promoted row keeps its Default pill (the promotion tag and the default marker are two pills now, and they render as pills in the picker rather than as plain text). The phone composer keeps its bottom gutter on folding devices (the generic fold rule used to erase it), a whitespace-only draft is no longer sent, and its dialog is translated on a zh-CN UI. A split that collapses mid-drag no longer leaves the page stuck in resize-cursor mode, Pane B refuses a session that has no live process, and a burst of refresh frames replays once instead of twice. The `</head>` script injections on the page render use replacer functions, so a CLI label containing `$'` can no longer splice the document into the inline script, and the frontend no-id-branching guard now catches comparisons on any variable name.
|
||||
- 6ef71ec: ### Thanks
|
||||
- @timkjr for split-pane sessions (#453): five review rounds turned around in two days, and the pointer-capture edge case measured in a real browser rather than reasoned about.
|
||||
- @DodgyBadger for the mobile prompt composer (#444), a first contribution that took the scope back down to one slice when asked, and that verified the delivery path against a live tmux pane and a live Claude Code composer instead of trusting the diff.
|
||||
- @opticon454 for putting the ready model first in the picker (#459) and for collapsing the eight Run-menu launch functions into one (#458), proven byte-identical across 288 launch permutations instead of argued.
|
||||
|
||||
## 1.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -77,7 +77,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.31.0 (must match `package.json`)
|
||||
**Version**: 1.32.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -171,7 +171,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (27 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 34 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 35 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
@@ -223,13 +223,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing. `Start-Codeman.sh` pre-creates both `CODEMAN_APPDATA_PATH` and `CODEMAN_CASES_PATH` on the host before `up`, which is what keeps the daemon from ever having to materialise either as root in the first place; the container ALSO starts as root (`cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against the base `cap_drop: ALL`; `test/docker-entrypoint.test.ts` pins that list) so `docker/entrypoint.sh` can correct a bind source that turns up root-owned anyway (a restored backup, a cleared directory, plain `docker compose up` run without the script) before dropping to `PUID:PGID` via `setpriv` — a directory owned by neither root nor `PUID:PGID` is never re-owned, since that ownership is not this container's to reassign; it is PROBED for writability as the runtime account (`setpriv ... test -w`, so ACLs, group-writable trees and CIFS/NFS mounts pass) and refused with a message naming path, owner and PUID:PGID if that fails. ⚠️ `KILL` is in that list for tini, not the entrypoint: `init: true` keeps tini as root while the server runs as PUID, and without CAP_KILL its SIGTERM forward fails and the server is SIGKILLed on every `compose down`/`restart` instead of flushing state. ⚠️ `/opt/codeman-cli` (the runtime-owned CLI prefix) is APPENDED to `PATH`, never prepended, and the entrypoint pins its own `PATH` to the system dirs: the root part of the start resolves `setpriv` by bare name, and a prefix ahead of `/usr/bin` let a planted `setpriv` run as uid 0 (measured). `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update below and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. `docs/docker-compose.md` + `docker/README.md` (user guides)
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears — it matches `===`, `!==`, `case '<id>':` and `[...].includes(mode)`, because an earlier `===`-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with `isExternalCliMode()` by hand). ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. ⚠️ Two capability fields carry a REGEX from config (`discovery.version.regex` and `capabilities.workDetect.workingLine`) and both must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. ⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured, so re-measure before wiring one up; the list is pinned so it cannot quietly grow. Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden. ⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → `docs/cli-registry.md`
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears — it matches `===`, `!==`, `case '<id>':` and `[...].includes(mode)`, because an earlier `===`-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with `isExternalCliMode()` by hand). A second CI-gated guard, `test/frontend-cli-no-id-branching.test.ts`, covers the two frontend files the run-menu consolidation (#458) touched, `session-ui.js` and `mobile-overview.js`: its allowlist is keyed by expression rather than line number, with an occurrence count per entry, so a new branch reusing an already-approved expression fails as a count mismatch instead of riding in on the old approval. ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. ⚠️ Two capability fields carry a REGEX from config (`discovery.version.regex` and `capabilities.workDetect.workingLine`) and both must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. ⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured, so re-measure before wiring one up; the list is pinned so it cannot quietly grow. Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden. ⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → `docs/cli-registry.md`
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. ⚠️ **Work detection is no longer part of that gate**: it is per-CLI `capabilities.workDetect` data (see the ❯ note above), so a CLI that declares its own glyph and working line gets the same screen-probed idle confirmation claude gets, and one that declares neither keeps the output-stabilization behaviour. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/<name>` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half Line truncated
|
||||
|
||||
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ **Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privileLine truncated
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ **Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privileLine truncated
|
||||
|
||||
**Everything below landed after the initial backend + picker cut, each confirmed live against a real llama-swap deployment.** ⚠️ **llama-swap runs one model at a time, and switching can disrupt ANOTHER live session** — before applying, both apply routes call llama-swap's own `GET /running` (feature-detected via `getLlamaSwapStatus()`, `custom-model-routes.ts`; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns `{requiresConfirmation, currentlyLoadedModel, affectedSessions}` instead of switching silently; retrying with `confirmedSwap: true` skips the check, and switching with nothing else affected proceeds immediately. ⚠️ **The swap question and the context-floor question below have SEPARATE flags** (`confirmedSwap`, `confirmedContext`), because the context check runs first and while both shared one `confirmed` a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy `confirmed` still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire `triggerLlamaSwapLoad()`, a fire-and-forget `POST <baseUrl>/v1/chat/completions` with `max_tokens: 1`, whenever the target model isn't already loaded and ready. ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. ⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. ⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasLine truncated
|
||||
|
||||
@@ -261,6 +261,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. ⚠️ **A visible capture now REPORTS the geometry it was taken at** (`captureCols`/`captureRows`, #435), because a frame built for a pane taller or wider than the browser is damaged two ways at once (overflow rows clamp onto the last line; a narrower browser wraps every painted row) and nothing in the response used to say so. Both fields are ABSENT when no frame was positioned, so every consumer tests `Number.isFinite`, never truthiness: a `display-message` cursor query that fails makes `capturePaneBuffer` return the raw capture while the route still labels it `mux-visible`. The comparison runs on `mux-visible` ONLY, the replay is capped at one attempt, and a pane that cannot be sized to fit latches in `_geometryRetryUseless` so it is diagnosed once per session rather than on every tab switch. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`.
|
||||
@@ -304,7 +306,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) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
@@ -342,6 +344,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
|
||||
|
||||
**Mobile prompt composer** (PR #444, the first slice of #359, `keyboard-accessory.js`): the agent bars' Paste key is now **Compose**, a dialog with a native multiline textarea (autocorrect, autocapitalize, spellcheck) where Enter adds a line and only **Send** submits; the shell bar keeps the direct Paste dialog, since shell input is not an agent prompt. Opening it ADOPTS the whole editable terminal prompt (`_takePendingLocalEcho`): the local-echo overlay's pending text has never reached the PTY, but the flushed prefix has, so that prefix is erased with backspaces counted in CODE POINTS (`Array.from(text).length`; measured on Claude Code 2.1.278, `a` + emoji + `b` takes three, and the UTF-16 count sent four and ate the neighbour; `clearTerminalInput()` in terminal-ui.js moved with it). ⚠️ **Drafts are per-session and in memory only** (`_composerDrafts`, never persisted: prompts routinely carry secrets, and persisting them would need the 0600 treatment the intent store gets). Every non-Send exit (Cancel, backdrop, Escape, "Use terminal keyboard") leaves the taken text ONLY in the draft, with the dot on the key (`has-draft`, kept in step by `_syncComposerDraftIndicator`) as the signal that the terminal prompt is empty on purpose, and `_cleanupSessionData` discards the draft with the session. ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). ⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. ⚠️ `.prompt-composer-overlay` is a `.paste-overlay` with a gutter of its own (a `padding` shorthand whose bottom is 12px plus the safe area), and the unconditional `.paste-overlay` fold rule at the end of styles.css is a later longhand at the same specificity, so it ERASED that gutter (measured at 393x852: `padding-bottom: 0px` flat, and the hinge strip REPLACING the gutter with the fold variables set): the composer has its own restatement after the fold rules, the dialog's `max-height` subtracts `--fold-block-end`, and `test/foldable-layout.test.ts` lists the composer in `ELEMENTS` by hand, because its derived overlay list keys on rules that declare `position: fixed; inset: 0` themselves. Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not under `test/mobile/**`).
|
||||
|
||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
|
||||
|
||||
**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.
|
||||
@@ -354,7 +358,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` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-load-window.browser.test.ts',
|
||||
'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/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -402,6 +402,10 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
|
||||
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
|
||||
|
||||
### Split-pane sessions
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return trLine truncated
|
||||
|
||||
### Gesture control: the setting
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
@@ -102,6 +102,8 @@ It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<i
|
||||
|
||||
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
|
||||
|
||||
`test/frontend-cli-no-id-branching.test.ts` is the same guard for the two frontend files the CLI registry's Run-menu consolidation touches, `session-ui.js` and `mobile-overview.js` — deliberately not the rest of `src/web/public/`, whose per-CLI rules stay out of scope for now (see "Fields declared for later" below). Its allowlist keys on `<file>::<expression>` with no line number, since a single unrelated edit to a contended file would otherwise shift every subsequent line and make every entry go stale at once, and each entry additionally carries the exact number of approved call sites — a bare key would let a brand-new branch reusing an already-approved expression land unreviewed. Its comparison shape differs from the backend guard's in one respect: the left-hand side may be any identifier, not only one named `mode`, `id` or `agentType`, because the review of #458 found `const m = this._runMode; if (m === 'codex')` slipping past the named form while the scanned file already filters with `(m) => m !== 'shell'`.
|
||||
|
||||
## Two namespaces called `param`
|
||||
|
||||
`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two.
|
||||
|
||||
@@ -183,6 +183,34 @@ launch, with the endpoint's `defaultModelId` marked but not auto-chosen —
|
||||
the point of asking is letting one launch deliberately differ from the
|
||||
saved default, not just confirming it.
|
||||
|
||||
The modal promotes exactly one row to the top of the list rather than
|
||||
always showing raw discovery order, so the zero-wait choice is the one
|
||||
under your thumb:
|
||||
|
||||
- **"Currently loaded"** — a model from this host's own list that
|
||||
llama-swap reports `ready` right now, queried via
|
||||
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
|
||||
~800ms (`Promise.race`), on top of the route's own 5s server-side
|
||||
timeout, so an endpoint that is asleep or firewalled cannot leave the
|
||||
modal invisible for the full 5s after the Run menu has already closed.
|
||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||
actually launched last for this exact (harness, endpoint) pair, read
|
||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
`runCustomModelEntry` entry point itself only dispatches between the
|
||||
two) only once the model is actually applied, never on the mere click —
|
||||
declining the context-window warning means this exact model cannot work
|
||||
with this CLI at all, so promoting it next time would be actively wrong,
|
||||
not just premature.
|
||||
|
||||
Neither tag reorders anything past that one promoted row. The "Default"
|
||||
pill is a separate span, not a third value of the same slot: a promoted
|
||||
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||
single-purpose GPU box that is the common case, and an exclusive slot
|
||||
silently dropped the Default marking for exactly that row), and a row
|
||||
with neither promotion nor default shows no tag at all.
|
||||
|
||||
**How the launch itself applies the endpoint depends on the harness.** For
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Split-Pane Sessions — Design Spec
|
||||
|
||||
**Status**: Implemented (v1)
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
switching panes re-binds the single xterm instance and the single WebSocket
|
||||
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
|
||||
`span-codeman.ps1`) turned out to solve a different problem: it makes one
|
||||
browser window bigger, but that window still shows one session; floating
|
||||
subagent windows are draggable overlays on top of it, not tiled panes. There
|
||||
is no way today to see two live sessions (e.g. `w1-codeman` and
|
||||
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
|
||||
fit both.
|
||||
|
||||
## Goal (v1)
|
||||
|
||||
From the active session, open a **second, independent, fully live session**
|
||||
in a pane beside it — draggable divider, side-by-side only. Closing the
|
||||
second pane collapses back to today's normal single-pane view. No
|
||||
persistence: a page reload always returns to single-pane. Floating
|
||||
subagent/Ultracode windows keep their current behavior unchanged (global,
|
||||
unconstrained across the whole viewport, split or not).
|
||||
|
||||
Explicitly out of scope for v1 (v2 candidates, not designed here):
|
||||
- More than 2 panes / grid layouts
|
||||
- Vertical (stacked) splits
|
||||
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
|
||||
- Persisting the split layout across reload or across devices
|
||||
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
|
||||
to desktop widths the same way `home-sessions.js`'s rail is)
|
||||
- Feature parity between the two panes (see "Pane B is deliberately plainer"
|
||||
below)
|
||||
|
||||
## Current architecture (why this isn't a CSS change)
|
||||
|
||||
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
|
||||
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
|
||||
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
|
||||
`this._xtermSnapshots` map used only to restore scrollback into that one
|
||||
terminal when switching back to a session. Roughly 280 references to this
|
||||
singleton state exist across the file (input handling, resize/fit, sizing-
|
||||
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
|
||||
keyboard accessory bar, link providers, etc.).
|
||||
|
||||
Showing two sessions at once therefore requires a second, independently
|
||||
alive xterm + WebSocket pair running concurrently — not a layout change to
|
||||
one shared instance.
|
||||
|
||||
**Related prior art**: `detachSession(id)` (app.js) already opens one session
|
||||
in a genuinely separate browser window (`isSoloWindow` mode) with its own
|
||||
independent WebSocket, and two of those can already be snapped side-by-side
|
||||
today with zero new code. That covers "two sessions visible at once" but not
|
||||
what this spec is for: one Codeman window with two panes and a divider you
|
||||
can drag without leaving your seat, each still a full participant in that
|
||||
window's floating subagent windows, header, and settings. This spec builds
|
||||
past detach, not a duplicate of it.
|
||||
|
||||
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
|
||||
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
|
||||
(`ws-connection-registry.ts`). Splitting always opens a *different* session
|
||||
in the second pane (self-splitting is disallowed, see below), so this is two
|
||||
sessions each getting their normal one connection — the existing cap is
|
||||
irrelevant here and needs no server change.
|
||||
|
||||
## Key design decision: Pane B is deliberately plainer than Pane A
|
||||
|
||||
Porting all ~280 singleton behaviors to a second, symmetric pane is not
|
||||
worth it for v1 — most of that code is input-quality-of-life for **mobile/
|
||||
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
|
||||
keyboard accessory bar), and this feature is desktop-only by nature (a split
|
||||
view needs a wide viewport). So:
|
||||
|
||||
- **Pane A** (the session that was already active when you opened the split)
|
||||
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
|
||||
code path, zero regression risk.
|
||||
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
|
||||
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
|
||||
resize-on-divider-drag, and plain keyboard input. It does **not** get the
|
||||
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
call sites to compare, rather than guessing the right abstraction now).
|
||||
|
||||
One more asymmetry worth naming here rather than discovering by surprise:
|
||||
while both panes accept keyboard input, the global capture-phase shortcut
|
||||
handler (`app.js`) always resolves against Pane A — it has no notion of
|
||||
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
|
||||
focus clears or closes Pane A, not the session you were actually typing
|
||||
into. Not fixed for v1, same reasoning as the rest of this section.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
|
||||
|
||||
A small class, one instance per secondary pane:
|
||||
- `constructor(sessionId, mountEl)`
|
||||
- `connect()` — creates the xterm instance (same theme/font config as the
|
||||
primary, read from the same settings so it doesn't visually clash), opens
|
||||
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
|
||||
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
|
||||
on window resize
|
||||
- `destroy()` — disposes the xterm instance, closes the WS cleanly
|
||||
|
||||
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
|
||||
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
|
||||
there's no persistence requirement.
|
||||
|
||||
### 2. Split container (layout)
|
||||
|
||||
```
|
||||
.terminal-split-container (flex row, only rendered when split is active)
|
||||
├── .terminal-wrap (existing element, Pane A — untouched)
|
||||
├── .split-divider (new, draggable seam)
|
||||
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
|
||||
small header: session name + × close button)
|
||||
```
|
||||
|
||||
When not split, `.terminal-wrap` renders exactly as it does today (no
|
||||
wrapping container at all, to keep the no-split path byte-identical to
|
||||
current behavior). Splitting inserts the container and reparents
|
||||
`.terminal-wrap` into it as the first child — same reparenting pattern
|
||||
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
|
||||
a new pattern for the codebase.
|
||||
|
||||
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
|
||||
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
|
||||
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
|
||||
unusably thin sliver.
|
||||
|
||||
### 3. Trigger UI
|
||||
|
||||
A **"Split"** button (header, opt-in like the other header buttons —
|
||||
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
|
||||
opens a small picker listing your other open sessions (reuses
|
||||
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
|
||||
session — you cannot split a session against itself). Picking one:
|
||||
1. Creates the split container, reparents `.terminal-wrap`
|
||||
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
|
||||
3. Button state flips to "close split" (or Pane B's own header × does it)
|
||||
|
||||
Closing (via Pane B's × or the header button toggling off):
|
||||
1. `SplitTerminalPane.destroy()`
|
||||
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
|
||||
its original location at 100% width
|
||||
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
|
||||
place today — no new code needed here, it fires naturally once the
|
||||
container's size changes)
|
||||
|
||||
v2 note (not designed): dragging a session tab onto the active pane as an
|
||||
alternate trigger. You confirmed right-click doesn't work today (Codeman
|
||||
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
|
||||
|
||||
### 4. Failure / edge cases
|
||||
|
||||
- **The Pane B session ends or is deleted while split is active** → treat
|
||||
identically to the user closing Pane B manually: destroy the pane, collapse
|
||||
to Pane A at full width.
|
||||
- **The Pane A session ends while split is active** → Pane B is promoted:
|
||||
it becomes the new single full-width pane (reusing today's normal
|
||||
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
|
||||
a real `this.terminal`/`this._ws` binding — simplest correct approach is
|
||||
to just collapse the split and let normal session-select logic reopen
|
||||
Pane B's session as the new primary, rather than trying to promote the
|
||||
lightweight pane object in place).
|
||||
- **Both end** → falls through to today's normal "no active session" /
|
||||
welcome-screen state.
|
||||
- **Subagent/Ultracode floating windows** → no design work needed; they're
|
||||
already positioned independent of `.terminal-wrap`'s layout, so they
|
||||
continue to float over whichever pane(s) are on screen, unconstrained,
|
||||
exactly as today.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
|
||||
existing terminal tests use `TEST_PTY_SCRIPT`).
|
||||
- Route/integration: opening two WS connections to two different sessions
|
||||
from one simulated client concurrently — confirms the existing per-session
|
||||
cap and connection registry need no changes.
|
||||
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
|
||||
UI): open split via button+picker, verify both panes render live output
|
||||
independently, drag divider and confirm both refit, close Pane B and
|
||||
confirm Pane A returns to full width, kill the Pane B session externally
|
||||
and confirm auto-collapse.
|
||||
|
||||
## Open questions for review
|
||||
|
||||
None blocking — the scope-narrowing decisions above (Pane B feature parity,
|
||||
no persistence, side-by-side only, button+picker trigger) came directly from
|
||||
your answers during brainstorming. Flag anything here you want reconsidered.
|
||||
@@ -50,7 +50,13 @@ pointed at.
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default.
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
|
||||
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
|
||||
|
||||
A row of keys above the virtual keyboard, and what it contains depends on the session.
|
||||
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
|
||||
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
|
||||
double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
|
||||
shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
|
||||
message and walking the prompt stack.
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
|
||||
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
|
||||
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
|
||||
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
|
||||
there), and dismiss. Your normal preference is remembered and restored when you
|
||||
switch back to an agent session, so a settings change during a shell session cannot strip
|
||||
the bar away permanently.
|
||||
|
||||
|
||||
@@ -55,12 +55,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
|
||||
@@ -116,6 +116,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -6851,6 +6851,7 @@ class CodemanApp {
|
||||
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
|
||||
// Drop any durably-queued input for a session that's actually gone (deleted/
|
||||
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
||||
// real session removal, never on a tab switch.
|
||||
|
||||
@@ -1516,8 +1516,57 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||||
// touch handlers. A dedicated constant rather than reusing
|
||||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||||
// from module-evaluation-time code here would be a ReferenceError.
|
||||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||||
|
||||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||||
if (rawPercent < min) return min;
|
||||
if (rawPercent > max) return max;
|
||||
return rawPercent;
|
||||
}
|
||||
|
||||
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,10 @@
|
||||
'File Viewer': '文件查看器',
|
||||
'Open file viewer': '打开文件查看器',
|
||||
'Open Codeman across all displays': '在所有显示器上打开 {name}',
|
||||
'Split: open a second session beside this one': '分屏:在旁边打开第二个会话',
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
@@ -312,6 +316,8 @@
|
||||
'运行菜单选择器会为此端点应用该模型。请先发现可用模型。',
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'Last used': '上次使用',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
'Subagent Options': '子智能体选项',
|
||||
@@ -445,6 +451,17 @@
|
||||
'Show Shortcuts': '显示快捷键',
|
||||
'Full shortcut reference': '完整快捷键参考',
|
||||
|
||||
// Mobile prompt composer (keyboard-accessory.js). The textarea's own
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
'Write your prompt…': '请输入提示词…',
|
||||
'Use terminal keyboard': '使用终端键盘',
|
||||
'Uploading…': '上传中…',
|
||||
|
||||
// Mobile overview (phone home screen)
|
||||
'Needs you': '需要你',
|
||||
'Current sessions': '当前会话',
|
||||
|
||||
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
/** Upload a batch and normally insert its paths into the active terminal.
|
||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||
* into its textarea instead. Returns successful paths in selection order. */
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
@@ -1924,6 +1925,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
@@ -3719,6 +3721,7 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
|
||||
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
||||
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
||||
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
|
||||
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
|
||||
* dialog because shell input is not an agent prompt.
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
@@ -635,6 +636,16 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
@@ -648,8 +659,15 @@ const KeyboardAccessoryBar = {
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
|
||||
// so persistence would need the same treatment as the 0600 intent store.
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
@@ -665,10 +683,10 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||
@@ -740,10 +758,10 @@ const KeyboardAccessoryBar = {
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
@@ -781,6 +799,7 @@ const KeyboardAccessoryBar = {
|
||||
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
||||
// session's mode instead of a setting.
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -823,8 +842,13 @@ const KeyboardAccessoryBar = {
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
const activeSessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== activeSessionId) {
|
||||
this._composerOverlay._closeComposer?.({ restoreFocus: false });
|
||||
}
|
||||
this._applyLayout(this._resolveMode());
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
@@ -852,6 +876,20 @@ const KeyboardAccessoryBar = {
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Show when the active session has a prompt parked in memory. The marker
|
||||
* keeps non-Send closes visible without copying the draft back into the PTY
|
||||
* and creating a second source of truth. */
|
||||
_syncComposerDraftIndicator() {
|
||||
const button = this.element?.querySelector('[data-action="compose"]');
|
||||
if (!button) return;
|
||||
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
|
||||
button.classList.toggle('has-draft', hasDraft);
|
||||
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
|
||||
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
@@ -977,6 +1015,9 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'compose':
|
||||
this.composePrompt();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
@@ -1130,6 +1171,230 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Move the whole editable terminal prompt into the composer. Pending text
|
||||
* exists only in the overlay; flushed text already reached the PTY, so erase
|
||||
* that prefix before making the textarea authoritative. */
|
||||
_takePendingLocalEcho(sessionId) {
|
||||
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
|
||||
const pending = app._localEchoOverlay.pendingText || '';
|
||||
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
|
||||
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
|
||||
const flushedLength = Array.from(flushedText).length;
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(sessionId);
|
||||
app._flushedTexts?.delete(sessionId);
|
||||
if (flushedLength > 0) {
|
||||
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedLength), { useMux: true });
|
||||
}
|
||||
return flushedText + pending;
|
||||
},
|
||||
|
||||
/** Insert text at the textarea selection, adding one separating space when
|
||||
* an attachment path would otherwise run into neighboring prompt text. */
|
||||
_insertComposerText(textarea, text) {
|
||||
if (!textarea || !text) return;
|
||||
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
|
||||
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
|
||||
const before = textarea.value.slice(0, start);
|
||||
const after = textarea.value.slice(end);
|
||||
const prefix = before && !/\s$/.test(before) ? ' ' : '';
|
||||
const suffix = after && !/^\s/.test(after) ? ' ' : '';
|
||||
const inserted = `${prefix}${text}${suffix}`;
|
||||
textarea.setRangeText(inserted, start, end, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Forget a draft when its target session no longer exists. */
|
||||
discardComposerDraft(sessionId) {
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._composerUploads.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
||||
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** Open the manual agent prompt composer. Enter remains a newline; only the
|
||||
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
|
||||
composePrompt() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
if (this._isShellSession()) {
|
||||
this.pasteFromClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay prompt-composer-overlay';
|
||||
overlay.dataset.sessionId = sessionId;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Compose prompt');
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog prompt-composer-dialog">
|
||||
<div class="prompt-composer-header">
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">🖼 Image</button>
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
if (typeof i18n?.t === 'function') {
|
||||
textarea.placeholder = i18n.t('Write your prompt…');
|
||||
textarea.setAttribute('aria-label', i18n.t('Prompt'));
|
||||
}
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
this._syncComposerDraftIndicator();
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
||||
else this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
};
|
||||
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
||||
if (preserveDraft) saveDraft();
|
||||
focusTrap.deactivate({ restoreFocus });
|
||||
overlay.remove();
|
||||
if (this._composerOverlay === overlay) this._composerOverlay = null;
|
||||
if (focusTerminal) app.terminal?.focus();
|
||||
};
|
||||
overlay._closeComposer = close;
|
||||
const send = () => {
|
||||
// Whitespace-only counts as empty (it would submit blank lines), but the
|
||||
// text goes out untrimmed so deliberate leading or trailing lines survive.
|
||||
const text = textarea.value;
|
||||
if (!text.trim() || !this._sendComposedPrompt(sessionId, text)) return;
|
||||
app._echoPassthroughSessions?.delete(sessionId);
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
const syncUploadUi = () => {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const count = this._composerUploads.get(sessionId) || 0;
|
||||
const currentImageButton = currentOverlay?.querySelector('.paste-image');
|
||||
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||
if (currentImageButton) {
|
||||
currentImageButton.disabled = count > 0;
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
const paths = await app._uploadAndInsertImages(images, { insert: false });
|
||||
if (paths?.length) {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
|
||||
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
|
||||
else if (app.sessions?.has(sessionId)) {
|
||||
const draft = this._composerDrafts.get(sessionId) || '';
|
||||
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
||||
this._syncComposerDraftIndicator();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
textarea.addEventListener('input', saveDraft);
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
focusTrap.activate();
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
@@ -1259,9 +1524,9 @@ class FocusTrap {
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
deactivate({ restoreFocus = true } = {}) {
|
||||
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
||||
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
this.previouslyFocused.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,11 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-approvals {
|
||||
display: none !important;
|
||||
}
|
||||
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
|
||||
wider `@media (max-width: 1179px)` rule in styles.css — this file only
|
||||
loads up to 1023px, which does not cover the 1024-1179px tablet range
|
||||
the split feature also needs to stay off (two 240px min-width panes plus
|
||||
the divider need ~486px; the divider also has no touch handlers). */
|
||||
|
||||
/* Read My Mind 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
|
||||
+281
-454
@@ -11,6 +11,119 @@
|
||||
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
*
|
||||
* Deliberately a LOCAL table rather than a server-injected catalogue: several
|
||||
* unit tests exercise these run*() methods inside a bare `vm.createContext()`
|
||||
* sandbox with no `window` global at all (see test/run-mode-ui.test.ts) —
|
||||
* referencing `window` there unguarded would throw, not degrade. `buildConfig`
|
||||
* returns the CLI's top-level legacy config field for a LOCAL launch, or
|
||||
* `null` for a CLI that sends none (pi: no bypass flag exists, so there is
|
||||
* nothing to send — see runPi's own history below for why that must stay
|
||||
* true).
|
||||
*/
|
||||
const RUN_MODE_LAUNCH = {
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }),
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: (globalSettings) => ({
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
}),
|
||||
},
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }),
|
||||
},
|
||||
antigravity: {
|
||||
label: 'Antigravity',
|
||||
installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||
// antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md
|
||||
// calls it `unsupported`) — never fold a pending pick into its launch body.
|
||||
supportsCustomModel: false,
|
||||
buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }),
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
supportsCustomModel: true,
|
||||
// Deliberately NO piConfig: pi has no permission prompts, so there is no
|
||||
// bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
// decision (an interactive prompt the user answers in the terminal).
|
||||
// Sending `approveProjectTrust: true` here would silently opt every
|
||||
// browser-launched pi session into executing repo-supplied TypeScript.
|
||||
buildConfig: () => null,
|
||||
},
|
||||
omp: {
|
||||
label: 'OMP',
|
||||
installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => null,
|
||||
},
|
||||
grok: {
|
||||
label: 'Grok',
|
||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
supportsCustomModel: true,
|
||||
// Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends
|
||||
// `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
// work, so the Run button opts into grok's bypassPermissions mode
|
||||
// (`--always-approve`; config-level deny rules still apply on top). The
|
||||
// multi-user clamp forces it back off for non-granted owners server-side.
|
||||
buildConfig: () => ({ grokConfig: { alwaysApprove: true } }),
|
||||
},
|
||||
deepseek: {
|
||||
label: 'DeepSeek',
|
||||
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
|
||||
// The two-part availability check is deliberate. `dsh` being installed is
|
||||
// not enough — DeepSeek ships no terminal front door, so a box can have a
|
||||
// perfect binary and nothing a pane can run.
|
||||
unrunnableHint:
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui',
|
||||
supportsCustomModel: true,
|
||||
// Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
// sibling Run button sends its bypass switch. The harness has no bypass
|
||||
// FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the
|
||||
// multi-user clamp forces it back down to `workspace-write` server-side.
|
||||
//
|
||||
// `statusReporting` is deliberately LEFT UNSET, i.e. ON: it is what upgrades
|
||||
// this mode from output-stabilization guessing to definitive idle/blocked
|
||||
// hook events (the harness reports to Codeman as its supervisor, see
|
||||
// deepseek-status-shim.ts). Never send `statusReporting: false` from here.
|
||||
buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH
|
||||
* above, kept as its own Set (`EXTERNAL_CLI_MODES.has(mode)`) rather than an
|
||||
* array recomputed per call. Single source for what used to be two hand-copied
|
||||
* 8-way `session.mode === '<id>' || ...` chains inside one function
|
||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||
* ninth CLI landed in one and not the other.
|
||||
*/
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Build envOverrides payload from case + global settings.
|
||||
@@ -395,34 +508,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return await this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return await this.runCodex();
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
if (mode === 'grok') {
|
||||
return await this.runGrok();
|
||||
}
|
||||
if (mode === 'deepseek') {
|
||||
return await this.runDeepSeek();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
return await this.runClaude();
|
||||
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||
return await this.runClaude();
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
@@ -633,14 +725,101 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (models.length === 1) {
|
||||
return this.runCustomModelEntry(mode, endpointId, models[0]);
|
||||
}
|
||||
this._openCustomModelPickModal(mode, host);
|
||||
await this._openCustomModelPickModal(mode, host);
|
||||
},
|
||||
|
||||
/** Renders the "which model" picker for a (harness, endpoint) pair with more than one discovered model. */
|
||||
_openCustomModelPickModal(mode, host) {
|
||||
/** localStorage key for the last model launched on a given (harness, endpoint) pair — per-device by design, like every other `codeman:*` UI preference, never synced. */
|
||||
_customModelLastUsedKey(mode, endpointId) {
|
||||
return `codeman:customModelLastUsed:${mode}:${endpointId}`;
|
||||
},
|
||||
|
||||
/** Reads the last model chosen for this (harness, endpoint) pair, or null. Never throws — a blocked/full localStorage just means no promotion, not a broken picker. */
|
||||
_getCustomModelLastUsed(mode, endpointId) {
|
||||
try {
|
||||
return localStorage.getItem(this._customModelLastUsedKey(mode, endpointId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Remembers `modelId` as the last one launched for this (harness, endpoint) pair. */
|
||||
_setCustomModelLastUsed(mode, endpointId, modelId) {
|
||||
try {
|
||||
localStorage.setItem(this._customModelLastUsedKey(mode, endpointId), modelId);
|
||||
} catch {
|
||||
// best-effort — losing the "last used" hint is cosmetic, never worth surfacing
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
|
||||
* endpoint, so the picker can offer it first instead of making the user remember what
|
||||
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
|
||||
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
|
||||
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
|
||||
* never throws, since a failed probe should just skip promotion, not break the picker.
|
||||
*
|
||||
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
|
||||
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
|
||||
* a saved endpoint keeps its discovered models cached, so "the box behind this endpoint
|
||||
* is asleep or firewalled" is a normal way to reach this path, not an exotic one, and
|
||||
* the modal must not sit invisible (Run menu already closed, nothing else on screen)
|
||||
* for the full 5s a slow/dead endpoint can take. The losing side of the race is left to
|
||||
* resolve on its own — `.catch(() => null)` only stops an unhandled-rejection warning
|
||||
* when it eventually fails, it never cancels the in-flight fetch.
|
||||
*
|
||||
* `timeoutMs` exists to let a test drive this in milliseconds instead of the real
|
||||
* 800 — same reasoning as `_watchLlamaSwapLoading`'s own `pollIntervalMs`: this code
|
||||
* runs inside a JSDOM window's own realm, whose `setTimeout` is not the one
|
||||
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
|
||||
* actually waiting on it. Real callers never pass it.
|
||||
*/
|
||||
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
|
||||
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
|
||||
() => null
|
||||
);
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
||||
const status = await Promise.race([probe, timeout]);
|
||||
if (!status?.isLlamaSwap) return null;
|
||||
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
|
||||
return ready?.model || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
|
||||
* discovered model. Async since it now awaits the currently-loaded-model probe below,
|
||||
* so a SECOND call (a different custom-model entry clicked while the first one's probe
|
||||
* is still in flight — the probe has its own 5s timeout) must not let the first call's
|
||||
* later-arriving response clobber the second's already-rendered, already-correct modal.
|
||||
* `_customModelPickGeneration` is the same guard-a-mutable-counter pattern
|
||||
* `_watchLlamaSwapLoading` uses for the same reason: every DOM write below, including
|
||||
* `_pendingCustomModelPick` itself, stays deferred until after the await, and a call
|
||||
* that finds a newer generation already claimed bails out untouched rather than only
|
||||
* skipping the model-list write and leaving title/hint/`_pendingCustomModelPick`
|
||||
* inconsistent with what's on screen.
|
||||
*/
|
||||
async _openCustomModelPickModal(mode, host) {
|
||||
const modal = document.getElementById('customModelPickModal');
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!modal || !list) return;
|
||||
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
|
||||
const isCurrent = () => this._customModelPickGeneration === generation;
|
||||
|
||||
// Whichever model llama-swap actually has loaded right now beats a merely
|
||||
// remembered choice — it's what a launch would attach to with zero wait, while
|
||||
// "last used" might have been swapped out by another session since. Neither
|
||||
// reorders past the top: exactly one model is promoted, everything else keeps
|
||||
// its discovery order.
|
||||
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
|
||||
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
|
||||
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || lastUsed;
|
||||
const models = [...(host.models || [])];
|
||||
if (promoted && models.includes(promoted)) {
|
||||
models.splice(models.indexOf(promoted), 1);
|
||||
models.unshift(promoted);
|
||||
}
|
||||
|
||||
this._pendingCustomModelPick = { mode, endpointId: host.id };
|
||||
const cliLabel = (window.__codemanCustomModelClis || []).find((c) => c.id === mode)?.label || mode;
|
||||
// A static title (translatable by i18n.js's exact-string walker) plus a
|
||||
@@ -649,13 +828,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
||||
document.getElementById('customModelPickHint').textContent =
|
||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||
list.innerHTML = (host.models || [])
|
||||
list.innerHTML = models
|
||||
.map((m) => {
|
||||
const isDefault = m === host.defaultModelId;
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||
// promoted model IS the default more often than not. One slot holding whichever
|
||||
// applied first silently dropped the Default marking for exactly that row.
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${isDefault ? ' <span class="set-scope">Default</span>' : ''}
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
@@ -771,6 +956,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* (Codex, confirmed live) than on claude's own `--resume`-based restart.
|
||||
*/
|
||||
async runCustomModelEntry(mode, endpointId, modelId) {
|
||||
// "Last used" is recorded by each path itself, ONLY once the model is actually
|
||||
// applied — never here, unconditionally, on the mere attempt. A context-window
|
||||
// warning or a swap-conflict question can still say no after this call, and the
|
||||
// context-warning case is the one that bites: declining it means this exact
|
||||
// model cannot work with this CLI at all, so promoting it as "Last used" next
|
||||
// time the picker opens would be actively wrong, not just premature.
|
||||
if (mode === 'claude') {
|
||||
return this._runCustomModelEntryViaRestart(mode, endpointId, modelId);
|
||||
}
|
||||
@@ -863,7 +1054,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
answered = { ...answered, confirmedSwap: true };
|
||||
data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, ...answered } });
|
||||
}
|
||||
this._lastCustomModelLaunchResult = data?.success !== false ? data?.data : undefined;
|
||||
const launched = data?.success !== false;
|
||||
this._lastCustomModelLaunchResult = launched ? data?.data : undefined;
|
||||
// Only once actually launched, and only for a call that carried a custom-model pick
|
||||
// at all — `_launchQuickStartInstances` runs every quick-start body (custom-model or
|
||||
// not) through this same function, so a plain launch must not fall through here with
|
||||
// an undefined endpointId/modelId that quietly no-ops the (mode, endpointId) key.
|
||||
if (launched && bodyObj.customModel) {
|
||||
this._setCustomModelLastUsed(bodyObj.mode, bodyObj.customModel.endpointId, bodyObj.customModel.modelId);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -991,6 +1190,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// The apply has actually succeeded and both questions (if asked) are answered
|
||||
// yes — only now is this a real "last used" for the picker's next open, not
|
||||
// before either confirmation had a chance to decline it.
|
||||
this._setCustomModelLastUsed(mode, endpointId, modelId);
|
||||
|
||||
// The apply above already succeeded — the session IS pointed at the endpoint — but
|
||||
// llama-swap itself may still be unloading the old model and loading this one, which
|
||||
// can take well over a minute. Without this, a prompt sent during that window either
|
||||
@@ -1917,110 +2121,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
return firstSessionId;
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
/**
|
||||
* Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except
|
||||
* claude/shell, which have their own flows — claude for its remote/docker
|
||||
* branching and parallel-create path, shell for needing no CLI probe at
|
||||
* all). The eight run<Mode>() methods below are thin named wrappers: their
|
||||
* names stay because index.html's welcome-screen buttons and the run-mode
|
||||
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
const entry = RUN_MODE_LAUNCH[mode];
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
// rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const statusRes = await fetch(`/api/${mode}/status`);
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OpenCode',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Codex session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
if (entry.unrunnableHint && !status.runnable) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
// No `effort` field for ANY entry in RUN_MODE_LAUNCH: effort is
|
||||
// Claude-specific (runClaude() alone sends it, and the backend turns it
|
||||
// into `claude --settings`); none of these CLIs has an /effort. Each of
|
||||
// the eight bodies this launcher replaced carried that rule as a comment.
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Codex',
|
||||
entry.label,
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
mode,
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
...(entry.supportsCustomModel && this._pendingCustomModelForLaunch
|
||||
? { customModel: this._pendingCustomModelForLaunch }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
@@ -2038,368 +2197,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
return this._runCliMode('opencode');
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
return this._runCliMode('codex');
|
||||
},
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Gemini',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('gemini');
|
||||
},
|
||||
|
||||
async runAntigravity() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Antigravity',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'antigravity',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('antigravity');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Pi (pi.dev) session.
|
||||
*
|
||||
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
|
||||
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
* decision (an interactive prompt the user answers in the terminal). Sending
|
||||
* `approveProjectTrust: true` here would silently opt every browser-launched pi
|
||||
* session into executing repo-supplied TypeScript.
|
||||
*/
|
||||
async runPi() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Pi session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/pi/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Pi',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'pi',
|
||||
sessionName,
|
||||
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
|
||||
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('pi');
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OMP session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OMP',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('omp');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
||||
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
* work, so the Run button opts into grok's bypassPermissions mode
|
||||
* (`--always-approve`; config-level deny rules still apply on top). The
|
||||
* multi-user clamp forces it back off for non-granted owners server-side.
|
||||
*/
|
||||
async runGrok() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Grok session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/grok/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Grok',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'grok',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
grokConfig: { alwaysApprove: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('grok');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a DeepSeek Harness (`dsh`) session.
|
||||
*
|
||||
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
||||
* autonomous work. The harness has no bypass FLAG, so this rides the
|
||||
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
||||
* back down to `workspace-write` for non-granted owners server-side.
|
||||
*
|
||||
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
||||
* output-stabilization guessing to definitive idle/blocked hook events.
|
||||
*
|
||||
* The two-part availability check is deliberate. `dsh` being installed is not
|
||||
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
||||
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
||||
* command that fixes it, is the difference between "the Run button is broken"
|
||||
* and a 30-second fix.
|
||||
*/
|
||||
async runDeepSeek() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/deepseek/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!status.runnable) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'DeepSeek',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'deepseek',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('deepseek');
|
||||
},
|
||||
|
||||
|
||||
@@ -2467,7 +2294,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -2497,7 +2324,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isExternalCli = isAltMode;
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
|
||||
@@ -407,6 +407,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -2120,6 +2121,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2350,6 +2352,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
// Desktop-only header button, per-device, and absent from
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
showSplitButton: _ssp,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -2742,6 +2748,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -2947,6 +2954,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Split button — hidden by default, and hard-gated to desktop widths
|
||||
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
|
||||
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
|
||||
// pattern — the CSS in styles.css is the backstop, this is the check).
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
this._applySplitButtonVisibility?.(showSplitButton);
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -3363,6 +3377,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -675,6 +675,12 @@ body.tab-rail-resizing * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-pane-resizing,
|
||||
body.split-pane-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tab-rail,
|
||||
.tab-rail-resize-handle {
|
||||
@@ -2282,6 +2288,39 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Split-pane header button: hidden by default (opt-in via App Settings →
|
||||
Header & Panels → Header buttons → Split, `showSplitButton`). Pure
|
||||
client-side toggle applied by applyHeaderVisibilitySettings(). */
|
||||
.btn-split--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
for assistive tech). */
|
||||
.btn-icon-header.btn-split.split-open {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-split.split-open:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
|
||||
two 240px min-width panes plus the divider need ~486px, the divider has no
|
||||
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
|
||||
the 1024-1179px tablet range on its own. Same threshold and reasoning as
|
||||
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
|
||||
(constants.js) — keep the three in sync. */
|
||||
@media (max-width: 1179px) {
|
||||
.btn-icon-header.btn-split {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2547,6 +2586,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-split,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
@@ -6829,6 +6869,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
|
||||
settings surface's .set-scope pill, but that pill is styled only inside
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
|
||||
settings-surface block), so in here it rendered as plain body text and
|
||||
"qwen3 Currently loaded" read as one model name. Same pill, same skin
|
||||
tokens (never a hardcoded colour: --text-muted / --border are what each
|
||||
html[data-skin] block redefines), and nothing about the modal's layout or
|
||||
z-index. The row is a flex container, so the pills sit after the name
|
||||
with the row's own gap and the trailing whitespace collapses. */
|
||||
#customModelPickModal .set-scope {
|
||||
font-size: 0.52rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
@@ -13613,6 +13674,14 @@ body.touch-device.cjk-input-visible .main {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.accessory-btn-compose.has-draft::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--yellow);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -13733,6 +13802,64 @@ body.touch-device.cjk-input-visible .main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prompt-composer-overlay {
|
||||
overflow-y: auto;
|
||||
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.prompt-composer-dialog {
|
||||
max-width: 560px;
|
||||
/* The overlay's top inset and bottom gutter, plus the strip a folding device
|
||||
reserves below the dialog (0px everywhere else; see the fold rules at the
|
||||
end of this file). */
|
||||
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom) - var(--fold-block-end));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prompt-composer-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 2px 2px 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prompt-composer-header span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-composer-textarea {
|
||||
min-height: min(34dvh, 240px);
|
||||
max-height: 50dvh;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prompt-composer-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prompt-composer-actions button {
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.prompt-composer-terminal {
|
||||
flex: 1 0 100%;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-composer-terminal:active {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||
.path-input-group {
|
||||
display: flex;
|
||||
@@ -15299,6 +15426,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
.main.webview-active .webview-layer { display: flex; }
|
||||
.main.webview-active .terminal-wrap { display: none; }
|
||||
/* A split (Pane A + divider + Pane B) hides as one unit when a web tab is
|
||||
active, mirroring the .terminal-wrap rule above — .terminal-wrap is
|
||||
reparented INSIDE .terminal-split-container while a split is open, so
|
||||
hiding only .terminal-wrap would leave Pane B and the divider stranded on
|
||||
screen over the dashboard iframe. No state is destroyed, so returning to
|
||||
the session tab shows the split intact. */
|
||||
.main.webview-active .terminal-split-container { display: none; }
|
||||
|
||||
.webview-frame {
|
||||
display: none;
|
||||
@@ -18552,6 +18686,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
/* The mobile prompt composer is a .paste-overlay with a gutter of its own: a
|
||||
three-value `padding` shorthand whose bottom is 12px plus the safe area. The
|
||||
generic rule above is a later longhand at the same specificity, so it ERASED
|
||||
that gutter (measured at 393x852: padding-bottom 0 instead of 12px, and with
|
||||
the fold variables set the hinge strip stood in for the gutter instead of
|
||||
adding to it). Restate the composer's bottom gutter on top of the strip; the
|
||||
side has no gutter of its own. The dialog's height cap subtracts the same
|
||||
strip where it is declared (.prompt-composer-dialog). */
|
||||
.prompt-composer-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom) + var(--fold-block-end));
|
||||
}
|
||||
|
||||
/* The response viewer is a bottom sheet, so a vertical hinge running through it
|
||||
is fine, since it is a wide surface like the terminal and inset dialogs are what
|
||||
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
|
||||
@@ -18565,3 +18712,128 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
/* Split-Pane Sessions: container inserted only while a split is active.
|
||||
.terminal-wrap (Pane A) is reparented into this as the first child; it
|
||||
keeps every existing rule unchanged since nothing here restyles it. */
|
||||
.terminal-split-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-split-container > .terminal-wrap {
|
||||
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
|
||||
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
|
||||
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
|
||||
.main's overflow clips Pane B's right edge by exactly the divider's
|
||||
width. Shrinking lets the two panes give up that 6px between them. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex: 0 0 6px;
|
||||
cursor: col-resize;
|
||||
background: var(--border-color, #333);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.terminal-pane-b {
|
||||
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-pane-b-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close {
|
||||
/* A native <button> now backs this (keyboard-reachable close), so reset its
|
||||
default chrome back to the plain glyph this rule always drew. */
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-pane-b-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Split-picker menu: a small popover listing sessions to split with, appended
|
||||
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
||||
top/right against the Split header button's own rect). z-index above the
|
||||
header (100) with headroom to spare, matching the sibling .run-mode-menu
|
||||
dropdown's 1000. Dismissed by openSplitPane()'s own inline onclick, or by
|
||||
the click-outside/Escape listeners installed alongside it. */
|
||||
.split-picker-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
padding: 4px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.split-picker-item {
|
||||
/* A native <button> now backs each row (keyboard-reachable picker), so
|
||||
reset its default chrome back to the plain list-row look this always
|
||||
drew — full-width, left-aligned, no border/background of its own. */
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-picker-item:hover {
|
||||
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.split-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return Promise.resolve();
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||
// across the whole replay rather than just the fetch that precedes it.
|
||||
return new Promise((resolve) => {
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
});
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Pane B has no gates of its own by default, so every app-level chord
|
||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||
// registry-aware predicates so a rebind or a disable restores plain
|
||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
||||
// to, so intercepting it here would only break paste.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||
// swallow — in a plain shell session this is the user's own
|
||||
// job-control tool and must reach the PTY, but in every other mode
|
||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||
// loop dead. Pane B has its own PTY/session and must not send a
|
||||
// suspend into a non-shell one just because the primary pane's own
|
||||
// gate lives elsewhere.
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey &&
|
||||
this.sessionMode !== 'shell'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||
// newline from a submit. Without this gate, Pane B's onData would
|
||||
// send that bare \r straight over the WS and submit an incomplete
|
||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
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. */
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||
// explicit, never-falls-through copy chord, and the predicate above
|
||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||
// a branch that assigns c.key only for a couple of special cases
|
||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||
// interrupt byte reaching the PTY (verified live: it does not).
|
||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||
// Re-implemented against this.terminal rather than reusing
|
||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||
// and would copy the wrong pane's selection.
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
this.terminal?.clearSelection?.();
|
||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying — clear for feedback (a padding-only
|
||||
// selection cleans to '' and this press still falls through to the
|
||||
// PTY as 0x03, matching the primary pane's own rule).
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
global.app.showToast?.('Nothing to copy', 'warning');
|
||||
}
|
||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load existing scrollback before going live. The WS below is
|
||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||
// 'terminal' events), so without this Pane B stays blank until the
|
||||
// target session happens to produce new output. It LOOKED
|
||||
// intermittent rather than always-broken because _sendResize() below
|
||||
// often nudges the shared session's real tmux window to a new size,
|
||||
// and tmux repaints its current screen on resize — that repaint was
|
||||
// getting captured and streamed here, incidentally populating the
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
// The await covers the whole chunked replay, not just the fetch, so a
|
||||
// live frame from the socket below can never land in the middle of it.
|
||||
await this._loadBuffer();
|
||||
if (this._destroyed) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._wsReady = true;
|
||||
this._sendResize();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
||||
// buffer loader for the same reason connect() does.
|
||||
this._refreshBuffer();
|
||||
}
|
||||
} catch {
|
||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
||||
// closed socket per the WebSocket spec (no exception, no log). No
|
||||
// reconnect logic here — Pane B is deliberately plainer than the
|
||||
// primary pane (see the fileoverview above); a drop just stops
|
||||
// resizing until the parent recreates the pane. But onData already
|
||||
// silently drops keystrokes while _wsReady is false (below), so
|
||||
// without a visible marker a dropped socket left Pane B looking
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||
// terminal, neither of which applies to this independent pane, so this is
|
||||
// a standalone equivalent rather than a call into it.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||
// get one full replay. `fetch` here goes through the global wrapper
|
||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||
// WebSocket URL above, which does not.
|
||||
//
|
||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||
// (writeChunked resolves after its last chunk), so two replays can never
|
||||
// interleave their chunks into one terminal. A second call while one is
|
||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||
// a trailing re-run instead.
|
||||
async _loadBuffer() {
|
||||
if (this._bufferLoading) return;
|
||||
this._bufferLoading = true;
|
||||
try {
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
}
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||
// and no further frame is coming to correct stale content.
|
||||
_refreshBuffer() {
|
||||
if (this._bufferLoading) {
|
||||
this._bufferRefreshPending = true;
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
||||
// divider's own reachable 20% floor position, causing real
|
||||
// output-wrapping bugs.
|
||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
// onclose fires asynchronously AFTER close(); without this it ran
|
||||
// its "disconnected" write against a pane already torn down.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
this.fitAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
global.SplitTerminalPane = SplitTerminalPane;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
* + matchMedia backstop: a JS width check (so openSplitPane() below can
|
||||
* refuse even if a click somehow reaches the button) plus a live listener,
|
||||
* because a window narrowed WHILE the button is showing must hide it
|
||||
* without waiting for a settings save or reload. The CSS `@media
|
||||
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
|
||||
* direction: it hides the button even if this JS never runs at all.
|
||||
*/
|
||||
_applySplitButtonVisibility(enabled) {
|
||||
this._splitButtonSettingEnabled = enabled;
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||
// Narrowing past the gate must not leave an open split on screen with no
|
||||
// way to reach the button that would close it — the two 240px min-widths
|
||||
// plus the divider overflow a narrow window and .main clips Pane B's edge.
|
||||
if (!wide && this._splitPane) this.closeSplitPane();
|
||||
if (!splitBtn) return;
|
||||
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
|
||||
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
|
||||
this._splitButtonWidthListenerInstalled = true;
|
||||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||||
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPicker(event) {
|
||||
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
||||
// OPENING click so it never reaches the outside-click listener this
|
||||
// same call is about to register — without it, a click landing on the
|
||||
// button's own inner <svg> (matched by neither `menu.contains()` nor
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
}
|
||||
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
||||
this.sessions,
|
||||
this.sessionOrder,
|
||||
this.activeSessionId,
|
||||
this.detachedSessions
|
||||
);
|
||||
// Route a pre-existing menu through the SAME dismiss path used
|
||||
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
|
||||
// menu has live document listeners (see below), and a raw removal left
|
||||
// them attached forever — only the single-slot field below got
|
||||
// overwritten, so every prior pair but the last was orphaned on
|
||||
// `document` with no way to ever find and remove it again.
|
||||
this._dismissSplitPicker();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'splitPickerMenu';
|
||||
menu.className = 'split-picker-menu';
|
||||
if (candidates.length === 0) {
|
||||
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
||||
} else {
|
||||
menu.innerHTML = candidates
|
||||
.map(
|
||||
(c) =>
|
||||
// data-i18n-skip: the whole row's text IS a session name — i18n.js
|
||||
// does exact-string lookup over text nodes, and a session
|
||||
// literally named e.g. "Sessions" would otherwise get translated
|
||||
// on zh-CN (see the .session-name skip on the pane header below).
|
||||
`<button type="button" class="split-picker-item" data-i18n-skip onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
if (splitBtn) {
|
||||
const rect = splitBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
// Dismiss on outside click or Escape — same one-shot listener pattern as
|
||||
// session-ui.js's other transient popovers (toggleCaseSettings(),
|
||||
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
|
||||
// menu (still bubbling) doesn't immediately close it — reinforced by
|
||||
// the button's own stopPropagation() above, which is what actually
|
||||
// stops that same click reaching `document` at all. Picking an item
|
||||
// (above) calls the SAME dismiss method, so these listeners never
|
||||
// outlive the menu either way.
|
||||
//
|
||||
// Self-removing by identity: each handler removes ITSELF (and its
|
||||
// sibling) the moment it fires, rather than leaning solely on the
|
||||
// `this._splitPickerDismissHandlers` field. That field is still kept in
|
||||
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
|
||||
// item's onclick above, or a still-open menu at the top of this method
|
||||
// — can find and remove the CURRENT pair), but no path here can ever
|
||||
// again leave a pair attached to `document` with nothing referencing it.
|
||||
const closeOnOutsideClick = (e) => {
|
||||
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
|
||||
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
},
|
||||
|
||||
_dismissSplitPicker() {
|
||||
document.getElementById('splitPickerMenu')?.remove();
|
||||
if (this._splitPickerDismissHandlers) {
|
||||
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPane(sessionId) {
|
||||
// Desktop-only hard gate, independent of the button's own hidden state —
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
// behind the opaque overlay with nothing visible to show for it.
|
||||
if (!this.activeSessionId) return;
|
||||
// A web tab hides `.terminal-wrap`'s container via CSS with nothing
|
||||
// gating the button itself, and `activeSessionId` survives openWebview()
|
||||
// — without this, picking a session opens Pane B's socket behind a
|
||||
// hidden container with nothing on screen to show for it.
|
||||
if (this.activeWebviewId) return;
|
||||
// A stale picker click (opened before switching tabs) or clicking Pane
|
||||
// B's own session tab while split can otherwise land here with
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
||||
if (sessionId === this.activeSessionId) return;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// re-applied here: the menu can sit open while a listed session's CLI
|
||||
// exits (pid → null) or gets popped out to its own window, and nothing
|
||||
// re-runs the picker filter for a row that already rendered. Same
|
||||
// outcome as the picker gives such a session (not offered, silently):
|
||||
// one with no PTY has nothing reading its pane, so Pane B would show
|
||||
// nothing and drop every keystroke behind a healthy-looking socket, and
|
||||
// a detached session's own window already owns its PTY size.
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.pid === null) return;
|
||||
if (this.detachedSessions?.has?.(sessionId)) return;
|
||||
if (this._splitPane) this.closeSplitPane();
|
||||
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
const parent = wrap.parentElement;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-split-container';
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
const paneB = document.createElement('div');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
|
||||
parent.insertBefore(container, wrap);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
});
|
||||
this._splitPane.connect().catch(() => {
|
||||
/* Best-effort, matching the primary pane's own tolerance for a failed
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||
// terminal-ui.js debounces 300ms and would eventually catch up, but
|
||||
// relying on that left the pane showing stale-width content (existing
|
||||
// box-drawing lines, banners) until the user hit "Redraw Terminal".
|
||||
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
|
||||
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||
this._updateSplitButtonState(true);
|
||||
},
|
||||
|
||||
closeSplitPane(options = {}) {
|
||||
if (!this._splitPane) return;
|
||||
// A split can collapse MID-DRAG (either session ending, the window
|
||||
// narrowing past the gate, a click on Pane B's own tab). The drag's own
|
||||
// onUp is what normally clears `body.split-pane-resizing` (a col-resize
|
||||
// cursor plus user-select:none on EVERY element, styles.css), and it
|
||||
// relied on pointer capture routing pointerup back to a divider this
|
||||
// method detaches below, so a mid-drag collapse left the whole page
|
||||
// locked in resize mode until a reload. Tear the drag down first.
|
||||
this._splitDividerDragTeardown?.();
|
||||
this._splitDividerDragTeardown = null;
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
if (!container) return;
|
||||
const wrap = container.querySelector('.terminal-wrap');
|
||||
const parent = container.parentElement;
|
||||
wrap.style.flexBasis = '';
|
||||
parent.insertBefore(wrap, container);
|
||||
container.remove();
|
||||
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||
// while activeSessionId is still the id the server just removed, so a
|
||||
// resize from here would be aimed at a session that no longer exists;
|
||||
// the promoted session gets its own resize from selectSession().
|
||||
if (!options.skipPrimaryResize) {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
// (openSplitPicker's own early return) close an already-open split — and
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('split-open', open);
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
},
|
||||
|
||||
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||
let dragging = false;
|
||||
let dragRaf = null;
|
||||
let pendingClientX = null;
|
||||
let capturedPointerId = null;
|
||||
|
||||
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
// ending, or the picker's own close button) — closeSplitPane() removes
|
||||
// `.terminal-split-container` from the DOM, which detaches `divider`
|
||||
// too, so `divider.parentElement` is null on the very next frame and
|
||||
// every drag threw here until mouseup finally removed the listener.
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||||
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||
wrap.style.flexBasis = `${percent}%`;
|
||||
paneB.style.flexBasis = `${100 - percent}%`;
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this._splitPane?.localFit();
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
pendingClientX = e.clientX;
|
||||
if (dragRaf) return;
|
||||
dragRaf = requestAnimationFrame(() => {
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
});
|
||||
};
|
||||
|
||||
// Everything pointerdown ARMS, undone in one place: the body-level
|
||||
// cursor/selection lock, the divider's dragging class, pointer capture,
|
||||
// the move/up/cancel listeners and a queued reflow frame. Shared by onUp
|
||||
// (a normal drag end) and by closeSplitPane(), via the teardown handle
|
||||
// stored below, for a split that collapses mid-drag: the pointerup that
|
||||
// would have run onUp is routed by pointer capture to a divider
|
||||
// closeSplitPane() has detached, so it never arrives. Idempotent, since
|
||||
// the teardown runs whether or not a drag is in progress.
|
||||
const endDrag = () => {
|
||||
dragging = false;
|
||||
divider.classList.remove('dragging');
|
||||
document.body.classList.remove('split-pane-resizing');
|
||||
if (capturedPointerId !== null) {
|
||||
try {
|
||||
divider.releasePointerCapture(capturedPointerId);
|
||||
} catch {
|
||||
/* Already released (pointercancel/lostpointercapture beat us here). */
|
||||
}
|
||||
capturedPointerId = null;
|
||||
}
|
||||
divider.removeEventListener('pointermove', onMove);
|
||||
divider.removeEventListener('pointerup', onUp);
|
||||
divider.removeEventListener('pointercancel', onUp);
|
||||
if (dragRaf) {
|
||||
cancelAnimationFrame(dragRaf);
|
||||
dragRaf = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
// A reflow frame still queued at release carries the final pointer
|
||||
// position; apply it once, synchronously, so the panes end where the
|
||||
// pointer did rather than one frame short.
|
||||
const hadQueuedFrame = dragRaf !== null;
|
||||
endDrag();
|
||||
if (hadQueuedFrame) applyDragPercent(pendingClientX);
|
||||
// Send the real PTY resize exactly once here, at drag end, for BOTH
|
||||
// panes — never per-move (matching the codebase's established
|
||||
// trailing-edge debounce convention, see throttledResize in
|
||||
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
|
||||
// SIGWINCH/reflow states into scrollback or spawn a `tmux
|
||||
// resize-window` child per frame.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
this._splitPane?.fit();
|
||||
};
|
||||
|
||||
// Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
|
||||
// of mousedown/document-level mousemove: a plain mousedown drag selects
|
||||
// the text under the cursor as it crosses both terminals, and pointer
|
||||
// capture routes move/up straight to `divider` regardless of what's under
|
||||
// the cursor mid-drag, so no document-level listener leak is possible if
|
||||
// the pointer is released off-window. `body.split-pane-resizing` (mirrors
|
||||
// `body.tab-rail-resizing`) locks the cursor/selection for the drag.
|
||||
divider.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
divider.classList.add('dragging');
|
||||
document.body.classList.add('split-pane-resizing');
|
||||
try {
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
capturedPointerId = e.pointerId;
|
||||
} catch {
|
||||
/* Capture failed — the drag still works via the listeners below. */
|
||||
}
|
||||
divider.addEventListener('pointermove', onMove);
|
||||
divider.addEventListener('pointerup', onUp);
|
||||
divider.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
this._splitDividerDragTeardown = endDrag;
|
||||
},
|
||||
});
|
||||
|
||||
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||||
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (this._splitSessionId === data.id) {
|
||||
this.closeSplitPane();
|
||||
} else if (this._splitPane && this.activeSessionId === data.id) {
|
||||
// Pane A's session ended: promote Pane B by closing the split and
|
||||
// selecting its session as the new (single) active pane. This is an
|
||||
// app-driven selection, not the user clicking a tab, so it must not
|
||||
// spend the promoted session's idle alert (see the Approvals Inbox
|
||||
// acknowledgement rule in CLAUDE.md — only a human opening a session
|
||||
// acknowledges it).
|
||||
const promoted = this._splitSessionId;
|
||||
// activeSessionId is still data.id here (the original handler below is
|
||||
// what retires it), so closeSplitPane()'s closing resize would be aimed
|
||||
// at the session the server just removed. Skip it; selectSession() sizes
|
||||
// the promoted session itself.
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
|
||||
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
|
||||
// selection itself once the delete lands — same race _onSessionDeleted's
|
||||
// own active-session handoff guards against (see its comment). Selecting
|
||||
// here too would fight it for which tab wins.
|
||||
if (promoted && !this._closingSessions.has(data.id)) {
|
||||
this.selectSession(promoted, { auto: true });
|
||||
}
|
||||
}
|
||||
return _originalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
// I2: closes an active split BEFORE the primary pane rebinds to the same
|
||||
// session Pane B is showing (clicking Pane B's own session tab while split,
|
||||
// or any other selectSession() call that targets _splitSessionId). Without
|
||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// dimensions via its own `{t:'z',...}` resize frame.
|
||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||
this.closeSplitPane();
|
||||
}
|
||||
return _originalSelectSession.call(this, sessionId, ...args);
|
||||
};
|
||||
@@ -1092,6 +1092,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
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
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -4069,8 +4075,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
const flushedLength = Array.from(flushed.text || '').length;
|
||||
if (flushedLength > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushedLength)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
@@ -5124,6 +5131,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5149,6 +5160,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5200,6 +5215,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5420,6 +5440,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+16
-5
@@ -1581,9 +1581,17 @@ export class WebServer extends EventEmitter {
|
||||
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
|
||||
// The id is gated to JSON + <-escaped so it can't break out of the inline
|
||||
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
|
||||
//
|
||||
// Every `</head>` injection below passes a replacer FUNCTION, never a
|
||||
// replacement STRING: `String.replace` interprets `$&`, `$'`, `` $` `` and
|
||||
// `$<n>` inside a string replacement, so a payload carrying `$'` would splice
|
||||
// the rest of the document (the whole <body>) into the inline script, past
|
||||
// any escaping applied to the payload itself. The custom-model list below
|
||||
// carries a user-settable `label` (clis.json), which is the site that made
|
||||
// this real; the others follow the same rule so the class of bug stays out.
|
||||
if (soloSessionId) {
|
||||
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
|
||||
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
html = html.replace('</head>', () => `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
}
|
||||
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
|
||||
// have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
|
||||
@@ -1655,7 +1663,7 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
|
||||
// generate an entry for: read generically off the registry's `capabilities`
|
||||
@@ -1672,16 +1680,19 @@ export class WebServer extends EventEmitter {
|
||||
const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis));
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
const v = this.gestureBundleVersion();
|
||||
// Relative src so the injected `<base href>` resolves it under the mount
|
||||
// prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
|
||||
html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`
|
||||
);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
|
||||
@@ -99,7 +99,10 @@ describe('App Settings modal structure', () => {
|
||||
for (const [, attrs, body] of previewed) {
|
||||
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
|
||||
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
|
||||
// A decimal (e.g. "11.5") is allowed — Split sits between Multi-monitor
|
||||
// (11) and Ultracode Agents (12) in the real header, and Number()
|
||||
// parses it fine for the preview's own sort.
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+(\.\d+)?"/);
|
||||
// A text token replaces the icon for readouts (plan usage, CPU, font size).
|
||||
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
|
||||
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it('returns the response directly when no confirmation is needed', async () => {
|
||||
it('returns the response directly when no confirmation is needed, and records "last used"', async () => {
|
||||
const { win, app } = bootApp();
|
||||
withFetch(win, (body) => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false, body } }));
|
||||
const data = await app._quickStartWithCustomModelConfirm({
|
||||
@@ -152,9 +152,17 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult).toEqual(data.data);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
|
||||
});
|
||||
|
||||
it('confirming re-sends with confirmedSwap and returns the second response', async () => {
|
||||
it('a plain launch with no customModel at all never touches the "last used" key (undefined endpointId/modelId would otherwise silently no-op it)', async () => {
|
||||
const { win, app } = bootApp();
|
||||
withFetch(win, () => ({ success: true, data: { sessionId: 's1' } }));
|
||||
await app._quickStartWithCustomModelConfirm({ mode: 'codex' });
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:undefined')).toBeNull();
|
||||
});
|
||||
|
||||
it('confirming re-sends with confirmedSwap, returns the second response, and only THEN records "last used"', async () => {
|
||||
const { win, app } = bootApp();
|
||||
app._confirmModelSwap = async () => true;
|
||||
let calls = 0;
|
||||
@@ -183,9 +191,10 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(calls).toBe(2);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult.modelSwapInProgress).toBe(true);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
|
||||
});
|
||||
|
||||
it('cancelling never re-sends, and reports a cancellation error', async () => {
|
||||
it('cancelling never re-sends, reports a cancellation error, and must NEVER record "last used" for a launch that never happened', async () => {
|
||||
const { win, app } = bootApp();
|
||||
app._confirmModelSwap = async () => false;
|
||||
let calls = 0;
|
||||
@@ -208,6 +217,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toMatch(/cancelled/i);
|
||||
expect(app._lastCustomModelLaunchResult).toBeUndefined();
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -272,6 +272,207 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
expect(win.document.getElementById('customModelPickList')!.textContent).toContain('Default');
|
||||
});
|
||||
|
||||
it('promotes the model llama-swap currently has loaded and ready to the top of the list, tagged', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons.map((b) => b.textContent)).toHaveLength(3);
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||
// Nothing else got relabelled or reordered past the promoted row.
|
||||
expect(buttons[1].textContent).toContain('qwen3');
|
||||
expect(buttons[2].textContent).toContain('llama3');
|
||||
});
|
||||
|
||||
it('falls back to the last model launched on this (harness, endpoint) pair when nothing is currently loaded', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
// Simulate a prior launch on this exact (harness, endpoint) pair having picked llama3.
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama3');
|
||||
expect(buttons[0].textContent).toContain('Last used');
|
||||
expect(buttons[0].textContent).not.toContain('Currently loaded');
|
||||
});
|
||||
|
||||
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||
});
|
||||
|
||||
it('a currently-loaded row that is also the endpoint default shows BOTH tags, as two separate spans', async () => {
|
||||
// The common case on a single-purpose GPU box: the one model that is loaded is the
|
||||
// saved default too. An exclusive tag slot (promotion, else Default) silently dropped
|
||||
// the Default marking for exactly that row.
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3', 'phi4'],
|
||||
defaultModelId: 'phi4',
|
||||
},
|
||||
],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||
expect(tags).toEqual(['Currently loaded', 'Default']);
|
||||
// Rows with neither a promotion nor the default carry no tag at all.
|
||||
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a "last used" row that is also the endpoint default shows both tags too', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3', 'phi4'],
|
||||
defaultModelId: 'llama3',
|
||||
},
|
||||
],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama3');
|
||||
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||
expect(tags).toEqual(['Last used', 'Default']);
|
||||
});
|
||||
|
||||
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
// "loading", not "ready" — and a model id this host's own /v1/models no longer serves.
|
||||
return { isLlamaSwap: true, running: [{ model: 'ghost-model', state: 'loading' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.textContent).not.toContain('Currently loaded');
|
||||
expect(list.textContent).not.toContain('ghost-model');
|
||||
const buttons = [...list.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('qwen3');
|
||||
});
|
||||
|
||||
it('remembers the launched model as "last used" only once the apply actually succeeds, not on the mere attempt', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
app.activeSessionId = 'old-session';
|
||||
// A real new session, and a real successful apply with no questions asked — the
|
||||
// restart path only reaches its _setCustomModelLastUsed call past both.
|
||||
app.run = async () => {
|
||||
app.activeSessionId = 'new-session';
|
||||
};
|
||||
app._api = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
|
||||
});
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('a slower currently-loaded probe for an earlier pick must never clobber a faster, later pick for a different endpoint', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
const hostA = { id: 'host-a', label: 'Host A', baseUrl: 'http://a', models: ['a1', 'a2'] };
|
||||
const hostB = { id: 'host-b', label: 'Host B', baseUrl: 'http://b', models: ['b1', 'b2'] };
|
||||
let resolveA!: (v: unknown) => void;
|
||||
const pendingA = new Promise((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/host-a/running-status') return pendingA;
|
||||
if (path === '/api/model-endpoints/host-b/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return null;
|
||||
};
|
||||
|
||||
// Host A's picker opens first but its probe never resolves until we say so below —
|
||||
// Host B's opens second and resolves immediately, so it renders first.
|
||||
const openA = app._openCustomModelPickModal('claude', hostA);
|
||||
await app._openCustomModelPickModal('claude', hostB);
|
||||
|
||||
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
|
||||
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
|
||||
|
||||
// Host A's probe finally answers, after Host B has already rendered.
|
||||
resolveA({ isLlamaSwap: false, running: [] });
|
||||
await openA;
|
||||
|
||||
// The late-arriving Host A response must be a no-op: still Host B on screen.
|
||||
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
|
||||
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
|
||||
const listText = win.document.getElementById('customModelPickList')!.textContent;
|
||||
expect(listText).toContain('b1');
|
||||
expect(listText).toContain('b2');
|
||||
expect(listText).not.toContain('a1');
|
||||
expect(listText).not.toContain('a2');
|
||||
});
|
||||
|
||||
it('picking a row in the modal closes it and launches with that exact model', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }],
|
||||
@@ -333,6 +534,48 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
|
||||
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
|
||||
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
|
||||
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
|
||||
// does not patch, so this is the only way to test the bound without actually waiting
|
||||
// on it (or, worse, hanging on a promise that deliberately never resolves).
|
||||
|
||||
it('never lets an endpoint that never answers keep the picker waiting past the client-side bound', async () => {
|
||||
const { app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3'] }],
|
||||
});
|
||||
// A `running-status` probe that simply never resolves — the exact shape of an
|
||||
// endpoint that is asleep or firewalled, distinct from one that answers an error.
|
||||
app._apiJson = () => new Promise(() => {});
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('an endpoint that answers well within the bound is unaffected by it', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
|
||||
expect(result).toBeNull();
|
||||
// Give the loser of the race a turn to actually reject and hit its own .catch —
|
||||
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
|
||||
// for the whole test file, not a failed assertion in this test.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
it('does not apply the endpoint to a session that was already open when the launch fails', async () => {
|
||||
const { app } = bootApp({});
|
||||
@@ -1025,8 +1268,8 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
return { win, app, applyBodies };
|
||||
}
|
||||
|
||||
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true', async () => {
|
||||
const { app, applyBodies } = launchHarness([
|
||||
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true, and only THEN records "last used"', async () => {
|
||||
const { win, app, applyBodies } = launchHarness([
|
||||
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
|
||||
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
|
||||
]);
|
||||
@@ -1043,10 +1286,11 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3' },
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
|
||||
]);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('declining the in-app context-warning modal keeps the native backend and never re-sends the apply', async () => {
|
||||
const { app, applyBodies } = launchHarness([
|
||||
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
|
||||
const { win, app, applyBodies } = launchHarness([
|
||||
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
|
||||
]);
|
||||
app._confirmContextWarning = async () => false;
|
||||
@@ -1059,6 +1303,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
|
||||
expect(applyBodies).toHaveLength(1); // no second (confirmed) call
|
||||
expect(toastMessage).toMatch(/context window too small/i);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -313,11 +313,17 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
/**
|
||||
* The elements whose padding cascade is simulated: every derived overlay as
|
||||
* a bare element, plus the open command palette, which is a `.modal` wearing
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand.
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand, plus
|
||||
* the mobile prompt composer, a `.paste-overlay` wearing a second class that
|
||||
* carries its own `padding` shorthand. The derived list cannot see the
|
||||
* composer (it inherits the centring declarations rather than declaring
|
||||
* them), and simulating `['paste-overlay']` alone stayed green while the
|
||||
* generic `.paste-overlay` fold rule erased the composer's bottom gutter.
|
||||
*/
|
||||
const ELEMENTS: { name: string; classes: string[] }[] = [
|
||||
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
|
||||
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
|
||||
{ name: '.paste-overlay.prompt-composer-overlay', classes: ['paste-overlay', 'prompt-composer-overlay'] },
|
||||
];
|
||||
|
||||
it('simulates the cascade the browser measured', () => {
|
||||
@@ -330,7 +336,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
const picker = ['path-picker-overlay'];
|
||||
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
|
||||
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
|
||||
const palette = ELEMENTS.at(-1)!.classes;
|
||||
const palette = ELEMENTS.find((e) => e.name === '.modal.command-palette-modal.active')!.classes;
|
||||
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
|
||||
expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem');
|
||||
expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0');
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
||||
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
||||
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||
*
|
||||
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
|
||||
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
|
||||
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
|
||||
* its own piece of work verified by a browser/mobile suite the CI gate cannot
|
||||
* see." Widening this guard to the whole directory would force either fixing
|
||||
* or allowlisting dozens of branches in files nobody has touched or reviewed
|
||||
* for this change, which is scope B2 never took on.
|
||||
*
|
||||
* Port: none (pure static analysis).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
||||
|
||||
/**
|
||||
* Every currently-surviving branch, each with the COUNT of physical call
|
||||
* sites carrying it and the reason none of them is a `CliCapabilities`
|
||||
* field, keyed `<file>::<the matched expression>` — deliberately NO line
|
||||
* number. An earlier version keyed on `<file>::<line>::<expression>`, and
|
||||
* inserting one comment line at the top of `session-ui.js` shifted every
|
||||
* subsequent line number, so all 21 entries went stale and the same 21
|
||||
* branches were then reported as "new". `session-ui.js` is one of the most
|
||||
* contended files in the repo, so a guard that goes red on any unrelated
|
||||
* edit to it sends the next person after the wrong problem.
|
||||
*
|
||||
* The `count` is what closes the gap dropping the line number opened: a key
|
||||
* alone says "this expression is approved somewhere in this file", so a
|
||||
* BRAND NEW `mode === 'codex'` site anywhere in `session-ui.js` would reuse
|
||||
* the same key as the two approved ones and pass silently. The count makes
|
||||
* that a mismatch — one more occurrence than declared — and the "counts
|
||||
* match" test below catches it, while a genuinely new expression (a CLI id
|
||||
* with no ALLOWED_BRANCHES entry at all) is still caught by the separate
|
||||
* "no unapproved id branches" test either way.
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
"session-ui.js::mode === 'shell'": {
|
||||
count: 2,
|
||||
reason:
|
||||
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
|
||||
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
|
||||
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
|
||||
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
|
||||
'hardcoded, see the PR #458 review thread)',
|
||||
},
|
||||
|
||||
"session-ui.js::mode === 'claude'": {
|
||||
count: 4,
|
||||
reason:
|
||||
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
||||
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
|
||||
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
|
||||
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
|
||||
"by design, mirroring the backend capabilities.ralph gate), and the runMode setter's " +
|
||||
'validity check',
|
||||
},
|
||||
|
||||
// The 8 external CLIs share the same two call sites and the same reason at
|
||||
// each: the button-label ternary (see the shell entry above for why it
|
||||
// stays hardcoded) and the runMode property setter's validity allowlist
|
||||
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
|
||||
// no shell arm at all and no evidence of what callers rely on it).
|
||||
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'antigravity'": {
|
||||
count: 2,
|
||||
reason: 'button-label ternary + runMode setter validity check',
|
||||
},
|
||||
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
|
||||
// The docker adopt-preflight status line and the docker link/adopt toast
|
||||
// both list the agent CLIs probed INSIDE the container and leave `shell`
|
||||
// out of that human-readable "found ..." summary (it is always present and
|
||||
// is not an agent CLI). Written as `(m) => m !== 'shell'`, the naming the
|
||||
// original named-variable pattern could not see; the widened pattern
|
||||
// normalizes the `m` to `mode` (see BRANCH_PATTERN below).
|
||||
"session-ui.js::mode !== 'shell'": {
|
||||
count: 2,
|
||||
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
|
||||
},
|
||||
|
||||
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||
"mobile-overview.js::mode !== 'shell'": {
|
||||
count: 1,
|
||||
reason: 'shell needs no CLI, so it is exempt from the availability gate',
|
||||
},
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
||||
const ID_ALT = IDS.join('|');
|
||||
|
||||
/**
|
||||
* The backend guard's four shapes (see its own comment for why all four
|
||||
* matter), with ONE deliberate widening on the first.
|
||||
*
|
||||
* The backend pattern accepts a comparison only when its left-hand side is
|
||||
* literally named `mode`, `id` or `agentType`, so both
|
||||
* `const m = this._runMode; if (m === 'codex')` and
|
||||
* `if (this._runMode !== 'gemini')` slip past it, and `session-ui.js` already
|
||||
* uses exactly that naming (`(m) => m !== 'shell'`, twice). The review of
|
||||
* PR #458 surfaced that blind spot, so here the left-hand side is ANY
|
||||
* identifier (`[\w$]+`, the leaf of a member chain), normalized to `mode` in
|
||||
* the allowlist key by `scan()` so a local rename never churns the entries.
|
||||
* Measured over both scanned files before widening: every extra hit was a
|
||||
* genuine mode comparison (the two `m !== 'shell'` filters, allowlisted
|
||||
* above), so the widening added no false positive; a future one gets an
|
||||
* allowlist entry with its reason like any other. The backend guard keeps
|
||||
* its narrower form and is deliberately not changed here.
|
||||
*
|
||||
* Still unseen, and worth knowing: a Yoda comparison (`'codex' === mode`),
|
||||
* and an id list held in a variable (`EXTERNAL.includes(mode)`), since the
|
||||
* third shape needs the literal list inline.
|
||||
*/
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
// <identifier> === 'codex' / <identifier> !== 'codex' (any left-hand identifier, see above)
|
||||
`\\b[\\w$]+\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// case 'codex':
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
||||
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
||||
].join('|'),
|
||||
'g'
|
||||
);
|
||||
|
||||
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
|
||||
function uncommented(source: string): string {
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
expression: string;
|
||||
line: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function scan(): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const file of SCANNED_FILES) {
|
||||
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
// Normalize the comparison shape's left-hand identifier (whatever the
|
||||
// local is called: `id`, `agentType`, `m`, `_runMode`) to `mode`; the
|
||||
// lookahead leaves the `case`/`.includes(` shapes untouched.
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^[\w$]+(?=\s*[!=]==)/, 'mode');
|
||||
findings.push({ file, expression, line: i + 1, key: `${file}::${expression}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
const findings = scan();
|
||||
|
||||
function actualCounts(): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const f of findings) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
||||
it('scans both files (sanity)', () => {
|
||||
// If this drops to zero the scanner or the file list drifted and every
|
||||
// assertion below would pass vacuously.
|
||||
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
||||
expect(scannedBytes).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
it('builds its id list from the live catalog (sanity)', () => {
|
||||
expect(IDS).toContain('claude');
|
||||
expect(IDS).toContain('deepseek');
|
||||
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
it('still detects a branch when one exists (anti-vacuity)', () => {
|
||||
const samples = [
|
||||
"if (session.mode === 'codex') { doSomething(); }",
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
// The two forms the named-variable pattern was blind to (see BRANCH_PATTERN).
|
||||
"const m = this._runMode; if (m === 'codex') { doSomething(); }",
|
||||
"if (this._runMode !== 'gemini') { doSomething(); }",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
||||
}
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
||||
});
|
||||
|
||||
it('has no unapproved id branches', () => {
|
||||
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
||||
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length === 0
|
||||
? ''
|
||||
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
|
||||
'Two ways out, in order of preference:\n' +
|
||||
' 1. Derive the difference from a shared module-level constant, the way\n' +
|
||||
' _runCliMode()/RUN_MODE_LAUNCH/EXTERNAL_CLI_MODES do.\n' +
|
||||
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
|
||||
' ALLOWED_BRANCHES in this file WITH the reason.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every allowlisted branch occurs exactly its declared number of times', () => {
|
||||
// This is what closes the gap the line-number removal opened (see the
|
||||
// ALLOWED_BRANCHES header comment): a key alone cannot tell "the two
|
||||
// approved sites" from "the two approved sites plus a brand new third
|
||||
// one reusing the same expression" — the count can. A mismatch in
|
||||
// either direction is real: higher means an unreviewed NEW branch
|
||||
// landed reusing an approved expression, lower means one of the
|
||||
// reviewed call sites was removed and the entry is now a stale lie
|
||||
// about the codebase (the count going to 0 is the old "stale entry"
|
||||
// case, now folded into this same check rather than a separate one).
|
||||
const actual = actualCounts();
|
||||
const mismatches: string[] = [];
|
||||
for (const [key, { count: expected }] of Object.entries(ALLOWED_BRANCHES)) {
|
||||
const got = actual.get(key) ?? 0;
|
||||
if (got !== expected) {
|
||||
mismatches.push(` ${key} expected ${expected}, found ${got}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
mismatches,
|
||||
mismatches.length === 0
|
||||
? ''
|
||||
: `ALLOWED_BRANCHES count mismatch(es):\n${mismatches.join('\n')}\n\n` +
|
||||
'A count LOWER than declared means a reviewed call site was removed — update or delete ' +
|
||||
'the entry. A count HIGHER than declared means a NEW branch landed reusing an already-' +
|
||||
'approved expression — review it and bump the count (or fix the branch) explicitly, ' +
|
||||
'rather than let it ride in on an existing approval.'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
|
||||
interface TrapListener {
|
||||
(e: Record<string, unknown>): void;
|
||||
@@ -91,8 +93,7 @@ function loadPasteHarness(): Harness {
|
||||
});
|
||||
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'image-input.js' });
|
||||
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
|
||||
const pastedText: string[] = [];
|
||||
@@ -133,6 +134,20 @@ function loadPasteHarness(): Harness {
|
||||
};
|
||||
}
|
||||
|
||||
function loadImageInputApp() {
|
||||
const context = vm.createContext({ console, window: {}, document: {} });
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
const app = new CodemanApp();
|
||||
app.activeSessionId = 'session-1';
|
||||
app.showToast = vi.fn();
|
||||
app.sendInput = vi.fn(async () => {});
|
||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||
return app as Record<string, any>;
|
||||
}
|
||||
|
||||
describe('Ctrl+V paste trap', () => {
|
||||
it('sends clipboard text to the terminal once for a single paste event', () => {
|
||||
const h = loadPasteHarness();
|
||||
@@ -174,3 +189,24 @@ describe('Ctrl+V paste trap', () => {
|
||||
expect(h.attachedTraps()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image upload insertion policy', () => {
|
||||
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
|
||||
const app = loadImageInputApp();
|
||||
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
|
||||
|
||||
const paths = await app._uploadAndInsertImages(files, { insert: false });
|
||||
|
||||
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves terminal insertion by default', async () => {
|
||||
const app = loadImageInputApp();
|
||||
|
||||
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||
|
||||
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @fileoverview CI-visible coverage for the manual mobile prompt composer.
|
||||
*
|
||||
* The Playwright mobile suite is excluded from the CI gate, so the behaviors
|
||||
* most likely to regress live here against the real browser module: native
|
||||
* textarea replacement, local-echo adoption, per-session drafts, bracketed
|
||||
* multiline delivery and image-path insertion.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
|
||||
|
||||
type Timer = { callback: () => void; delay: number };
|
||||
|
||||
function loadComposer(sessionId = 'session-1') {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
const window = dom.window;
|
||||
const timers: Timer[] = [];
|
||||
const localEcho = {
|
||||
pendingText: '',
|
||||
clear: vi.fn(() => {
|
||||
localEcho.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app = {
|
||||
activeSessionId: sessionId,
|
||||
sessions: new Map([
|
||||
['session-1', { mode: 'claude' }],
|
||||
['session-2', { mode: 'claude' }],
|
||||
]),
|
||||
terminal: { paste: vi.fn(), focus: vi.fn(), modes: { bracketedPasteMode: true } },
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: localEcho,
|
||||
_flushedOffsets: new Map<string, number>(),
|
||||
_flushedTexts: new Map<string, string>(),
|
||||
_echoPassthroughSessions: new Set<string>(),
|
||||
_predictiveEcho: { clearPredictions: vi.fn() },
|
||||
_sendInputAsync: vi.fn(),
|
||||
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
|
||||
showToast: vi.fn(),
|
||||
};
|
||||
const schedule = (callback: () => void, delay = 0) => {
|
||||
timers.push({ callback, delay });
|
||||
return timers.length;
|
||||
};
|
||||
const factory = new Function(
|
||||
'window',
|
||||
'document',
|
||||
'Event',
|
||||
'app',
|
||||
'MobileDetection',
|
||||
'URLSearchParams',
|
||||
'fetch',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'requestAnimationFrame',
|
||||
`${accessorySource}\nreturn KeyboardAccessoryBar;`
|
||||
);
|
||||
const bar = factory(
|
||||
window,
|
||||
window.document,
|
||||
window.Event,
|
||||
app,
|
||||
{ isTouchDevice: () => true },
|
||||
window.URLSearchParams,
|
||||
vi.fn(),
|
||||
schedule,
|
||||
vi.fn(),
|
||||
(callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
app,
|
||||
bar,
|
||||
document: window.document,
|
||||
localEcho,
|
||||
timers,
|
||||
runTimers() {
|
||||
for (const timer of timers.splice(0)) timer.callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function textarea(document: Document): HTMLTextAreaElement {
|
||||
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function mountComposeButton(bar: any, document: Document): HTMLButtonElement {
|
||||
bar.element = document.createElement('div');
|
||||
bar.element.innerHTML = bar._simpleButtons;
|
||||
document.body.appendChild(bar.element);
|
||||
bar._syncComposerDraftIndicator();
|
||||
return bar.element.querySelector('[data-action="compose"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('mobile prompt composer', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('replaces Paste with Compose on agent bars while shell keeps direct Paste', () => {
|
||||
const template = (name: string) => accessorySource.match(new RegExp(name + '\\s*:\\s*`([\\s\\S]*?)`'))?.[1] ?? '';
|
||||
|
||||
expect(template('_simpleButtons')).toContain('data-action="compose"');
|
||||
expect(template('_simpleButtons')).not.toContain('data-action="paste"');
|
||||
expect(template('_extendedButtons')).toContain('data-action="compose"');
|
||||
expect(template('_extendedButtons')).not.toContain('data-action="paste"');
|
||||
expect(template('_shellButtons')).toContain('data-action="paste"');
|
||||
expect(template('_shellButtons')).not.toContain('data-action="compose"');
|
||||
});
|
||||
|
||||
it('uses a compact accessible icon for Compose in both agent layouts', () => {
|
||||
const { bar, document } = loadComposer();
|
||||
|
||||
for (const markup of [bar._simpleButtons, bar._extendedButtons]) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = markup;
|
||||
const button = wrapper.querySelector('[data-action="compose"]') as HTMLButtonElement;
|
||||
|
||||
expect(button.getAttribute('aria-label')).toBe('Compose prompt');
|
||||
expect(button.getAttribute('title')).toBe('Compose prompt');
|
||||
expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(button.textContent?.trim()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a native autocorrect-aware textarea and stores replacement text exactly once', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
|
||||
expect(input.getAttribute('autocorrect')).toBe('on');
|
||||
expect(input.getAttribute('autocapitalize')).toBe('sentences');
|
||||
expect(input.getAttribute('spellcheck')).toBe('true');
|
||||
|
||||
input.value = 'Please fix teh bug';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
input.value = 'Please fix the bug';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('Please fix the bug');
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adopts and clears locally-buffered terminal input on open', () => {
|
||||
const { app, bar, document, localEcho } = loadComposer();
|
||||
localEcho.pendingText = '-written prompt';
|
||||
app._flushedOffsets.set('session-1', 4);
|
||||
app._flushedTexts.set('session-1', 'half');
|
||||
|
||||
bar.composePrompt();
|
||||
|
||||
expect(textarea(document).value).toBe('half-written prompt');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(4), { useMux: true });
|
||||
expect(localEcho.clear).toHaveBeenCalledOnce();
|
||||
expect(localEcho.suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(app._flushedOffsets.has('session-1')).toBe(false);
|
||||
expect(app._flushedTexts.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses Unicode code points when erasing flushed text', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._flushedOffsets.set('session-1', 3);
|
||||
app._flushedTexts.set('session-1', 'a😀');
|
||||
|
||||
bar.composePrompt();
|
||||
|
||||
expect(textarea(document).value).toBe('a😀');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(2), { useMux: true });
|
||||
});
|
||||
|
||||
it('closes on tab switch and keeps drafts isolated by session', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'first session draft';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
|
||||
app.activeSessionId = 'session-2';
|
||||
bar.refreshForActiveSession();
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
expect(() => bar.refreshForActiveSession()).not.toThrow();
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
textarea(document).value = 'second session draft';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
|
||||
app.activeSessionId = 'session-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
expect(composeButton.getAttribute('aria-label')).toBe('Compose prompt, draft saved');
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('first session draft');
|
||||
});
|
||||
|
||||
it('drops a draft and closes its composer when the session is deleted', () => {
|
||||
const { bar, document } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'temporary secret';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
|
||||
bar.discardComposerDraft('session-1');
|
||||
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
});
|
||||
|
||||
it('wires session cleanup to composer draft cleanup', () => {
|
||||
const cleanupStart = appSource.indexOf(' _cleanupSessionData(sessionId) {');
|
||||
const cleanup = appSource.slice(cleanupStart, cleanupStart + 1200);
|
||||
|
||||
expect(cleanup).toContain('KeyboardAccessoryBar.discardComposerDraft?.(sessionId)');
|
||||
});
|
||||
|
||||
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
|
||||
const { app, bar, document, timers, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'first line\nsecond line';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new document.defaultView!.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||
expect(app._sendInputAsync).toHaveBeenNthCalledWith(1, 'session-1', '\x1b[200~first line\rsecond line\x1b[201~');
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
|
||||
runTimers();
|
||||
expect(app._sendInputAsync).toHaveBeenNthCalledWith(2, 'session-1', '\r', { useMux: true });
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
});
|
||||
|
||||
it('sends after replay resets xterm’s mirrored bracketed-paste mode', () => {
|
||||
const { app, bar, document, runTimers } = loadComposer();
|
||||
app.terminal.modes.bracketedPasteMode = false;
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'still\nmultiline';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~still\rmultiline\x1b[201~');
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
runTimers();
|
||||
expect(app._sendInputAsync).toHaveBeenLastCalledWith('session-1', '\r', { useMux: true });
|
||||
});
|
||||
|
||||
it('releases echo passthrough after a composed prompt is queued', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._echoPassthroughSessions.add('session-1');
|
||||
app._echoPassthroughSessions.add('session-2');
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'send from composer';
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._echoPassthroughSessions.has('session-1')).toBe(false);
|
||||
expect(app._echoPassthroughSessions.has('session-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an oversized prompt as a draft instead of queueing a rejected frame', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'x'.repeat(65525);
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too long'), 'error');
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
expect(bar._composerDrafts.get('session-1')).toHaveLength(65525);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only draft as empty instead of submitting blank lines', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
textarea(document).value = ' \n\n ';
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('derives the prompt budget from the 64 KiB input frame minus both paste markers', () => {
|
||||
// ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH without an ACK,
|
||||
// so a prompt of exactly the budget must produce a frame of exactly 64 KiB.
|
||||
const { app, bar, document } = loadComposer();
|
||||
expect(bar._composerMaxLength).toBe(64 * 1024 - '\x1b[200~\x1b[201~'.length);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'y'.repeat(bar._composerMaxLength);
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||
expect((app._sendInputAsync.mock.calls[0][1] as string).length).toBe(64 * 1024);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'keep this';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
expect(composeButton.title).toBe('Resume saved prompt draft');
|
||||
runTimers();
|
||||
localEcho.pendingText = '; then continue';
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('keep this; then continue');
|
||||
});
|
||||
|
||||
it('uploads images without writing into the PTY and inserts their paths into the draft', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'review';
|
||||
input.selectionStart = input.selectionEnd = input.value.length;
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
expect((document.querySelector('.paste-image') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
finishUpload(['/tmp/image-one.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('review /tmp/image-one.png'));
|
||||
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { insert: false });
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
bar.composePrompt();
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
finishUpload(['/tmp/late-image.png']);
|
||||
await vi.waitFor(() => expect(textarea(document).value).toBe('/tmp/late-image.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
let finishDeletedUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishDeletedUpload = resolveUpload))
|
||||
);
|
||||
const reopenedInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
Object.defineProperty(reopenedInput, 'files', { configurable: true, value: [image] });
|
||||
reopenedInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
app.sessions.delete('session-1');
|
||||
bar.discardComposerDraft('session-1');
|
||||
finishDeletedUpload(['/tmp/deleted-session.png']);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(bar._composerDrafts.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Send disabled until every concurrent image upload finishes', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
const finishUploads: Array<(paths: string[]) => void> = [];
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => finishUploads.push(resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
const pasteImage = () => {
|
||||
const item = { type: 'image/png', getAsFile: () => image };
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
|
||||
input.dispatchEvent(event);
|
||||
};
|
||||
|
||||
pasteImage();
|
||||
pasteImage();
|
||||
expect(finishUploads).toHaveLength(2);
|
||||
finishUploads[0](['/tmp/first.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
finishUploads[1](['/tmp/second.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -478,7 +478,7 @@ describe('Virtual Keyboard', () => {
|
||||
'tab',
|
||||
'shift-left',
|
||||
'shift-right',
|
||||
'paste',
|
||||
'compose',
|
||||
'readmymind',
|
||||
'esc',
|
||||
'dismiss',
|
||||
|
||||
@@ -55,32 +55,46 @@ describe('OpenCode session initial resize', () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that runOpenCode does NOT
|
||||
// pre-set activeSessionId before calling selectSession.
|
||||
// If it did, selectSession would early-return and skip sendResize.
|
||||
it('selectSession is not bypassed when the shared launcher sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that the OpenCode launch path does
|
||||
// NOT pre-set activeSessionId before calling selectSession. If it did,
|
||||
// selectSession would early-return and skip sendResize.
|
||||
//
|
||||
// PR B2 consolidated runOpenCode() (and 7 siblings) into one shared
|
||||
// _runCliMode(mode) — runOpenCode is now a one-line wrapper
|
||||
// (`return this._runCliMode('opencode')`), so inspecting ITS source would
|
||||
// never see the real launch logic and this check would pass vacuously
|
||||
// regardless of what _runCliMode actually does. Inspect _runCliMode itself.
|
||||
({ context, page } = await freshPage());
|
||||
await navigateAndWait(page);
|
||||
|
||||
// Read the runOpenCode source from the live app and verify
|
||||
// it doesn't assign activeSessionId before selectSession
|
||||
const hasPreAssignment = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app;
|
||||
const source = app.runOpenCode.toString();
|
||||
const { selectIdx, assignIdx } = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { _runCliMode: { toString: () => string } } }).app;
|
||||
const source = app._runCliMode.toString();
|
||||
|
||||
// Check: the source should NOT have activeSessionId = ... before selectSession
|
||||
// Find positions of both patterns
|
||||
const assignIdx = source.indexOf('this.activeSessionId = data.sessionId');
|
||||
const selectIdx = source.indexOf('this.selectSession(data.sessionId)');
|
||||
|
||||
// If assign doesn't exist at all, that's the correct fix
|
||||
if (assignIdx === -1) return false;
|
||||
|
||||
// If assign comes before select, that's the bug
|
||||
return assignIdx < selectIdx;
|
||||
// The launcher hands the FIRST created session to selectSession
|
||||
// (`_launchQuickStartInstances()` returns `firstSessionId`). An earlier
|
||||
// version of this check looked for `this.selectSession(data.sessionId)`,
|
||||
// a string that exists nowhere in session-ui.js, so both lookups came
|
||||
// back -1 and the assertion could never fail. Hence the anti-vacuity
|
||||
// check below: the select call itself must be found.
|
||||
const selectIdx = source.indexOf('this.selectSession(firstSessionId)');
|
||||
// ANY assignment to activeSessionId (whatever the right-hand side is
|
||||
// called), not `==`/`===` comparisons and not the comment that mentions
|
||||
// pre-setting it without a `this.` prefix.
|
||||
const assign = /this\.activeSessionId\s*=(?!=)/.exec(source);
|
||||
return { selectIdx, assignIdx: assign ? assign.index : -1 };
|
||||
});
|
||||
|
||||
expect(hasPreAssignment).toBe(false);
|
||||
// Anti-vacuity: if the select call is renamed again, fail here rather
|
||||
// than pass on two -1s.
|
||||
expect(selectIdx).toBeGreaterThan(-1);
|
||||
// Correct: no assignment at all. Bug: an assignment that lands BEFORE
|
||||
// selectSession runs, which makes selectSession early-return.
|
||||
expect(
|
||||
assignIdx === -1 || assignIdx > selectIdx,
|
||||
`activeSessionId is assigned at ${assignIdx}, before selectSession at ${selectIdx}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sends resize to server after creating a session via quick-start', async () => {
|
||||
|
||||
@@ -200,12 +200,12 @@ describe('mobile filesystem picker actions', () => {
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
getFlushed: () => ({ count: 4, text: 'a😀b' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
_flushedTexts: new Map([['session-1', 'a😀b']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
@@ -216,7 +216,7 @@ describe('mobile filesystem picker actions', () => {
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(3));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
|
||||
@@ -23,6 +23,8 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c
|
||||
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.js';
|
||||
import { enabledClis } from '../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
// renderIndexHtml probes the real PATH for every CLI, which would make the
|
||||
// assertions below depend on whatever happens to be installed on the machine
|
||||
@@ -79,6 +81,14 @@ vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
vi.mock('../src/git-clone.js', () => ({
|
||||
isGitAvailable: vi.fn(() => false),
|
||||
}));
|
||||
// The custom-model list carries `label`, a string a user's own clis.json can set.
|
||||
// Wrap enabledClis so ONE test below can hand renderIndexHtml a label with `$'`
|
||||
// in it while every other test still reads the real stock registry through the
|
||||
// real implementation.
|
||||
vi.mock('../src/config/cli-registry/registry.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/config/cli-registry/registry.js')>();
|
||||
return { ...actual, enabledClis: vi.fn(actual.enabledClis) };
|
||||
});
|
||||
|
||||
const TEMPLATE = [
|
||||
'<head>',
|
||||
@@ -222,6 +232,35 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
expect(eval(escaped)[0].label).toBe('</script><script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it("inserts a label containing $' verbatim instead of splicing the document into the script", async () => {
|
||||
// `String.replace` with a STRING replacement interprets `$'` as "the text
|
||||
// after the match", so a clis.json label carrying it used to re-inject the
|
||||
// rest of the document (the whole <body>) into the inline script, past
|
||||
// escapeScriptJson, which only neutralizes `<`. Every `</head>` injection
|
||||
// passes a replacer FUNCTION instead, whose return value is inserted
|
||||
// verbatim. The other `$` forms ride along so a partial escape cannot pass.
|
||||
const claude = STOCK_CLIS.find((e) => e.id === 'claude')!;
|
||||
const label = "Claude $' $& $` $1 $$";
|
||||
const real = vi.mocked(enabledClis).getMockImplementation()!;
|
||||
vi.mocked(enabledClis).mockImplementation(() => [{ ...claude, label }]);
|
||||
try {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html.match(/<body>/g)).toHaveLength(1);
|
||||
const clis = JSON.parse(html.match(/window\.__codemanCustomModelClis=(\[.*?\]);/)![1]);
|
||||
expect(clis).toEqual([{ id: 'claude', label }]);
|
||||
} finally {
|
||||
vi.mocked(enabledClis).mockImplementation(real);
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts a solo id containing $' verbatim, under the same replacer rule", async () => {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, "sess$'x");
|
||||
expect(html.match(/<body>/g)).toHaveLength(1);
|
||||
expect(html).toContain(`window.__CODEMAN_SOLO__="sess$'x"`);
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Table-driven pin for `run()`'s dispatch in session-ui.js
|
||||
* (PR #458). Before the run-menu consolidation, `run()` was an eight-arm
|
||||
* `if (mode === 'codex') return this.runCodex(); ...` chain and each arm was
|
||||
* pinned only by the name it called; after it, every non-claude, non-shell
|
||||
* mode reaches ONE shared launcher, `_runCliMode(mode)`, gated on
|
||||
* `EXTERNAL_CLI_MODES` (the key set of `RUN_MODE_LAUNCH`). Nothing pinned that
|
||||
* gate: a mode dropped from the table would fall through to `runClaude()` and
|
||||
* start a Claude session under a Codex label with no error, while an unknown
|
||||
* mode reaching `_runCliMode()` would throw on `entry.label` of an undefined
|
||||
* entry.
|
||||
*
|
||||
* The external ids are read off `RUN_MODE_LAUNCH` itself (same JSDOM
|
||||
* extraction as test/run-mode-launch-table-drift.test.ts, which separately
|
||||
* pins that key set against stock.ts), so a ninth CLI is covered the day it
|
||||
* lands in the table.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface HarnessApp {
|
||||
_runMode?: string;
|
||||
_runInFlight?: boolean;
|
||||
_runMinLockMs?: number;
|
||||
run: () => Promise<unknown>;
|
||||
runClaude: ReturnType<typeof vi.fn>;
|
||||
runShell: ReturnType<typeof vi.fn>;
|
||||
_runCliMode: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
app: HarnessApp;
|
||||
runBtn: HTMLButtonElement;
|
||||
externalIds: string[];
|
||||
}
|
||||
|
||||
function loadHarness(): Harness {
|
||||
const dom = new JSDOM('<!doctype html><body><button id="runBtn"></button></body>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'dangerously',
|
||||
});
|
||||
const win = dom.window as unknown as {
|
||||
eval: (s: string) => void;
|
||||
document: Document;
|
||||
CodemanApp: new () => HarnessApp;
|
||||
__TEST_RUN_MODE_LAUNCH: Record<string, unknown>;
|
||||
};
|
||||
win.eval('window.CodemanApp = function CodemanApp() {};');
|
||||
// The assignment rides in the SAME evaluated string as the module:
|
||||
// RUN_MODE_LAUNCH is a bare top-level `const`, visible only to this eval call
|
||||
// (see test/run-mode-launch-table-drift.test.ts for the measurement).
|
||||
win.eval(`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`);
|
||||
const app = new win.CodemanApp();
|
||||
app._runMinLockMs = 0; // run() otherwise holds its lock for >= 500ms per call
|
||||
app.runClaude = vi.fn(async () => 'claude');
|
||||
app.runShell = vi.fn(async () => 'shell');
|
||||
app._runCliMode = vi.fn(async (mode: string) => `cli:${mode}`);
|
||||
return {
|
||||
app,
|
||||
runBtn: win.document.getElementById('runBtn') as HTMLButtonElement,
|
||||
externalIds: Object.keys(win.__TEST_RUN_MODE_LAUNCH),
|
||||
};
|
||||
}
|
||||
|
||||
describe('run() dispatch (session-ui.js)', () => {
|
||||
const { externalIds } = loadHarness();
|
||||
|
||||
it('reads at least the eight external CLIs off RUN_MODE_LAUNCH (anti-vacuity)', () => {
|
||||
expect(externalIds.length).toBeGreaterThanOrEqual(8);
|
||||
expect(externalIds).not.toContain('claude');
|
||||
expect(externalIds).not.toContain('shell');
|
||||
});
|
||||
|
||||
it("'claude' reaches runClaude() and nothing else", async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = 'claude';
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'shell' reaches runShell(), which needs no CLI probe at all", async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = 'shell';
|
||||
await expect(app.run()).resolves.toBe('shell');
|
||||
expect(app.runShell).toHaveBeenCalledTimes(1);
|
||||
expect(app.runClaude).not.toHaveBeenCalled();
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const id of externalIds) {
|
||||
it(`'${id}' reaches _runCliMode('${id}') and never runClaude()`, async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = id;
|
||||
await expect(app.run()).resolves.toBe(`cli:${id}`);
|
||||
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).toHaveBeenCalledWith(id);
|
||||
expect(app.runClaude).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it('an unknown mode lands on runClaude(), never on the shared launcher', async () => {
|
||||
// `_runCliMode(mode)` reads `RUN_MODE_LAUNCH[mode].label` unguarded, so an
|
||||
// unknown id reaching it would throw rather than launch anything.
|
||||
for (const mode of ['nope', 'CLAUDE', 'code x']) {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = mode;
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('an unset or empty _runMode defaults to claude', async () => {
|
||||
for (const mode of [undefined, '']) {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = mode;
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the launch lock for the whole launch and releases it afterwards', async () => {
|
||||
const { app, runBtn } = loadHarness();
|
||||
app._runMode = 'codex';
|
||||
const pending = app.run();
|
||||
expect(app._runInFlight).toBe(true);
|
||||
expect(runBtn.disabled).toBe(true);
|
||||
expect(runBtn.getAttribute('aria-busy')).toBe('true');
|
||||
// A second click while the first launch is in flight is a no-op.
|
||||
await expect(app.run()).resolves.toBeUndefined();
|
||||
await pending;
|
||||
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._runInFlight).toBe(false);
|
||||
expect(runBtn.disabled).toBe(false);
|
||||
expect(runBtn.hasAttribute('aria-busy')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview `RUN_MODE_LAUNCH` (session-ui.js, PR B2) restates four things
|
||||
* `stock.ts` already owns: label, an install command, whether the CLI
|
||||
* supports a custom-model launch, and the external-mode key set itself.
|
||||
* They agree today, but nothing enforced it — the dangerous drift is
|
||||
* `supportsCustomModel`: the Run menu's "CLI (endpoint)" rows come from the
|
||||
* server-injected `window.__codemanCustomModelClis` (built from
|
||||
* `capabilities.customModelInjection.kind !== 'unsupported'`), so a CLI that
|
||||
* gains a real injection recipe later would be OFFERED in that menu while
|
||||
* `_runCliMode` still drops the `customModel` field for it — the session
|
||||
* launches on the vendor's cloud while the UI claims the local endpoint.
|
||||
*
|
||||
* Drives the REAL session-ui.js via JSDOM (`runScripts: 'dangerously'`, same
|
||||
* approach as test/custom-model-one-shot-launch.test.ts), extracting the
|
||||
* module-level `RUN_MODE_LAUNCH` const by appending one assignment line to
|
||||
* the SAME source string before the one `eval()` call — it is not attached
|
||||
* to `window` on its own (top-level `const` lives in the script's own
|
||||
* lexical scope, not the global object), and a SEPARATE later `eval()` call
|
||||
* cannot see an earlier call's top-level bindings either (measured: each
|
||||
* `window.eval()` invocation gets its own top-level lexical environment in
|
||||
* jsdom), so the assignment has to ride in the same evaluated string.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface RunModeLaunchEntry {
|
||||
label: string;
|
||||
installHint: string;
|
||||
supportsCustomModel: boolean;
|
||||
buildConfig: (globalSettings: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function loadRunModeLaunch(): Record<string, RunModeLaunchEntry> {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', runScripts: 'dangerously' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => unknown };
|
||||
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||
// The assignment MUST be part of the same evaluated string as
|
||||
// SESSION_UI_JS — RUN_MODE_LAUNCH is a bare top-level `const`, so it only
|
||||
// exists in the lexical scope of THIS eval call.
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`
|
||||
);
|
||||
return (win as unknown as { __TEST_RUN_MODE_LAUNCH: Record<string, RunModeLaunchEntry> }).__TEST_RUN_MODE_LAUNCH;
|
||||
}
|
||||
|
||||
describe('RUN_MODE_LAUNCH (session-ui.js) stays in step with stock.ts', () => {
|
||||
const runModeLaunch = loadRunModeLaunch();
|
||||
const byId = new Map(STOCK_CLIS.map((e) => [e.id as string, e]));
|
||||
|
||||
it('covers exactly the non-claude, non-shell stock CLIs — no more, no fewer', () => {
|
||||
const expectedIds = STOCK_CLIS.map((e) => e.id as string)
|
||||
.filter((id) => id !== 'claude' && id !== 'shell')
|
||||
.sort();
|
||||
expect(Object.keys(runModeLaunch).sort()).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('label matches CliEntry.label for every entry', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const stockEntry = byId.get(id);
|
||||
expect(stockEntry, `no stock entry for ${id}`).toBeTruthy();
|
||||
expect(entry.label, `${id} label drifted from stock.ts`).toBe(stockEntry!.label);
|
||||
}
|
||||
});
|
||||
|
||||
it('installHint embeds the real linux install command', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const command = byId.get(id)!.discovery.install.command?.linux;
|
||||
if (!command) continue; // shell-less entries (none today) carry no command to check
|
||||
expect(entry.installHint, `${id} installHint no longer matches stock.ts's linux install command`).toContain(
|
||||
command
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsCustomModel matches capabilities.customModelInjection.kind !== "unsupported"', () => {
|
||||
// This is the one that fails SILENTLY if it drifts (see file header):
|
||||
// window.__codemanCustomModelClis (server.ts) is built from this same
|
||||
// stock.ts field, so a mismatch here means the Run-menu picker and the
|
||||
// actual launch body disagree about which CLIs are custom-model-capable.
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const supported = byId.get(id)!.capabilities.customModelInjection.kind !== 'unsupported';
|
||||
expect(entry.supportsCustomModel, `${id}.supportsCustomModel drifted from stock.ts's capability`).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -166,8 +166,18 @@ describe('Run launch synchronization', () => {
|
||||
|
||||
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||
//
|
||||
// `(\w*)` in the param list, not `()`, and the leading `_?`: PR B2
|
||||
// consolidated the eight run<Mode>() bodies into one shared
|
||||
// `_runCliMode(mode)`, and the ORIGINAL `\(\)`-only pattern matched every
|
||||
// one-line wrapper (`async runOpenCode() { return this._runCliMode(...) }`)
|
||||
// but not `_runCliMode` itself, where the real terminal-ownership logic
|
||||
// now lives — so this guard could see 8 clean one-liners and stay green
|
||||
// while the actual bug shipped unseen for all eight external CLIs at
|
||||
// once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode`
|
||||
// left this test 32/32 green under the old pattern.
|
||||
const bodies = new Map<string, string>();
|
||||
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||
const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm;
|
||||
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||
const start = m.index + m[0].length;
|
||||
const end = src.indexOf('\n },', start);
|
||||
@@ -181,6 +191,7 @@ describe('Run launch synchronization', () => {
|
||||
expect.arrayContaining([
|
||||
'runClaude',
|
||||
'runShell',
|
||||
'_runCliMode',
|
||||
'runOpenCode',
|
||||
'runCodex',
|
||||
'runGemini',
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// test/split-pane-auto-collapse-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like session-close-fallback.test.ts).
|
||||
//
|
||||
// Fast, CI-visible unit coverage for the `_onSessionDeleted` prototype patch in
|
||||
// terminal-split.js (whole-branch review finding I6). The "Pane B ends" branch
|
||||
// already has real-Chromium coverage in test/split-pane-auto-collapse.browser.test.ts,
|
||||
// but that suite is excluded from `npm test` (see Testing in CLAUDE.md), and the
|
||||
// "Pane A ends, Pane B gets promoted" branch had NO coverage anywhere — it is the
|
||||
// one whose correctness depends on exact ordering: `_splitSessionId` must be
|
||||
// captured BEFORE `closeSplitPane()` runs (which nulls it), or the promoted
|
||||
// session id is lost. This file pins that ordering plus the sibling branches
|
||||
// (Pane B ends, unrelated session ends) so a regression fails in the normal CI
|
||||
// gate rather than only in the browser suite nobody runs by default.
|
||||
//
|
||||
// The same vm harness also pins two merge-time fixes from the final review of
|
||||
// #453 that no browser test reaches: closeSplitPane() tearing down a divider
|
||||
// drag that is still in progress (the body-level `split-pane-resizing` lock
|
||||
// otherwise outlived the split), and openSplitPane() re-applying the picker's
|
||||
// own exclusions for a row that went stale while the menu sat open.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** A class-list stub backed by a Set, enough for add/remove/contains. */
|
||||
function fakeClassList() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (c: string) => void classes.add(c),
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
contains: (c: string) => classes.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The only `document` surface the methods under test touch: `body.classList`
|
||||
* (the drag's cursor/selection lock) and `querySelector` (the split container
|
||||
* and the header button, both absent here, which is the "already collapsed /
|
||||
* no button rendered" path every early-return in closeSplitPane() takes).
|
||||
* `querySelector` is reassignable per test so a test can plant a sentinel.
|
||||
*/
|
||||
const fakeDocument = {
|
||||
body: { classList: fakeClassList() },
|
||||
querySelector: (_selector: string): unknown => null,
|
||||
getElementById: (_id: string): unknown => null,
|
||||
};
|
||||
|
||||
const rafCalls: Array<() => void> = [];
|
||||
const cancelledRafs: number[] = [];
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
// innerWidth clears the desktop-only gate so openSplitPane() reaches the
|
||||
// exclusions under test; SPLIT_PANE_MIN_WIDTH is the bare global
|
||||
// constants.js would otherwise define.
|
||||
window: { innerWidth: 1600 },
|
||||
SPLIT_PANE_MIN_WIDTH: 1180,
|
||||
document: fakeDocument,
|
||||
requestAnimationFrame: (fn: () => void) => rafCalls.push(fn),
|
||||
cancelAnimationFrame: (id: number) => cancelledRafs.push(id),
|
||||
});
|
||||
// A minimal fake CodemanApp — terminal-split.js only needs `_onSessionDeleted`
|
||||
// and `selectSession` to already exist on the prototype (it wraps both), and
|
||||
// neither wrapped body executes at module-load time (only inside method
|
||||
// calls), so no xterm/WebSocket/CodemanSplitPane globals are needed here.
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
_onSessionDeleted(data) {
|
||||
(this.__originalDeletedCalls ??= []).push(data);
|
||||
}
|
||||
selectSession(id) {
|
||||
(this.__originalSelectSessionCalls ??= []).push(id);
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(`${fakeAppSrc}\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp as {
|
||||
prototype: {
|
||||
_onSessionDeleted: (this: unknown, data: { id: string }) => unknown;
|
||||
closeSplitPane: (this: unknown, options?: { skipPrimaryResize?: boolean }) => unknown;
|
||||
openSplitPane: (this: unknown, sessionId: string) => unknown;
|
||||
_installSplitDividerDrag: (this: unknown, divider: unknown, wrap: unknown, paneB: unknown) => unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
type TestApp = {
|
||||
activeSessionId: string | null;
|
||||
_splitSessionId: string | null;
|
||||
_splitPane: { destroy: ReturnType<typeof vi.fn> } | null;
|
||||
_closingSessions: Set<string>;
|
||||
closeSplitPane: ReturnType<typeof vi.fn>;
|
||||
selectSession: ReturnType<typeof vi.fn>;
|
||||
__originalDeletedCalls?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
/** A split-active instance: Pane A === activeSessionId, Pane B === _splitSessionId. */
|
||||
function makeSplitActiveApp(): TestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
// Empty by default: the app's OWN closeSession() is not mid-await for this
|
||||
// delete, so the promotion below is expected to fire. See the dedicated
|
||||
// test further down for the non-empty (_closingSessions owns it) case.
|
||||
app._closingSessions = new Set();
|
||||
// closeSplitPane is mocked but mirrors the REAL implementation's one
|
||||
// observable side effect relevant here: it nulls _splitPane/_splitSessionId.
|
||||
// If the wrapper captured _splitSessionId AFTER calling closeSplitPane
|
||||
// instead of before, this would surface as selectSession(undefined) below.
|
||||
app.closeSplitPane = vi.fn(() => {
|
||||
app._splitPane = null;
|
||||
app._splitSessionId = null;
|
||||
});
|
||||
app.selectSession = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('terminal-split.js _onSessionDeleted wrapper (I6)', () => {
|
||||
it('Pane A ends: closes the split and promotes Pane B via selectSession(ORIGINAL splitSessionId)', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
// activeSessionId is still the deleted id while the split collapses (the
|
||||
// original handler, called last, is what retires it), so the closing
|
||||
// resize must be skipped or it targets a session the server already
|
||||
// removed; selectSession() below sizes the promoted session itself.
|
||||
expect(app.closeSplitPane).toHaveBeenCalledWith({ skipPrimaryResize: true });
|
||||
// Pinned ordering: selectSession must receive the id _splitSessionId held
|
||||
// BEFORE closeSplitPane ran (which nulls it), not whatever it holds after.
|
||||
// { auto: true } because this is an app-driven promotion, not the user
|
||||
// clicking a tab — it must not spend the promoted session's idle alert.
|
||||
expect(app.selectSession).toHaveBeenCalledWith('session-b', { auto: true });
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane B ends: closes the split without promoting anything', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-b' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
// Pane A's session is alive and stays active: the closing resize is
|
||||
// wanted here, so no skip option must be passed.
|
||||
expect(app.closeSplitPane).toHaveBeenCalledWith();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-b' }]);
|
||||
});
|
||||
|
||||
it('an unrelated session ending leaves the split untouched', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-c' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).not.toBeNull();
|
||||
expect(app._splitSessionId).toBe('session-b');
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-c' }]);
|
||||
});
|
||||
|
||||
it('the original _onSessionDeleted always fires, split-active or not', () => {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = null;
|
||||
app._splitPane = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane A ends via the user closing its OWN tab: still collapses the split, but skips the promotion', () => {
|
||||
// closeSession() (app.js) adds the id to _closingSessions BEFORE awaiting
|
||||
// the delete, then owns the follow-up selection itself once it lands —
|
||||
// selecting Pane B's session here too would race it for which tab wins.
|
||||
const app = makeSplitActiveApp();
|
||||
app._closingSessions.add('session-a');
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── closeSplitPane() vs a divider drag in progress ──────────────────────────
|
||||
|
||||
type DragEl = ReturnType<typeof fakeElement>;
|
||||
|
||||
/** A DOM element stub: class list, a listener registry, pointer-capture spies. */
|
||||
function fakeElement() {
|
||||
const listeners = new Map<string, Set<(e: unknown) => void>>();
|
||||
return {
|
||||
style: {} as Record<string, string>,
|
||||
classList: fakeClassList(),
|
||||
parentElement: null as unknown,
|
||||
listeners,
|
||||
addEventListener(type: string, fn: (e: unknown) => void) {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (e: unknown) => void) {
|
||||
listeners.get(type)?.delete(fn);
|
||||
},
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function listenerCount(el: DragEl, type: string): number {
|
||||
return el.listeners.get(type)?.size ?? 0;
|
||||
}
|
||||
|
||||
type DragTestApp = TestApp & {
|
||||
_splitDividerDragTeardown?: (() => void) | null;
|
||||
sendResize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('closeSplitPane() tears down a divider drag that is still in progress', () => {
|
||||
afterEach(() => {
|
||||
fakeDocument.querySelector = () => null;
|
||||
fakeDocument.body.classList.remove('split-pane-resizing');
|
||||
});
|
||||
|
||||
/**
|
||||
* A split-active app with the REAL _installSplitDividerDrag() wired to stub
|
||||
* elements, then armed the way the browser arms it: a primary-button
|
||||
* pointerdown on the divider. `document.querySelector` hands closeSplitPane()
|
||||
* a stub container so it runs its full body (reparent, remove, resize) rather
|
||||
* than the already-collapsed early return.
|
||||
*/
|
||||
function makeDraggingApp() {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as DragTestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
app._closingSessions = new Set();
|
||||
app.sendResize = vi.fn(() => Promise.resolve(true));
|
||||
const divider = fakeElement();
|
||||
const wrap = fakeElement();
|
||||
const paneB = fakeElement();
|
||||
const container = { querySelector: () => wrap, parentElement: { insertBefore: vi.fn() }, remove: vi.fn() };
|
||||
fakeDocument.querySelector = (selector: string) => (selector === '.terminal-split-container' ? container : null);
|
||||
|
||||
CodemanApp.prototype._installSplitDividerDrag.call(app, divider, wrap, paneB);
|
||||
const [onDown] = divider.listeners.get('pointerdown')!;
|
||||
onDown({ button: 0, pointerId: 7, preventDefault: vi.fn() });
|
||||
return { app, divider, container };
|
||||
}
|
||||
|
||||
it('precondition: pointerdown arms the page-wide resize lock, capture and the drag listeners', () => {
|
||||
const { divider } = makeDraggingApp();
|
||||
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(true);
|
||||
expect(divider.classList.contains('dragging')).toBe(true);
|
||||
expect(divider.setPointerCapture).toHaveBeenCalledWith(7);
|
||||
for (const type of ['pointermove', 'pointerup', 'pointercancel']) {
|
||||
expect(listenerCount(divider, type), type).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears body.split-pane-resizing, releases capture and drops the drag listeners', () => {
|
||||
const { app, divider, container } = makeDraggingApp();
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app);
|
||||
|
||||
// The lock is `cursor: col-resize; user-select: none` on EVERY element
|
||||
// (styles.css); left set, it outlives the split until a reload.
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(false);
|
||||
expect(divider.classList.contains('dragging')).toBe(false);
|
||||
expect(divider.releasePointerCapture).toHaveBeenCalledWith(7);
|
||||
for (const type of ['pointermove', 'pointerup', 'pointercancel']) {
|
||||
expect(listenerCount(divider, type), type).toBe(0);
|
||||
}
|
||||
expect(app._splitDividerDragTeardown).toBeNull();
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(container.remove).toHaveBeenCalledTimes(1);
|
||||
// An ordinary close still resizes the (live) primary pane's session.
|
||||
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||
});
|
||||
|
||||
it('cancels a reflow frame the drag had queued', () => {
|
||||
const { app, divider } = makeDraggingApp();
|
||||
const [onMove] = divider.listeners.get('pointermove')!;
|
||||
onMove({ clientX: 400 });
|
||||
const queuedRafId = rafCalls.length;
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app);
|
||||
|
||||
expect(cancelledRafs).toContain(queuedRafId);
|
||||
});
|
||||
|
||||
it('closeSplitPane({ skipPrimaryResize: true }) collapses without resizing the primary session', () => {
|
||||
const { app, container } = makeDraggingApp();
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app, { skipPrimaryResize: true });
|
||||
|
||||
expect(app.sendResize).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(container.remove).toHaveBeenCalledTimes(1);
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── openSplitPane() re-applies the picker's exclusions ──────────────────────
|
||||
|
||||
type OpenTestApp = TestApp & {
|
||||
activeWebviewId: string | null;
|
||||
sessions: Map<string, { pid: number | null; name?: string }>;
|
||||
detachedSessions: Set<string>;
|
||||
};
|
||||
|
||||
describe('openSplitPane() re-applies the picker exclusions at open time', () => {
|
||||
// buildSplitPickerSessions() (constants.js) never lists a detached session
|
||||
// or one with `pid === null`, but the menu can sit open while a listed
|
||||
// session's CLI exits or gets popped out, and the row's click carries only
|
||||
// the id. openSplitPane() must refuse those the same way the picker would
|
||||
// have (silently, like its neighbouring gates) BEFORE touching the DOM.
|
||||
const DOM_REACHED = 'sentinel: openSplitPane reached the DOM stage';
|
||||
|
||||
function makeApp(): OpenTestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as OpenTestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app.activeWebviewId = null;
|
||||
app._splitPane = null;
|
||||
app._splitSessionId = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
app.sessions = new Map([
|
||||
['session-a', { pid: 101, name: 'w1-active' }],
|
||||
['session-exited', { pid: null, name: 'w2-exited' }],
|
||||
['session-detached', { pid: 103, name: 'w3-detached' }],
|
||||
['session-ok', { pid: 104, name: 'w4-ok' }],
|
||||
]);
|
||||
app.detachedSessions = new Set(['session-detached']);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeDocument.querySelector = vi.fn(() => {
|
||||
throw new Error(DOM_REACHED);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
fakeDocument.querySelector = () => null;
|
||||
});
|
||||
|
||||
it('control: a listed, attached session gets past every guard to the DOM stage', () => {
|
||||
const app = makeApp();
|
||||
expect(() => CodemanApp.prototype.openSplitPane.call(app, 'session-ok')).toThrow(DOM_REACHED);
|
||||
expect(fakeDocument.querySelector).toHaveBeenCalledWith('.terminal-wrap');
|
||||
});
|
||||
|
||||
it('refuses a session whose CLI has exited (pid === null) without touching the DOM', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-exited')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a detached (popped-out) session', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-detached')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an id with no session record at all', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-ghost')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('a refusal leaves an already-open split alone', () => {
|
||||
const app = makeApp();
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
app._splitSessionId = 'session-ok';
|
||||
|
||||
CodemanApp.prototype.openSplitPane.call(app, 'session-exited');
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).not.toBeNull();
|
||||
expect(app._splitSessionId).toBe('session-ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// test/split-pane-auto-collapse.browser.test.ts
|
||||
/** @fileoverview Real Chromium coverage for split auto-collapse when either session ends (Task 6). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3177;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane auto-collapse 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(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('deleting the Pane B session auto-collapses the split', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
// Delete Pane B's session from "outside" (simulating the SSE event another
|
||||
// client's delete would produce, by hitting the DELETE route directly).
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idB);
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const collapsed = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
splitPaneNulled: (window as any).app._splitPane === null,
|
||||
}));
|
||||
expect(collapsed.hasContainer).toBe(true);
|
||||
expect(collapsed.splitPaneNulled).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadSplitPaneHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanSplitPane: any }).CodemanSplitPane;
|
||||
}
|
||||
|
||||
describe('CodemanSplitPane.clampDividerPercent', () => {
|
||||
it('passes through a value inside the clamp range', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(50)).toBe(50);
|
||||
expect(clampDividerPercent(35.5)).toBe(35.5);
|
||||
});
|
||||
|
||||
it('clamps below the floor to the floor', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(5)).toBe(20);
|
||||
});
|
||||
|
||||
it('clamps above the ceiling to the ceiling', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(95)).toBe(80);
|
||||
});
|
||||
|
||||
it('honors custom min/max', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(10, 15, 85)).toBe(15);
|
||||
expect(clampDividerPercent(90, 15, 85)).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanSplitPane.buildSplitPickerSessions', () => {
|
||||
it('excludes the active session and preserves tab order', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w1-mcp-memory' }],
|
||||
['c', { name: null }],
|
||||
]);
|
||||
const sessionOrder = ['a', 'b', 'c'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, 'a');
|
||||
expect(result).toEqual([
|
||||
{ id: 'b', label: 'w1-mcp-memory' },
|
||||
{ id: 'c', label: 'Session' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops order entries with no matching session (stale ids)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const sessionOrder = ['a', 'ghost'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, null);
|
||||
expect(result).toEqual([{ id: 'a', label: 'w1-codeman' }]);
|
||||
});
|
||||
|
||||
it('returns an empty list when only the excluded session exists', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes a session with no PTY attached (pid === null)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-exited', pid: null }],
|
||||
['c', { name: 'w3-alive', pid: 12345 }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b', 'c'], 'a');
|
||||
expect(result).toEqual([{ id: 'c', label: 'w3-alive' }]);
|
||||
});
|
||||
|
||||
it('excludes a detached session even when it also has no PTY', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-detached', pid: null }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b'], 'a', new Set(['b']));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// test/split-pane-hidden-button-css.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the split-pane whole-branch review finding C1: the
|
||||
// header ships `.btn-split.btn-split--hidden` in index.html (an opt-in
|
||||
// header button, gated behind `showSplitButton`), but no CSS anywhere gave
|
||||
// `--hidden` markers meaning for that class, so the Split button rendered
|
||||
// VISIBLE to every user on every viewport regardless of the setting.
|
||||
//
|
||||
// Every OTHER opt-in header button follows a marker-class pattern: the base
|
||||
// rule is `display:inline-flex !important` and a more-specific
|
||||
// `.btn-x.btn-x--hidden { display: none !important; }` rule hides it
|
||||
// (`.btn-multimonitor--hidden` etc. in styles.css). C1 fixed the missing rule
|
||||
// for `.btn-split--hidden`; this test is the guard so the NEXT such class
|
||||
// fails loudly here instead of shipping invisible-until-noticed, the same
|
||||
// static-parse shape as test/mobile-header-buttons-policy.test.ts (read
|
||||
// first for the parsing conventions this file reuses).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
/** Every distinct `*--hidden` class token referenced anywhere in index.html. */
|
||||
function loadHiddenMarkerClasses(): Set<string> {
|
||||
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
||||
const classes = new Set<string>();
|
||||
for (const m of html.matchAll(/class="([^"]*)"/g)) {
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token.endsWith('--hidden')) classes.add(token);
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*--hidden` class that has a CSS rule (anywhere — top-level or inside
|
||||
* any at-rule, e.g. a phone-only @media block) whose selector targets that
|
||||
* exact class and whose declarations set `display: none` (with or without
|
||||
* `!important`).
|
||||
*/
|
||||
function loadCssHiddenClasses(cssFile: string): Set<string> {
|
||||
const css = readFileSync(join(PUBLIC, cssFile), 'utf-8');
|
||||
const hidden = new Set<string>();
|
||||
postcss.parse(css).walkRules((rule) => {
|
||||
let hides = false;
|
||||
rule.walkDecls('display', (decl) => {
|
||||
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
||||
});
|
||||
if (!hides) return;
|
||||
for (const token of rule.selector.match(/\.[a-z0-9-]*--hidden\b/gi) || []) {
|
||||
hidden.add(token.slice(1));
|
||||
}
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
describe('Every "*--hidden" marker class has a matching CSS hide rule (static guard)', () => {
|
||||
const markerClasses = loadHiddenMarkerClasses();
|
||||
const cssHidden = new Set([...loadCssHiddenClasses('styles.css'), ...loadCssHiddenClasses('mobile.css')]);
|
||||
|
||||
it('finds at least one *--hidden marker class in index.html (sanity)', () => {
|
||||
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
||||
expect(markerClasses.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every "*--hidden" class in index.html has a `display: none` rule in styles.css or mobile.css', () => {
|
||||
for (const cls of markerClasses) {
|
||||
expect(
|
||||
cssHidden.has(cls),
|
||||
`index.html references class "${cls}" (an opt-in-hide marker) but no rule in styles.css or ` +
|
||||
`mobile.css sets "display: none" for it — the element it marks ships VISIBLE regardless of the ` +
|
||||
`setting that is supposed to gate it. Add ".${cls} { display: none !important; }" (see the sibling ` +
|
||||
`.btn-multimonitor--hidden / .btn-redraw-terminal--hidden rules in styles.css for the pattern).`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the split-pane Split button specifically (C1 regression)', () => {
|
||||
expect(markerClasses.has('btn-split--hidden')).toBe(true);
|
||||
expect(cssHidden.has('btn-split--hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
|
||||
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 = 3176;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane orchestration 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(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('opening and closing a split reparents and restores .terminal-wrap', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const duringSplit = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') !== null,
|
||||
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
|
||||
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
|
||||
}));
|
||||
expect(duringSplit.hasContainer).toBe(true);
|
||||
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
|
||||
expect(duringSplit.hasPaneB).toBe(true);
|
||||
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const afterClose = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
|
||||
}));
|
||||
expect(afterClose.hasContainer).toBe(true);
|
||||
expect(afterClose.wrapRestored).toBe(true);
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('the split picker excludes the active session', async () => {
|
||||
const id = await createShellSession();
|
||||
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
|
||||
|
||||
const pickerExcludesActive = await page.evaluate((sid) => {
|
||||
(window as any).app.openSplitPicker();
|
||||
const items = Array.from(document.querySelectorAll('.split-picker-item'));
|
||||
return !items.some((el) => el.getAttribute('data-session-id') === sid);
|
||||
}, id);
|
||||
expect(pickerExcludesActive).toBe(true);
|
||||
|
||||
await page.evaluate(async (sid) => {
|
||||
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
|
||||
}, id);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A immediately when a split opens', async () => {
|
||||
// Regression guard: opening a split moved Pane A from full width to 50%
|
||||
// in the DOM, but nothing told its session's PTY/tmux window about the
|
||||
// new size — only the passive, 300ms-debounced ResizeObserver in
|
||||
// terminal-ui.js eventually caught up, leaving stale-width content on
|
||||
// screen until the user manually hit "Redraw Terminal". openSplitPane()
|
||||
// now force-resizes Pane A synchronously as part of the same call.
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A once at the end of a divider drag', async () => {
|
||||
// Regression guard: the divider's onMove handler only called
|
||||
// fitAddon.fit() for Pane A — a LOCAL xterm reflow that never told Pane
|
||||
// A's own PTY/tmux window the new size, so existing content stayed laid
|
||||
// out for the pre-drag width. onUp now force-resizes Pane A once, at
|
||||
// drag end (not per-move, to avoid flooding the PTY with SIGWINCHes
|
||||
// during a fast drag).
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.split-divider', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
const divider = await page.$('.split-divider');
|
||||
const box = await divider!.boundingBox();
|
||||
if (!box) throw new Error('divider has no bounding box');
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 80, startY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// test/split-pane-per-device-setting.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the review finding that landed the blocker: moving
|
||||
// showSplitButton into settings-ui.js's per-device `displayKeys` set is only
|
||||
// HALF of making a setting per-device. The other half is stripping it out of
|
||||
// the object `saveAppSettings()` PUTs to `/api/settings` — displayKeys is a
|
||||
// client-side merge policy, not a wire filter. Without the strip, every save
|
||||
// sent `showSplitButton` in the body, `SettingsUpdateSchema` (.strict()) does
|
||||
// not declare it, the server answered 400 INVALID_INPUT, and because the
|
||||
// call site never checked `res.ok` the UI still reported "Settings saved"
|
||||
// while NOTHING persisted — workspaceHooksEnabled, agentSkillEnabled,
|
||||
// tunnelEnabled, claudeModel, every toggle, on every save, on every device.
|
||||
//
|
||||
// Mirrors test/terminal-auto-copy.test.ts's "keeps the toggle per-device"
|
||||
// guard for autoCopySelection — same three-way rule, same shape of test.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
function read(file: string): string {
|
||||
return readFileSync(join(PUBLIC, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('showSplitButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
|
||||
|
||||
it('is in the client-side displayKeys merge policy', () => {
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'showSplitButton'");
|
||||
});
|
||||
|
||||
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
|
||||
// The strip is a destructure: `showSplitButton: _ssp,` pulls the key out
|
||||
// of `settings` so it never reaches `...serverSettings` in the PUT body.
|
||||
expect(settingsUi).toContain('showSplitButton: _ssp,');
|
||||
});
|
||||
|
||||
it('is never declared in the .strict() SettingsUpdateSchema', () => {
|
||||
// Not even in a comment — a mention there reads as "this is a real
|
||||
// field" to the next person grepping schemas.ts for it.
|
||||
expect(schemas).not.toContain('showSplitButton');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// test/split-pane-picker-listener-leak-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like split-pane-auto-collapse-unit.test.ts).
|
||||
//
|
||||
// Fast, CI-visible regression coverage for a listener leak in `openSplitPicker()` /
|
||||
// `_dismissSplitPicker()` (terminal-split.js): repeatedly opening the split picker
|
||||
// (e.g. clicking the `.btn-split` button, whose real clicks land on its inner
|
||||
// <svg>) used to leave a `click`/`keydown` listener pair attached to `document`
|
||||
// on every cycle, because a pre-existing menu was torn down with a raw
|
||||
// `existing.remove()` instead of through `_dismissSplitPicker()`, and the
|
||||
// single-slot `_splitPickerDismissHandlers` field was overwritten rather than
|
||||
// used to clean up the previous pair first.
|
||||
//
|
||||
// This is a `vm`-driven DOM-listener-count assertion rather than a real-browser
|
||||
// interaction test (`test/split-pane-orchestration.browser.test.ts` already
|
||||
// covers the real click-through-svg interaction and is excluded from `npm test`
|
||||
// per CLAUDE.md's Testing section) — it exercises the exact document
|
||||
// addEventListener/removeEventListener calls the fix and the bug both hinge on,
|
||||
// with no xterm/WebSocket/tmux involved.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** A tiny fake DOM element: enough surface for createElement/appendChild/contains/remove/closest. */
|
||||
function makeFakeElement(tag: string) {
|
||||
const children: Array<ReturnType<typeof makeFakeElement>> = [];
|
||||
let id: string | undefined;
|
||||
let parent: ReturnType<typeof makeFakeElement> | null = null;
|
||||
const el = {
|
||||
tag,
|
||||
style: {} as Record<string, string>,
|
||||
innerHTML: '',
|
||||
className: '',
|
||||
get id() {
|
||||
return id;
|
||||
},
|
||||
set id(v: string | undefined) {
|
||||
id = v;
|
||||
},
|
||||
appendChild(child: ReturnType<typeof makeFakeElement>) {
|
||||
children.push(child);
|
||||
(child as { _parent: unknown })._parent = el;
|
||||
return child;
|
||||
},
|
||||
contains(target: unknown): boolean {
|
||||
if (target === el) return true;
|
||||
return children.some((c) => c.contains(target));
|
||||
},
|
||||
// Real Element.closest(): walk up from THIS element (inclusive), matching
|
||||
// a bare class selector like `.btn-split` against a space-separated
|
||||
// className — enough to exercise the fix's `e.target.closest('.btn-split')`
|
||||
// check without a real DOM.
|
||||
closest(selector: string): ReturnType<typeof makeFakeElement> | null {
|
||||
const cls = selector.replace(/^\./, '');
|
||||
let node: ReturnType<typeof makeFakeElement> | null = el;
|
||||
while (node) {
|
||||
if (node.className.split(/\s+/).includes(cls)) return node;
|
||||
node = (node as unknown as { __parent: ReturnType<typeof makeFakeElement> | null }).__parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
remove() {
|
||||
if (parent) {
|
||||
parent = null;
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { bottom: 0, right: 0, left: 0, top: 0, width: 0, height: 0 };
|
||||
},
|
||||
set _parent(p: ReturnType<typeof makeFakeElement>) {
|
||||
parent = p;
|
||||
(el as unknown as { __parent: ReturnType<typeof makeFakeElement> }).__parent = p;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A minimal `document` mock tracking real addEventListener/removeEventListener identity. */
|
||||
function makeFakeDocument() {
|
||||
const elementsById = new Map<string, ReturnType<typeof makeFakeElement>>();
|
||||
const listeners: Record<string, Array<(...args: unknown[]) => unknown>> = {
|
||||
click: [],
|
||||
keydown: [],
|
||||
};
|
||||
|
||||
return {
|
||||
createElement(tag: string) {
|
||||
return makeFakeElement(tag);
|
||||
},
|
||||
body: {
|
||||
appendChild(el: ReturnType<typeof makeFakeElement>) {
|
||||
if (el.id) elementsById.set(el.id, el);
|
||||
},
|
||||
},
|
||||
getElementById(id: string) {
|
||||
const el = elementsById.get(id);
|
||||
if (el) {
|
||||
// Support the real `?.remove()` call site removing it from the registry.
|
||||
const originalRemove = el.remove.bind(el);
|
||||
el.remove = () => {
|
||||
elementsById.delete(id);
|
||||
originalRemove();
|
||||
};
|
||||
}
|
||||
return el;
|
||||
},
|
||||
querySelector(sel: string) {
|
||||
return sel === '.btn-split' ? makeFakeElement('button') : null;
|
||||
},
|
||||
addEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
(listeners[type] ??= []).push(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
__listenerCount(type: string) {
|
||||
return (listeners[type] ?? []).length;
|
||||
},
|
||||
// Test-only helpers: fire a snapshot of the currently-registered
|
||||
// listeners (a handler removing itself mid-dispatch must not skip or
|
||||
// double-invoke a sibling — snapshotting avoids that ambiguity here).
|
||||
__dispatchClick(target: unknown) {
|
||||
for (const fn of [...(listeners.click ?? [])]) fn({ target });
|
||||
},
|
||||
__dispatchKeydown(key: string) {
|
||||
for (const fn of [...(listeners.keydown ?? [])]) fn({ key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadCodemanAppClass(documentMock: ReturnType<typeof makeFakeDocument>) {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: { innerWidth: 1024 },
|
||||
document: documentMock,
|
||||
setTimeout,
|
||||
// terminal-split.js's picker markup calls the global `escapeHtml()` helper
|
||||
// (defined in constants.js at runtime); a plain identity stub is enough
|
||||
// here since no candidate labels/ids in this test contain HTML.
|
||||
escapeHtml: (s: unknown) => String(s),
|
||||
});
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.sessionOrder = [];
|
||||
this.activeSessionId = 'active-session';
|
||||
this._splitPane = null;
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(
|
||||
`${fakeAppSrc}\nwindow.CodemanSplitPane = { buildSplitPickerSessions: () => [] };\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (
|
||||
context as { __CodemanApp: new () => { openSplitPicker: (e?: unknown) => void; _dismissSplitPicker: () => void } }
|
||||
).__CodemanApp;
|
||||
}
|
||||
|
||||
describe('terminal-split.js split-picker document-listener leak (repeated open/close)', () => {
|
||||
let documentMock: ReturnType<typeof makeFakeDocument>;
|
||||
let CodemanApp: ReturnType<typeof loadCodemanAppClass>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
documentMock = makeFakeDocument();
|
||||
CodemanApp = loadCodemanAppClass(documentMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not grow document click/keydown listeners across repeated open cycles with no dismissal', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
// Simulates the real failure sequence: the button's onclick re-fires
|
||||
// `openSplitPicker()` on every click (e.g. landing on the button's inner
|
||||
// <svg>) without an intervening outside click or Escape ever resolving.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers(); // flush the deferred `setTimeout(() => addEventListener('click', ...))`
|
||||
}
|
||||
|
||||
// Exactly one pair should be live — the CURRENT menu's — never one per cycle.
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('drops to zero listeners after the outside-click handler fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
app._dismissSplitPicker();
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('never accumulates listeners across many open→dismiss cycles', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
app._dismissSplitPicker();
|
||||
}
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it("does not dismiss when the click lands on the split button's own inner icon (closest() match, not exact-node equality)", () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
// Mirrors the real DOM: the button carries the onclick and the
|
||||
// `.btn-split` class, but the actual click target is its inner <svg>.
|
||||
// The old `e.target !== splitBtn` check matched this (target !== button)
|
||||
// and dismissed the menu the same click had just (re)opened.
|
||||
const button = makeFakeElement('button');
|
||||
button.className = 'btn-split';
|
||||
const svg = button.appendChild(makeFakeElement('svg'));
|
||||
|
||||
documentMock.__dispatchClick(svg);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when a genuine outside click fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
const outside = makeFakeElement('div');
|
||||
documentMock.__dispatchClick(outside);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when Escape fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
|
||||
documentMock.__dispatchKeydown('Escape');
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// test/split-pane-terminal-unit.test.ts
|
||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
||||
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
||||
//
|
||||
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from
|
||||
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
||||
// handler (onclose used to survive it and fire its "disconnected" write into a
|
||||
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
||||
// single-flight. Two refresh frames in a row used to start two concurrent
|
||||
// replays, each clearing the terminal under the other's chunked write; a
|
||||
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
||||
// than dropped, because the in-flight fetch may predate the drop the new frame
|
||||
// reports and no further frame comes to correct stale content.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||
|
||||
type FakeTerminal = {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type FakeSocket = {
|
||||
onopen: unknown;
|
||||
onmessage: unknown;
|
||||
onclose: unknown;
|
||||
onerror: unknown;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type PaneUnderTest = {
|
||||
ws: FakeSocket | null;
|
||||
terminal: FakeTerminal | null;
|
||||
_destroyed: boolean;
|
||||
_bufferLoading: boolean;
|
||||
_bufferRefreshPending: boolean;
|
||||
destroy(): void;
|
||||
_loadBuffer(): Promise<void>;
|
||||
_refreshBuffer(): void;
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: {},
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE: 1024 * 1024,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
|
||||
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', {}, { mode });
|
||||
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
|
||||
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
||||
}
|
||||
|
||||
function jsonResponse(terminalBuffer: string) {
|
||||
return { json: async () => ({ data: { terminalBuffer } }) };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Lets every microtask the vm-side promise chain queued run. */
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||
const pane = makePane();
|
||||
const terminal = pane.terminal;
|
||||
const ws: FakeSocket = { onopen: vi.fn(), onmessage: vi.fn(), onclose: vi.fn(), onerror: vi.fn(), close: vi.fn() };
|
||||
pane.ws = ws;
|
||||
|
||||
pane.destroy();
|
||||
|
||||
// close() fires onclose asynchronously, so a handler left attached ran its
|
||||
// "disconnected" write against a pane whose terminal was already disposed.
|
||||
expect(ws.onopen).toBeNull();
|
||||
expect(ws.onmessage).toBeNull();
|
||||
expect(ws.onclose).toBeNull();
|
||||
expect(ws.onerror).toBeNull();
|
||||
expect(ws.close).toHaveBeenCalledTimes(1);
|
||||
expect(pane.ws).toBeNull();
|
||||
expect(terminal.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(pane.terminal).toBeNull();
|
||||
expect(pane._destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1');
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('one');
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a shell pane asks for the bounded tail, matching connect()', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('tail'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`);
|
||||
});
|
||||
|
||||
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
|
||||
const pane = makePane();
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
const second = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Two more frames while the first replay is still in flight.
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
|
||||
first.resolve(jsonResponse('replay-1'));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('replay-1');
|
||||
// Exactly one trailing re-run for the two coalesced frames, not two.
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
second.resolve(jsonResponse('replay-2'));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._bufferRefreshPending).toBe(false);
|
||||
});
|
||||
|
||||
it('holds the flag across the chunked write, not just the fetch', async () => {
|
||||
const pane = makePane();
|
||||
// Three chunks: two full ones plus a tail, so the last two are queued on
|
||||
// requestAnimationFrame and the replay is mid-write after the fetch lands.
|
||||
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(big));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
expect(pane._bufferLoading).toBe(true);
|
||||
|
||||
// A refresh mid-write must not clear the terminal under the chunks still
|
||||
// to come, nor start a second fetch.
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(4);
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a pending refresh is dropped once the pane is destroyed', async () => {
|
||||
const pane = makePane();
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer();
|
||||
pane.destroy();
|
||||
first.resolve(jsonResponse('late'));
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag so the next refresh can run', async () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('back'));
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
||||
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 = 3175;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('SplitTerminalPane 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(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('connects, echoes real PTY output, and cleans up on destroy', async () => {
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
// Session creation alone leaves pid:null and no pane (per CLAUDE.md's
|
||||
// Testing section) — the shell PTY only spawns once this is called, and
|
||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||
// below would hang until its own timeout for reasons unrelated to
|
||||
// SplitTerminalPane.
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
pane.connect();
|
||||
|
||||
// Wait for the WS to open, then send a real input frame — testMode's
|
||||
// echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
|
||||
// is what proves the WS round-trip actually reaches a real PTY and back,
|
||||
// not just that xterm can render locally-written text.
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));
|
||||
|
||||
const hasEcho = await new Promise((resolve) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
const poll = () => {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() > deadline) resolve(false);
|
||||
else setTimeout(poll, 100);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
pane.destroy();
|
||||
const cleanedUp = mount.querySelector('.xterm') === null;
|
||||
document.body.removeChild(mount);
|
||||
|
||||
return { hasEcho, cleanedUp };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.hasEcho).toBe(true);
|
||||
expect(result.cleanedUp).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('shows existing scrollback immediately on connect, before any new output', async () => {
|
||||
// Regression guard: connect() previously only opened the WS and waited for
|
||||
// live 'terminal' events (ws-routes.ts sends nothing on connect), so a pane
|
||||
// opened onto an already-quiet session stayed blank until either new output
|
||||
// arrived or a resize happened to trigger a tmux repaint. Writing a marker
|
||||
// and letting the echo settle BEFORE connect() proves the fetched buffer,
|
||||
// not a live echo, is what populates the pane.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
// Write directly to the session (not through SplitTerminalPane, which
|
||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||
// connect() below will use) rather than a fixed delay — the shell's
|
||||
// own startup can race an early write and, on this box, a startup
|
||||
// script issues a `clear` that erases scrollback (modern ncurses
|
||||
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
|
||||
await fetch(`/api/sessions/${id}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }),
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
for (;;) {
|
||||
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
|
||||
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
|
||||
if (buffer.includes('PRE_EXISTING_MARKER')) break;
|
||||
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
const hasMarker = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
|
||||
// xterm's write() parses asynchronously (it queues data and processes it
|
||||
// on a later microtask/frame), so the fetched buffer connect() writes is
|
||||
// not necessarily in the rendered buffer the instant connect() resolves.
|
||||
// Poll rather than check once — no new input is sent here, so any pass
|
||||
// still comes from the ?full=1 fetch inside connect(), never a live echo.
|
||||
let found = false;
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!found && Date.now() < deadline) {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('PRE_EXISTING_MARKER')) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return found;
|
||||
}, sessionId);
|
||||
|
||||
expect(hasMarker).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => {
|
||||
// Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no
|
||||
// attachCustomKeyEventHandler of its own, so the document capture-phase
|
||||
// shortcut handler's preventDefault() (which does not stop xterm) left
|
||||
// every one of these chords ALSO writing its raw byte/escape sequence into
|
||||
// Pane B's live PTY on top of whatever the app action did to Pane A.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
|
||||
const sent: string[] = [];
|
||||
const realSend = pane.ws.send.bind(pane.ws);
|
||||
pane.ws.send = (payload: string) => {
|
||||
sent.push(payload);
|
||||
return realSend(payload);
|
||||
};
|
||||
|
||||
pane.terminal.focus();
|
||||
// Dispatch straight at xterm's own textarea, matching how a real
|
||||
// keypress reaches attachCustomKeyEventHandler — page.keyboard.press()
|
||||
// goes through the OS/CDP input pipeline and would also trigger the
|
||||
// app's document-capture handler (opening a real command palette),
|
||||
// which is not what this test is isolating.
|
||||
const textarea = (pane.terminal as any)._core?.textarea || (pane.terminal as any).textarea;
|
||||
const fire = (init: KeyboardEventInit) => {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
textarea.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
// keyCode is what xterm's evaluateKeyboardEvent switches on to decide
|
||||
// whether to produce a data frame at all — at keyCode 0 (unset) it can
|
||||
// never emit bytes, so the assertion below held regardless of whether
|
||||
// the custom key handler's gate actually fired. Real values (K=75,
|
||||
// 1=49, B=66) are what a real keypress carries.
|
||||
const app = window.app as any;
|
||||
fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette
|
||||
fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch
|
||||
|
||||
// Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode`
|
||||
// opt passed to the constructor above), so `this.sessionMode !== 'shell'`
|
||||
// holds and the gate must block it, mirroring a non-shell (agent) mode.
|
||||
fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true });
|
||||
|
||||
// Shift+Enter: must never reach the PTY as a bare \r (that would submit
|
||||
// an incomplete prompt instead of inserting a newline) — it goes out as
|
||||
// a POST to /api/sessions/:id/send-key instead.
|
||||
const sendKeyCalls: unknown[] = [];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = ((...args: Parameters<typeof fetch>) => {
|
||||
const url = String(args[0]);
|
||||
if (url.includes('/send-key')) {
|
||||
sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null);
|
||||
}
|
||||
return realFetch(...args);
|
||||
}) as typeof fetch;
|
||||
fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true });
|
||||
window.fetch = realFetch;
|
||||
|
||||
// Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal,
|
||||
// Ctrl+C must copy it (never send 0x03) and must copy Pane B's
|
||||
// selection, not Pane A's. app._copyText is stubbed rather than relying
|
||||
// on a real clipboard, which headless Chromium may refuse permission
|
||||
// for.
|
||||
pane.terminal.write('SPLITPANE_COPY_MARKER');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
pane.terminal.selectAll();
|
||||
let copiedText: string | null = null;
|
||||
const realCopyText = app._copyText;
|
||||
app._copyText = async (text: string) => {
|
||||
copiedText = text;
|
||||
return true;
|
||||
};
|
||||
fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
app._copyText = realCopyText;
|
||||
|
||||
// Ctrl+Shift+C with NO selection: the blanket "no 'i' frames" check
|
||||
// below is NOT what proves this gate works — xterm's own
|
||||
// evaluateKeyboardEvent never emits data for a shifted ctrl-letter in
|
||||
// the first place (verified live: removing the gate entirely still
|
||||
// produces zero WS frames for this exact key), so an absent 'i' frame
|
||||
// is true whether or not the app-level shiftKey branch fires. What the
|
||||
// branch actually buys is `preventDefault()`, so the browser's own
|
||||
// handling of the chord (e.g. Chrome's Inspect-Element binding) is
|
||||
// pre-empted, mirroring Pane A's own "never falls through" contract —
|
||||
// asserted directly via the dispatched event's defaultPrevented.
|
||||
pane.terminal.clearSelection();
|
||||
const ctrlShiftCPrevented = fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true, shiftKey: true });
|
||||
|
||||
// Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when
|
||||
// the sidebar layout is actually active (app.js:4325) — under the
|
||||
// default header-strip layout the app doesn't treat Alt+B as its own
|
||||
// shortcut either, so Pane A forwards the same `ESC b` to its own PTY.
|
||||
// Assert the gate where it is meant to hold: sidebar layout active.
|
||||
//
|
||||
// Setting only the `data-session-list` attribute is not enough: this
|
||||
// event bubbles (matching how a real keypress reaches xterm), so it
|
||||
// also reaches app.js's OWN document-level capture-phase shortcut
|
||||
// dispatcher, which matches the same Alt+B binding and calls the real
|
||||
// toggleSessionSidebar() — that reads the persisted settings (still
|
||||
// 'header'), re-runs applySessionListLayout(), and resets the
|
||||
// attribute back to 'header' before xterm's own (later, non-capture)
|
||||
// key handler ever sees it. Persisting the setting through the app's
|
||||
// own settings cache keeps the attribute stable across that bubble.
|
||||
const prevSettings = { ...app.loadAppSettingsFromStorage() };
|
||||
app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' };
|
||||
app.applySessionListLayout();
|
||||
fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle
|
||||
app._cachedAppSettings = prevSettings;
|
||||
app.applySessionListLayout();
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return { sent, sendKeyCalls, copiedText, ctrlShiftCPrevented };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
|
||||
expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]);
|
||||
expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER');
|
||||
expect(result.ctrlShiftCPrevented).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user