mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9466acfc1a | ||
|
|
e899af4305 | ||
|
|
299a21d5f5 | ||
|
|
d8e85285c9 | ||
|
|
0f955327b2 | ||
|
|
d3f2ec0220 | ||
|
|
6ef71ec3b9 | ||
|
|
d47f93abdb | ||
|
|
dcf9437308 | ||
|
|
aa13af1f7f | ||
|
|
9a2e14a93a | ||
|
|
ecb95b5d67 | ||
|
|
a7452dc046 | ||
|
|
72d437ab63 | ||
|
|
1ba0684438 | ||
|
|
af744bdb54 | ||
|
|
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 |
@@ -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"
|
||||
|
||||
@@ -100,6 +100,50 @@ jobs:
|
||||
fi
|
||||
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
|
||||
'
|
||||
# Installer v2: the question phase runs before the build, and every decision it
|
||||
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
|
||||
# default, the occupied-:443 menu and the rename question with canned answers,
|
||||
# so a bash-4 construct or a flipped default in any of them fails here, not on a
|
||||
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
|
||||
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
|
||||
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/h
|
||||
. /w/install.sh
|
||||
parse_flags --tailscale --service --name Build-Box --port 4000
|
||||
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
|
||||
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
|
||||
has_tty() { return 0; }
|
||||
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
|
||||
systemctl() { return 0; }
|
||||
LAUNCH_PRESET=""; NONINTERACTIVE=0
|
||||
choose_launch_mode linux >/dev/null 2>&1
|
||||
[[ "$LAUNCH_CHOICE" == "2" ]]
|
||||
check_tailscale() { return 0; }
|
||||
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
|
||||
ts_backend_state() { printf Running; }
|
||||
ts_dns_name() { printf box.tail.ts.net; }
|
||||
ts_serve_443_target_port() { printf 8080; }
|
||||
ts_serve_find_port_mapping() { :; }
|
||||
ts_serve_port_used() { return 1; }
|
||||
detect_tailscale_serve_url() { :; }
|
||||
tailscale_choose_mapping >/dev/null 2>&1
|
||||
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
|
||||
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
|
||||
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
|
||||
[[ -z "$RENAMED" ]]
|
||||
# A flag re-run keeps the password the unit already carries (and so
|
||||
# never writes the unauthenticated ack), and the hand-start line the
|
||||
# done screen prints carries every non-default value.
|
||||
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
|
||||
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
|
||||
choose_network_binding >/dev/null 2>&1
|
||||
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
|
||||
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
|
||||
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
|
||||
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
|
||||
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
|
||||
'
|
||||
|
||||
- name: CLI catalogue artifacts are in sync with stock.ts
|
||||
run: npm run generate:cli-catalog -- --check
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,12 +61,13 @@ The installer asks before every system change, and re-running the same line upda
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. It looks at what is already on the machine, asks at most three questions, then does all the work unattended and ends on the URL with a QR code for your phone. A few things worth knowing:
|
||||
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
||||
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
- **Three questions, all up front.** How the dashboard is reached, optionally what to call this machine on your tailnet, and whether to run Codeman as a background service (systemd/launchd, auto-start on boot; Enter says yes). Everything that needs you, including one consent for all missing packages, one sudo password, and the Tailscale login, happens before the build, so you can walk away while it compiles.
|
||||
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
|
||||
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||
|
||||
@@ -219,7 +220,7 @@ codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone.
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone. The installer ends on that URL with a QR code to scan, and `bash ~/.codeman/app/install.sh status` prints it again any time.
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,380 @@
|
||||
# Installer v2: three questions, then a URL you can open on your phone (Plan)
|
||||
|
||||
Status: **Phase 1 IMPLEMENTED (2026-09-20)**, phases 2 and 3 open. It builds on
|
||||
`docs/tailscale-installer-plan.md` (implemented 2026-08-04), which made Tailscale a
|
||||
guided option; this round makes it the thing the install ENDS on, and makes the whole
|
||||
installer shorter to sit through. Owner decisions taken before implementation: rename
|
||||
is opt-in and **defaults to no everywhere** (the machine name is used for other things);
|
||||
the URL keeps the node name unless asked; `codeman-<hostname>` is the suggested name;
|
||||
sub-path is the default for an occupied `:443`.
|
||||
|
||||
Verification record for phase 1 (all on the maintainer's box, 2026-09-20):
|
||||
|
||||
- `test/install-sh-invariants.test.ts` (28 tests, incl. the new Tailscale safety pins)
|
||||
and the detection-parity test pass; `bash -n` passes.
|
||||
- Every new decision function driven with stubbed tailscale state under **bash 5.2 and
|
||||
bash 3.2** (the `bash:3.2` container CI uses): flags, the launch default, the serve
|
||||
shape for free / ours / occupied `:443` (all four answers plus the non-interactive
|
||||
default), the three serve commands, the rename question (Enter keeps the name; `--yes`
|
||||
and non-interactive never rename; `codeman-*` nodes are skipped; `--name` is
|
||||
sanitized), `run_step` success/failure/stdin, the unit round-trip of
|
||||
`CODEMAN_BASE_URL`/`CODEMAN_PORT`/an escaped password, and the done screen.
|
||||
- A full non-interactive install into a sandboxed `HOME` with `CODEMAN_TAILSCALE=1`:
|
||||
preflight summary, kept the existing prod mapping (no serve mutation), clone 2 s,
|
||||
`npm install` 18 s, build 23 s, symlink, done screen; `install.sh status` on a pty
|
||||
renders the QR code. Nothing on the real system changed.
|
||||
- **Sub-path mode end to end over the real tailnet**: an isolated Codeman
|
||||
(`CODEMAN_INSTANCE`, port 3999, `--base-url /codeman`) behind
|
||||
`tailscale serve --https=8445 --set-path /codeman 3999` answered `/codeman/api/status`,
|
||||
`/codeman/` (with `<base href="/codeman/">` and `__CODEMAN_BASE__="/codeman"`), the
|
||||
hashed CSS/JS, `/codeman` without a slash, and the SSE stream; mapping and server
|
||||
removed afterwards. **Correction to section 2**: serve STRIPS the mount prefix
|
||||
before proxying (a direct `/codeman/api/status` on the server is 404 while the same
|
||||
path through serve is 200). That is fine because Codeman's ingress tolerates
|
||||
unprefixed requests; `--base-url` is needed for the URLs Codeman EMITS, not for
|
||||
what it receives.
|
||||
- Not yet exercised on a fresh machine (unchanged from the previous plan): Tailscale
|
||||
absent / logged out / HTTPS toggle off, the rename against a real node (the
|
||||
off-rename-re-add order is implemented but only unit-driven), macOS, uninstall. The
|
||||
Mac mini and a throwaway VM are the venues; see section 8.
|
||||
- **Review fixes (2026-09-21)**, from the two reviews on PR #460 (DeepSeek Harness, then
|
||||
Claude): the done screen's Start line is composed from every non-default value
|
||||
(`start_command_hint`, shared with the exec branch as `export_bind_env`), so "do not
|
||||
start" under a sub-path or a custom port no longer prints a bare `codeman web`; the
|
||||
`--lan`/`--tailscale`/env preset paths keep an existing password instead of rewriting
|
||||
the unit open; `--password`/`--port` flip `RECONFIGURE` so they reach the unit;
|
||||
`install.sh name` re-syncs the unit's base URL after a rename; the sudo keepalive is
|
||||
ended before the `exec` into the foreground server; Ctrl+C in the HTTPS-toggle poll
|
||||
skips Tailscale instead of killing the run; `uninstall` asks before removing a
|
||||
LaunchDaemon it never wrote; a foreign LaunchDaemon gets a restart hint and the done
|
||||
screen stops claiming the new build is running; the preflight summary reads the
|
||||
Tailscale state without node; the LAN security notice uses the configured port; a
|
||||
bare re-run ends on the done screen; a build failure after a rename names the
|
||||
`install.sh tailscale` recovery; `TS_JOINED_HERE` is gone.
|
||||
|
||||
Goal, in one sentence: a user runs the one-liner, answers at most three questions, walks
|
||||
away during the build, and comes back to `https://<name>.<tailnet>.ts.net` printed with a
|
||||
QR code, already answering, on every device in their tailnet. That is exactly the
|
||||
maintainer's own production setup (`tnode.tailf80371.ts.net` fronting `127.0.0.1:3000`),
|
||||
and the installer should produce it without the user knowing what `tailscale serve` is.
|
||||
|
||||
## 1. Where the installer is today
|
||||
|
||||
Facts from reading `install.sh` (2886 lines, 19 `prompt_yes_no` sites) and the live
|
||||
Tailscale state on the maintainer's box (tailscale 1.102.2, user-owned node, MagicDNS +
|
||||
HTTPS certs on, serve mapping `443 -> https+insecure://localhost:3000`).
|
||||
|
||||
**The order is backwards for a human.** The flow is: detect -> ask about git -> ask about
|
||||
node -> ask about tmux -> ask about build tools -> AI CLI menu -> ask about cloudflared ->
|
||||
clone -> `npm install` -> build (minutes) -> **then** the network-access question -> the
|
||||
Tailscale sub-steps (install? login URL, sudo for operator, admin-console toggle loop) ->
|
||||
the launch menu (no default; a bare Enter re-prompts) -> tunnel-service question. A fresh
|
||||
Ubuntu server taking the Tailscale route answers roughly ten prompts plus two to four sudo
|
||||
password prompts, split around a multi-minute build. The user cannot walk away at any
|
||||
point, and the question that matters most (how do I reach it) comes last.
|
||||
|
||||
**The Tailscale flow works but was never exercised on a fresh machine.** The previous
|
||||
plan's manual matrix still lists items 1-4, 7 and 10-12 (Tailscale absent, logged out,
|
||||
HTTPS toggle off, port 443 occupied, macOS, uninstall, phone PWA) as untested. The
|
||||
maintainer's own verification was the idempotent "kept as-is" path.
|
||||
|
||||
**The URL is the machine's name, full stop.** `setup_tailscale_serve` derives it from
|
||||
`.Self.DNSName`, and nothing lets the user influence it. A second Codeman on the same
|
||||
tailnet is `macminis-mac-mini.tailf80371.ts.net`, which tells you nothing about Codeman.
|
||||
|
||||
**Port 443 taken means give up or clobber.** If another app already owns the root of
|
||||
`:443`, the only offer is "replace it?" (default no), and declining falls back to
|
||||
local-only. Codeman already supports running under a sub-path (`--base-url`), and
|
||||
Tailscale serve supports mounting a path (`--set-path`), so there is a third answer nobody
|
||||
is offered.
|
||||
|
||||
**The result is invisible afterwards.** Once the terminal scrolls away, nothing in the app
|
||||
or the CLI tells the user their Tailscale URL again. `codeman doctor` does not probe
|
||||
Tailscale; App Settings -> Remote access shows only the Cloudflare tunnel.
|
||||
|
||||
**Two service writers exist.** `install.sh` carries its own plist/unit generator (~180
|
||||
lines) next to `codeman service install` (`src/service-installer.ts`). They agree on the
|
||||
job name by design, but the bash copy is the one that writes `CODEMAN_PASSWORD` into the
|
||||
unit, so they cannot simply be merged. Left as-is in this plan (see section 9).
|
||||
|
||||
## 2. What Tailscale makes possible for the name (researched 2026-09-20)
|
||||
|
||||
| Option | Resulting URL | What it needs | Side effects | Verdict |
|
||||
| ------ | ------------- | ------------- | ------------ | ------- |
|
||||
| **A. Node name** (today) | `https://tnode.tailf80371.ts.net` | `tailscale serve --bg 3000` | none | **Default.** Zero admin-console work, matches the maintainer's prod. |
|
||||
| **B. Rename the node** | `https://codeman-tnode.tailf80371.ts.net` | `tailscale set --hostname codeman-<host>` (operator or root) | Renames the machine tailnet-wide: ssh targets, other serve URLs, the admin console entry. Tailscale de-dups a clash as `-1`. The cert follows the new name. | **Opt-in, default NO everywhere** (owner decision 2026-09-20: the machine is used for other things, so a bare Enter never renames it). The proposal was YES when the installer itself had just joined the tailnet; rejected. |
|
||||
| **C. Tailscale Service** | `https://codeman.tailf80371.ts.net` | tailscale >= 1.86 on the host; the host must have a **tag-based identity** ("You cannot use a device authenticated with a user account as a Service host"); the service is defined in the admin console first; the host is then approved there (or via `autoApprovers.services`). Public beta since 2025-10-28, all plans. | Re-authenticating a personal machine as a tagged node changes its identity (SSH ACLs, user attribution). Known daemon quirk: approval is not picked up until `serve clear` + re-advertise (tailscale/tailscale#18821). | **Detect and hint only** in this round. The maintainer's own node has `Self.Tags: null`, so it could not host one without re-tagging. Worth a real flow once someone with a tagged fleet asks. |
|
||||
| **D. Sub-path** | `https://tnode.tailf80371.ts.net/codeman` | `tailscale serve --bg --set-path /codeman 3000` plus `--base-url /codeman` on the server | Codeman runs under a prefix. Hooks are unaffected (they hit the raw port with no prefix, which `rewriteUrl` already tolerates). Serve forwards the prefix unchanged, which is exactly the shape `--base-url` was built for. | **The answer when `:443` root is already taken.** Replaces today's replace-or-nothing prompt. |
|
||||
| **E. Second port** | `https://tnode.tailf80371.ts.net:8443` | `tailscale serve --bg --https=8443 3000` | Port in the URL; the beta-preview recipe already uses this. | Fallback when the user rejects D. |
|
||||
| Funnel (public internet) | `https://tnode.tailf80371.ts.net` from anywhere | `tailscale funnel` | Public exposure; different risk class. | **Out of scope**, as before. Docs only, with the password warning. |
|
||||
|
||||
Sources: Tailscale Services docs (`tailscale.com/docs/features/tailscale-services`), the
|
||||
Services beta announcement (`tailscale.com/blog/services-beta`), machine names
|
||||
(`tailscale.com/kb/1098/machine-names`), the serve CLI reference
|
||||
(`tailscale.com/docs/reference/tailscale-cli/serve`), the macOS variants page
|
||||
(`tailscale.com/docs/concepts/macos-variants`), and `tailscale serve --help` on 1.102.2
|
||||
(which lists `--service`, `--set-path`, `--yes`, `advertise`, `get-config`/`set-config`).
|
||||
|
||||
**Trap for option B (verify on the Mac mini before shipping):** the serve config is keyed
|
||||
by `host:port` using the DNS name at configuration time (`"Web": {"tnode.tailf80371.ts.net:443": ...}`
|
||||
in `serve status --json`). Renaming a node after serve is configured most likely orphans that
|
||||
entry: the handler lookup uses the current name and never matches the old key, and the only
|
||||
tool that removes a stale key is `serve reset`, which this installer must never run. So the
|
||||
order is **rename first, then configure serve** on a fresh install, and on a retrofit
|
||||
(`install.sh name`) **turn our mapping off, rename, wait for `.Self.DNSName` to change,
|
||||
re-add**.
|
||||
|
||||
## 3. Target UX
|
||||
|
||||
### 3.1 Three questions, then walk away
|
||||
|
||||
```
|
||||
Codeman installer
|
||||
|
||||
Found: git, Node 22.14, tmux 3.4, build tools Missing: nothing
|
||||
AI CLIs: Claude Code (~/.local/bin/claude)
|
||||
Tailscale: connected as tnode (tailf80371.ts.net)
|
||||
Existing: none
|
||||
|
||||
1/3 How should the dashboard be reachable?
|
||||
1) Tailscale https://tnode.tailf80371.ts.net (recommended, already connected)
|
||||
2) Any device on your network (0.0.0.0, password required)
|
||||
3) This machine only (127.0.0.1)
|
||||
Choose [1/2/3] (default 1):
|
||||
|
||||
2/3 Name this machine "codeman-tnode" on your tailnet? [y/N]
|
||||
(only shown for option 1; default no, always)
|
||||
|
||||
3/3 Run Codeman as a background service that starts on boot? [Y/n]
|
||||
|
||||
Installing… this takes a few minutes. You can leave this running.
|
||||
✓ dependencies ✓ clone ✓ build (2m 41s) ✓ service ✓ tailscale serve
|
||||
```
|
||||
|
||||
Rules that make this work:
|
||||
|
||||
- **Every step that needs a human runs BEFORE the build.** The dependency consent, the
|
||||
AI CLI menu, the Tailscale install consent, the `tailscale up` login URL, the operator
|
||||
grant, and the tailnet HTTPS toggle all move into the question phase. The build, the
|
||||
service, `tailscale serve` and the verification are unattended.
|
||||
- **One consent for all missing system packages.** "Install git, Node 22 and build tools
|
||||
now? [Y/n]" replaces four separate prompts. Each package still runs its own
|
||||
distro-specific installer.
|
||||
- **One sudo prompt.** When anything needs root (packages, the Tailscale installer,
|
||||
`tailscale up`, the operator grant), the installer says so once, runs `sudo -v`, and keeps
|
||||
the timestamp alive in a background loop until it exits. macOS needs no sudo for the
|
||||
Tailscale GUI-app CLI and the pattern still holds for Homebrew packages.
|
||||
- **Service is the default.** Enter on the last question installs the service; "run in
|
||||
this terminal" and "don't start" stay reachable by answering, and by flag.
|
||||
- **The cloudflared question is gone from the main flow.** It is optional, defaults to
|
||||
no, and has an in-app toggle (App Settings -> Remote access). The done screen mentions it
|
||||
only when `cloudflared` is already installed. The Linux tunnel-service prompt goes with it.
|
||||
- **The HTTPS-certificates toggle no longer asks "re-check now?"** The installer prints the
|
||||
admin URL, opens it in a browser when one is available (`xdg-open` / `open`, never on a
|
||||
headless box), and polls `tailscale status --json` every 5 s for up to 5 minutes. Ctrl+C or
|
||||
the timeout falls back exactly as today.
|
||||
- **Progress, not silence.** `npm install` and `npm run build` run behind one line each
|
||||
with elapsed time; their output goes to `~/.codeman/install.log` and is printed only on
|
||||
failure, with the exact retry command.
|
||||
|
||||
### 3.2 The done screen
|
||||
|
||||
One block, the URL first, a QR code the phone can scan, and nothing the user does not need
|
||||
right now.
|
||||
|
||||
```
|
||||
✓ Codeman 1.31.0 is running
|
||||
|
||||
Your tailnet: https://codeman-tnode.tailf80371.ts.net (HTTPS, any of your devices)
|
||||
This machine: http://localhost:3000
|
||||
|
||||
▄▄▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄▄▄
|
||||
█ ▄▄▄ █ ▄▄▀ ▄ █ ▄▄▄ █ scan with your phone
|
||||
█ ███ █ ███▀▀ █ ███ █
|
||||
█▄▄▄▄▄█ █ ▄ █ █▄▄▄▄▄█
|
||||
|
||||
Manage systemctl --user restart codeman-web · journalctl --user -u codeman-web -f
|
||||
Update re-run the install line, or App Settings → System → Updates
|
||||
Docs https://github.com/Ark0N/Codeman/wiki
|
||||
|
||||
Security: Codeman binds 127.0.0.1. Tailscale authenticates every device before a
|
||||
packet reaches it. Details: docs/security-architecture.md
|
||||
```
|
||||
|
||||
The QR comes from the `qrcode` package Codeman already depends on
|
||||
(`node -e "require('qrcode').toString(url, {type:'terminal', small:true}, …)"` from
|
||||
`$INSTALL_DIR`, verified locally: 17 rows by 45 columns). Skipped when the terminal has no
|
||||
color support or fewer than 50 columns. The QR encodes the plain URL, not an auth token:
|
||||
the tailnet is the login.
|
||||
|
||||
### 3.3 Express mode and flags
|
||||
|
||||
Env vars stay (`CODEMAN_TAILSCALE=1`, `CODEMAN_HOST`, `CODEMAN_PASSWORD`,
|
||||
`CODEMAN_NONINTERACTIVE=1`, `CODEMAN_PORT`). Flags are added because they are
|
||||
discoverable from the one-liner and pipe through `bash -s --`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --name codeman-build --yes
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
| ---- | ------- |
|
||||
| `--tailscale` / `--lan` / `--local` | Answer 1/3 (same semantics as `CODEMAN_TAILSCALE=1`, `CODEMAN_HOST=0.0.0.0`, `CODEMAN_HOST=127.0.0.1`) |
|
||||
| `--name <n>` / `--no-rename` | Answer 2/3: rename the node to `<n>`, or never ask |
|
||||
| `--service` / `--run` / `--no-start` | Answer 3/3 |
|
||||
| `--yes` | Accept every default, still prompt for a login URL (a human must open it) |
|
||||
| `--password <p>` | Same as `CODEMAN_PASSWORD` |
|
||||
| `--port <n>` | Same as `CODEMAN_PORT`; the serve target follows it |
|
||||
|
||||
`--yes` differs from `CODEMAN_NONINTERACTIVE=1`: it is the interactive user saying "I trust
|
||||
the defaults", so it may install software and may wait on a login URL. Non-interactive stays
|
||||
the CI contract and never installs Tailscale.
|
||||
|
||||
## 4. The Tailscale flow, v2
|
||||
|
||||
The state machine from the previous plan stays; these are the changes.
|
||||
|
||||
1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install
|
||||
(Linux: official script; macOS: brew cask, else download link and wait). Logged in? ->
|
||||
`tailscale up` with the URL printed prominently and a 5-minute poll. Operator (Linux):
|
||||
grant once under the single sudo session. HTTPS certs: poll instead of ask (Ctrl+C
|
||||
during the poll skips Tailscale for this run rather than ending the installer). The
|
||||
rename default does not depend on whether this run performed the login (decided NO
|
||||
everywhere), so nothing records it.
|
||||
2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route.
|
||||
Default `codeman-<oshostname>` sanitized to `[a-z0-9-]`, max 63. Applied with
|
||||
`ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name
|
||||
(up to 60 s). Order matters: this runs before any serve mutation (section 2 trap).
|
||||
Declining keeps the node name. On a re-run against a node already named `codeman-*`,
|
||||
the question is skipped.
|
||||
3. **Serve, after the service is up** (`setup_tailscale_serve`): unchanged idempotent
|
||||
"kept as-is" path first. When `:443` root belongs to another target, the new prompt is:
|
||||
|
||||
```
|
||||
tailscale serve already sends https://tnode.tailf80371.ts.net to port 8080.
|
||||
1) Add Codeman under a path: https://tnode.tailf80371.ts.net/codeman (default)
|
||||
2) Use another port: https://tnode.tailf80371.ts.net:8443
|
||||
3) Replace the existing mapping with Codeman
|
||||
4) Skip Tailscale for now
|
||||
```
|
||||
|
||||
Option 1 writes `--base-url /codeman` into the service unit (it is a `WebLaunchOptions`
|
||||
field already, and `buildWebArgs` carries it) and runs
|
||||
`tailscale serve --bg --set-path /codeman <port>`. Option 2 runs `--https=8443`.
|
||||
`detect_tailscale_serve_url` learns to recognize all three shapes (root, path, port) so
|
||||
uninstall, the security notice and the re-run default keep working.
|
||||
4. **Warm the certificate.** Right after serve is configured, fire one background
|
||||
`curl -sk https://<url>/api/status` so Let's Encrypt issuance overlaps the rest of the
|
||||
install instead of adding 30 s to the verify step.
|
||||
5. **Verify** as today (200 or 401 on `/api/status`), with the path-aware URL.
|
||||
6. **Services hint** (option C): when `.Self.Tags` is non-empty and `serve --help`
|
||||
lists `--service`, the done screen adds one line: "This is a tagged node, so it can also
|
||||
host `https://codeman.<tailnet>.ts.net` as a Tailscale Service: see Remote Access in the
|
||||
wiki." No flow, no prompt.
|
||||
7. **macOS**: the App Store and Standalone variants cannot run before login, so a
|
||||
LaunchAgent plus serve only comes back after someone logs in. The done screen says so on
|
||||
macOS. The Mac mini (`arbbot`, headless, system LaunchDaemon) is the reference for the
|
||||
"headless Mac" caveat, and `install.sh` must keep refusing to replace a LaunchDaemon it
|
||||
did not write (today it removes one; that is a bug for the Mac mini and is fixed here:
|
||||
detect `UserName` in the daemon plist and leave it alone with a message).
|
||||
8. **Uninstall** additionally offers to restore the original node name when this installer
|
||||
renamed it (the original is recorded in `~/.codeman/install.json`, the one marker file
|
||||
this feature adds, because tailscaled does not remember previous names).
|
||||
9. **Subcommands**: `install.sh tailscale` (unchanged purpose, now runs the v2 flow),
|
||||
`install.sh name [<n>]` (rename with the off/rename/re-add dance), `install.sh status`
|
||||
(prints the done screen again, URL and QR included, for the "what was my URL" moment).
|
||||
|
||||
## 5. In-app: the URL stays discoverable
|
||||
|
||||
Small, read-only, and the first server-side code this feature has ever needed.
|
||||
|
||||
- **`GET /api/system/remote-access`** returns
|
||||
`{ tailscale: { installed, connected, dnsName, url, mode: 'root'|'path'|'port'|null } }`
|
||||
by running `tailscale status --json` and `tailscale serve status --json` through
|
||||
`execFile` with the existing exec timeout, cached 30 s, resolved through the same
|
||||
`get_tailscale_path` search as the installer (PATH, then the macOS app bundle), and a
|
||||
no-op under `VITEST` like every other IO probe. Never mutates serve config.
|
||||
- **App Settings -> Remote access** gains a **Tailscale** row above the Cloudflare toggle:
|
||||
the URL as a copy chip, a QR button reusing `showTunnelQR`'s modal, and when nothing is
|
||||
configured a one-line hint with `bash ~/.codeman/app/install.sh tailscale`. The welcome
|
||||
screen's "open on your phone" affordance shows the same QR.
|
||||
- **`codeman doctor`** grows a `tailscale` entry under `other` in
|
||||
`config/dependency-registry.ts`: installed, connected, serving Codeman (URL). Pure
|
||||
engine, injectable probe host, like the existing rows.
|
||||
- No new SSE event, no settings key, no state.json change.
|
||||
|
||||
## 6. Security posture
|
||||
|
||||
Nothing widens. The bind stays loopback; the tailnet is the authentication boundary;
|
||||
`.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`. New surfaces are read-only
|
||||
probes. `install.sh` still never runs `tailscale serve reset`, still touches only the
|
||||
mapping it created, and gains one more never: it never advertises a Tailscale Service or
|
||||
runs `tailscale funnel`. The sudo keep-alive loop is killed by the existing `cleanup` trap.
|
||||
The rename records the previous name locally and offers the reversal at uninstall.
|
||||
|
||||
## 7. Implementation inventory
|
||||
|
||||
| File | Change |
|
||||
| ---- | ------ |
|
||||
| `install.sh` | New `parse_flags`, `preflight_summary`, `ask_everything` (the three questions), `sudo_session`, `run_step` (spinner + log), `tailscale_preflight`, `tailscale_choose_name`, `tailscale_rename_node`, `print_done_screen`, `print_qr`, `status` subcommand, `name` subcommand. Modified: `main` (reordered into ask -> work -> done), `choose_network_binding` (question 1/3, same defaults), `setup_tailscale_serve` (path/port options), `detect_tailscale_serve_url` (three shapes), `setup_systemd_service`/`setup_launchd_service` (`--base-url`, LaunchDaemon guard), `uninstall` (rename reversal), header docs (flags). Removed from the main flow: the cloudflared prompt, the tunnel-service prompt. bash 3.2 rules unchanged. |
|
||||
| `src/web/routes/system-routes.ts` | `GET /api/system/remote-access` |
|
||||
| `src/tailscale-status.ts` (new) | Pure parser for the two JSON shapes + the IO wrapper; unit-tested against captured `serve status --json` fixtures (root, path, port, foreign target, none) |
|
||||
| `src/config/dependency-registry.ts`, `src/utils/dependency-checker.ts` | `tailscale` doctor row |
|
||||
| `src/web/public/index.html`, `settings-ui.js`, `panels-ui.js` | Tailscale row + QR, welcome-screen QR |
|
||||
| `test/install-sh-invariants.test.ts` | Extend: flags documented in the header, no `serve reset`, no `funnel`, no `--service` advertise, every serve mutation goes through `ts_cmd_serve`, rename happens before serve in `main` (static order check) |
|
||||
| `.github/workflows/ci.yml` | The bash 3.2 step additionally sources the script with stubbed `ts_cmd`/`ts_cmd_serve`/`read_reply` and drives `ask_everything` through all three answers and the 443-occupied menu |
|
||||
| `test/tailscale-status.test.ts`, `test/routes/system-routes-remote-access.test.ts` | Parser + route |
|
||||
| Docs | README install + remote-access sections, `docs/wiki/Installation.md`, `Remote-Access.md` (naming options table, Services caveat, path/port variants), `Mobile-Guide.md`, `Running-As-A-Service.md` (macOS login caveat), `FAQ.md`, `docs/security-architecture.md` §A, CLAUDE.md Scripts & Tunnel paragraph, `docs/tailscale-installer-plan.md` gets a pointer here. getcodeman.com copy lives outside the repo (maintainer handbook). |
|
||||
|
||||
Changeset: `minor` (new flags, new subcommands, new API route).
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
Automated (the gate): the static invariants above, the bash 3.2 container drive of the
|
||||
question phase, the JSON parser fixtures, the route test.
|
||||
|
||||
Manual matrix, on a fresh Ubuntu 24 VM and on the Mac mini, since the previous plan's
|
||||
items never ran on a fresh machine:
|
||||
|
||||
1. Tailscale absent, declined -> local-only, done screen shows the retrofit command.
|
||||
2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename
|
||||
question shown (default no), service, serve, URL verified, QR scans on a phone, PWA installs.
|
||||
3. Tailscale present and logged in on a pre-existing node -> rename default NO, URL is the
|
||||
node name, `serve status` gains exactly one entry.
|
||||
4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still
|
||||
fire (raw port), `install.sh status` prints the path URL.
|
||||
5. Rename on a node that already has our serve mapping (`install.sh name`) -> off, rename,
|
||||
re-add, `serve status` has no stale key.
|
||||
6. Re-run the one-liner -> quiet update, binding and name preserved, no prompts.
|
||||
7. `--yes` end to end; `CODEMAN_NONINTERACTIVE=1` end to end (no software installed).
|
||||
8. Uninstall -> mapping removed, other mappings intact, rename reversal offered.
|
||||
9. Mac mini: LaunchDaemon left alone with the message; done screen carries the login caveat.
|
||||
|
||||
## 9. Phasing and open decisions
|
||||
|
||||
**Phase 1 (this round):** the reorder, the three questions, one consent + one sudo, flags,
|
||||
the done screen with QR, Tailscale preflight-before-build, the path/port answer for an
|
||||
occupied 443, the rename step, `status` and `name` subcommands, docs.
|
||||
|
||||
**Phase 2:** the in-app Tailscale row + QR, `codeman doctor` row, the `remote-access`
|
||||
route. Independent of phase 1 and useful on its own for existing installs.
|
||||
|
||||
**Phase 3 (optional):** replace the bash service writers with `codeman service install`
|
||||
once that command can carry `CODEMAN_PASSWORD` behind an explicit flag; and a Tailscale
|
||||
Services flow if a tagged-fleet user asks for `codeman.<tailnet>.ts.net`.
|
||||
|
||||
Decisions for the maintainer:
|
||||
|
||||
1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
|
||||
`install.sh name` are the ways in. (The proposal was YES only when this run had joined
|
||||
the tailnet, NO otherwise; rejected because the host is used for other things.)
|
||||
2. **Name pattern.** `codeman-<hostname>` (proposed; unique per machine, and two Codemans
|
||||
on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the
|
||||
second install, Tailscale silently appends `-1`).
|
||||
3. **Path versus port** as the default answer for an occupied 443. Proposed: path, because
|
||||
the URL has no port and `--base-url` already exists for exactly this proxy shape.
|
||||
4. **Whether Phase 2 ships in the same release.** It is the part that helps people who
|
||||
installed months ago.
|
||||
@@ -270,9 +270,15 @@ tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
|
||||
Only devices on your tailnet can reach it; Tailscale handles identity and
|
||||
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
|
||||
push work). No app password and no `0.0.0.0` bind required. (This is the
|
||||
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
|
||||
automation; the installer never runs `tailscale serve reset` and never touches
|
||||
serve mappings other than `443 -> Codeman's port`.
|
||||
maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets
|
||||
the choice for automation. When `:443` on the node already belongs to another
|
||||
app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path`
|
||||
plus `--base-url`, which keeps the loopback bind and the same host guard) or on a
|
||||
second port rather than replacing it. The installer never runs `tailscale serve
|
||||
reset`, never touches serve mappings other than the one it created, never opens a
|
||||
`tailscale funnel` (public internet, a different risk class) and never advertises
|
||||
a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in
|
||||
and defaults to no, because the tailnet name is also the machine's SSH identity.
|
||||
|
||||
### B. Authenticated cloudflared tunnel + password
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Tailscale Setup in the Installer (Plan)
|
||||
|
||||
> Superseded in part by [`installer-v2-plan.md`](installer-v2-plan.md) (2026-09-20), which
|
||||
> moved every human step before the build, added the sub-path / second-port answer for an
|
||||
> occupied `:443`, the opt-in rename, flags, and the done screen with a QR code. The
|
||||
> state machine and safety rules below still hold.
|
||||
|
||||
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
|
||||
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
|
||||
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
-13
@@ -24,16 +24,22 @@ This installs Node.js, tmux and a build toolchain if they are missing (node-pty
|
||||
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
|
||||
builds it.
|
||||
|
||||
What it asks you:
|
||||
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
|
||||
an existing install), then asks everything it needs up front, then does the work
|
||||
unattended. You can leave while it builds. What it asks you:
|
||||
|
||||
1. **Permission for every system change.** Package installs and agent CLI downloads are
|
||||
prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
|
||||
offers to install any of them (DeepSeek excepted: its npm package installs only a
|
||||
launcher with no runnable profile), or you skip and install one yourself later.
|
||||
1. **One consent for the missing packages.** Git, Node.js, tmux and (on Linux) the build
|
||||
toolchain are installed after a single yes, and sudo asks for your password once for
|
||||
the whole run. Nothing is installed silently. If no agent CLI is found, a menu offers
|
||||
to install any of them (DeepSeek excepted: its npm package installs only a launcher
|
||||
with no runnable profile), or you skip and install one yourself later.
|
||||
2. **How the dashboard should be reachable.** Three choices:
|
||||
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
|
||||
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
|
||||
end to end.
|
||||
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs
|
||||
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
|
||||
page for you and waits; Ctrl+C there skips Tailscale for this run), then configures `tailscale serve` after the build and
|
||||
verifies the result end to end. If another app already owns `:443` on your node,
|
||||
you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
|
||||
default), a second port, replacing the other mapping, or skipping.
|
||||
- **Your local network** (`0.0.0.0`): prompts for a password. Skipping the password takes
|
||||
an explicit confirmation and ends on a loud warning.
|
||||
- **This machine only** (`127.0.0.1`): the safest option, and the default for a bare
|
||||
@@ -44,26 +50,54 @@ What it asks you:
|
||||
Tailscale. An existing loopback install defaults to keeping loopback, or to Tailscale when
|
||||
a serve mapping for Codeman is already there. A bare Enter never pulls in new software,
|
||||
and a non-interactive run always keeps the safe loopback default.
|
||||
3. **What to do when it finishes.** Run in this terminal, install as a background service
|
||||
that starts on boot, or do nothing yet.
|
||||
3. **What to call this machine on your tailnet** (Tailscale route only). By default the URL
|
||||
uses the machine's existing name. Answer yes to rename it `codeman-<hostname>`; the
|
||||
default is no, because the tailnet name is also what SSH and everything else on that
|
||||
machine are reached by.
|
||||
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
|
||||
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
|
||||
instead, or not at all.
|
||||
|
||||
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
|
||||
scan with your phone, and the two commands you need to manage the service.
|
||||
|
||||
Re-running the same one-liner **updates an existing install in place**. Local changes in
|
||||
`~/.codeman/app` are stashed rather than discarded, a running service is restarted and
|
||||
verified, and your existing network binding is preserved. An interrupted first install
|
||||
resumes instead of restarting.
|
||||
|
||||
Two other entry points exist:
|
||||
Other entry points:
|
||||
|
||||
```bash
|
||||
install.sh status # print the URLs, the QR code and the manage commands again
|
||||
install.sh update # update only
|
||||
install.sh uninstall # remove
|
||||
install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
|
||||
```
|
||||
|
||||
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
|
||||
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default
|
||||
(it still waits on a Tailscale login URL, and a network bind still asks for a password).
|
||||
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
|
||||
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
|
||||
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
|
||||
|
||||
**Automation and CI**: with no terminal attached, any step that would change the system
|
||||
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
|
||||
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
installs Tailscale itself non-interactively.
|
||||
installs Tailscale itself non-interactively; a non-interactive run never renames the
|
||||
machine and never starts a service. Everything the unattended steps print goes to
|
||||
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
|
||||
|
||||
## Route B: npm
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -33,11 +33,12 @@ Your devices join a private network, and Codeman stays bound to loopback. Nothin
|
||||
published to the internet, and you get real HTTPS with a real certificate.
|
||||
|
||||
The installer sets this up for you, including installing Tailscale, logging in, enabling
|
||||
tailnet HTTPS, and verifying the result end to end. To retrofit it onto an existing
|
||||
install:
|
||||
tailnet HTTPS, and verifying the result end to end. It ends on the URL with a QR code to
|
||||
scan. To retrofit it onto an existing install, or to see the URL and QR code again:
|
||||
|
||||
```bash
|
||||
install.sh tailscale
|
||||
install.sh status
|
||||
```
|
||||
|
||||
By hand:
|
||||
@@ -49,6 +50,22 @@ tailscale serve status
|
||||
|
||||
Then open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet.
|
||||
|
||||
### The name in the URL
|
||||
|
||||
The URL is the machine's MagicDNS name, so on a machine called `tnode` it is
|
||||
`https://tnode.<tailnet>.ts.net`. Three ways to influence that, from least to most work:
|
||||
|
||||
| You want | How |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| The machine's existing name (default) | Nothing. This is what the installer does unless you say otherwise. |
|
||||
| `https://codeman-<hostname>.<tailnet>.ts.net` | Answer yes to the installer's name question, pass `--name codeman-<hostname>`, or run `install.sh name`. This renames the machine tailnet-wide (SSH included), which is why the installer defaults to no. `install.sh uninstall` offers to rename it back. |
|
||||
| `https://codeman.<tailnet>.ts.net` | A [Tailscale Service](https://tailscale.com/docs/features/tailscale-services). Only a **tagged** node can host one (a device signed in with a user account cannot), the service is defined and approved in the admin console, and the feature is in beta. The installer does not set this up; it is a `tailscale serve --service=svc:codeman --https=443 127.0.0.1:3000` on a tagged host once the service exists. |
|
||||
|
||||
If `:443` on your node already belongs to another app, the installer offers Codeman under
|
||||
`https://<machine>.<tailnet>.ts.net/codeman` (the default, via `tailscale serve --set-path`
|
||||
plus Codeman's `--base-url`), on a second port (`https://<machine>.<tailnet>.ts.net:8443`),
|
||||
or replacing the other mapping. It never replaces anything without asking.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the loopback bind. `tailscale serve` connects to `127.0.0.1:3000` locally, so
|
||||
@@ -58,7 +75,11 @@ Notes:
|
||||
- Codeman's Host-header allowlist already accepts `.ts.net`, so no extra configuration is
|
||||
needed.
|
||||
- The installer never resets or rewrites `serve` mappings other than the one pointing at
|
||||
Codeman's port, so unrelated serve configuration is left alone.
|
||||
Codeman's port, so unrelated serve configuration is left alone. It also never opens a
|
||||
`tailscale funnel` (that is the public internet) and never advertises a Tailscale Service.
|
||||
- On macOS, the App Store and standalone Tailscale apps only run once someone is logged in,
|
||||
so a headless Mac needs the open-source `tailscaled` for the URL to come back after a
|
||||
reboot on its own.
|
||||
|
||||
## Cloudflare tunnel
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ On Linux, if you want the service running while you are not logged in:
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
On macOS, a LaunchAgent starts when you log in, not at boot. A headless Mac (no GUI login)
|
||||
needs a system LaunchDaemon instead, written by hand as root. The installer recognises an
|
||||
existing `/Library/LaunchDaemons/com.codeman.web.plist` and leaves it alone rather than
|
||||
installing a LaunchAgent next to it, since the two would fight over the port; remove the
|
||||
daemon first if you want to switch. The same login caveat applies to the App Store and
|
||||
standalone Tailscale apps, so on a headless Mac the Tailscale URL only comes back after a
|
||||
reboot if the open-source `tailscaled` is used.
|
||||
|
||||
### Writing the unit by hand
|
||||
|
||||
**Linux (systemd user unit):**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
+1478
-553
File diff suppressed because it is too large
Load Diff
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,8 +179,13 @@ describe('install.sh runtime safety', () => {
|
||||
/if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/
|
||||
);
|
||||
const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB');
|
||||
const dispatchAt = SOURCE.indexOf('case "${1:-}" in');
|
||||
const dispatchAt = SOURCE.indexOf('case "$SUBCOMMAND" in');
|
||||
expect(dispatchAt, 'the dispatch case must exist').toBeGreaterThan(-1);
|
||||
expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt);
|
||||
// parse_flags runs only in the dispatch tail, after the guard: a sourced copy must
|
||||
// never consume the harness's own arguments.
|
||||
const parseAt = SOURCE.indexOf('\nparse_flags "$@"');
|
||||
expect(parseAt, 'parse_flags must be invoked after the sourcing guard').toBeGreaterThan(guardAt);
|
||||
});
|
||||
|
||||
it('still sets the strict flags it has always run under', () => {
|
||||
@@ -205,6 +210,104 @@ describe('install.sh DeepSeek identity probe', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh owns the build and the start', () => {
|
||||
it('runs npm install with CODEMAN_NO_AUTOSTART=1', () => {
|
||||
// scripts/postinstall.js builds dist/ and starts a detached `codeman web` on its
|
||||
// own unless told not to. Under the installer that orphan made the service
|
||||
// crash-loop on EADDRINUSE while the done screen reported "running" off the
|
||||
// orphan (fresh Ubuntu 24 sandbox, 2026-09-20). Every npm install here must
|
||||
// carry the opt-out.
|
||||
// Executed installs only: the catalogue's `npm install -g` literals and the
|
||||
// failure message that quotes the command are prose here.
|
||||
const installs = CODE_LINES.filter(
|
||||
(line) => /\bnpm install\b/.test(line) && !/npm install -g/.test(line) && !/\b(error|warn|info|echo) "/.test(line)
|
||||
);
|
||||
expect(installs.length, 'expected the one npm install call').toBeGreaterThan(0);
|
||||
for (const line of installs) {
|
||||
expect(line, `npm install without CODEMAN_NO_AUTOSTART=1:\n ${line}`).toContain('CODEMAN_NO_AUTOSTART=1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh Tailscale safety rules', () => {
|
||||
// Every rule here protects config that is not ours. `serve reset` destroys a user's
|
||||
// unrelated serve mappings (the maintainer's own node carries two); funnel is the
|
||||
// public internet, a different risk class than tailnet-only serve; advertising a
|
||||
// Tailscale Service requires a tagged node and admin approval and is documented
|
||||
// as a hint only. All three are pinned as absences.
|
||||
it('never runs `tailscale serve reset`', () => {
|
||||
const offenders = CODE_LINES.filter((line) => /serve\s+reset\b/.test(line));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('never runs `tailscale funnel` and never advertises a Tailscale Service', () => {
|
||||
// The installer's own `--service` flag (run as a service) is not Tailscale's
|
||||
// `--service=svc:<name>`; the pin keys on the svc: prefix and the serve form.
|
||||
const offenders = CODE_LINES.filter(
|
||||
(line) => /\bfunnel\b/.test(line) || /\bsvc:/.test(line) || /\bserve\b.*--service/.test(line)
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes every serve mutation through ts_cmd_serve (the sudo-aware wrapper)', () => {
|
||||
// A bare `tailscale serve --bg` or `set --hostname` would fail for a non-operator
|
||||
// user on Linux, exactly the state the wrapper exists to handle.
|
||||
const mutations = CODE_LINES.filter((line) => /\bserve --(bg|https)/.test(line) || /\bset --hostname\b/.test(line));
|
||||
expect(mutations.length).toBeGreaterThan(0);
|
||||
for (const line of mutations) {
|
||||
// Prose in warn/info strings and manual-command hints are fine; executed lines
|
||||
// must start with the wrapper.
|
||||
const executed = /^\s*(if\s+)?(!\s*)?(out=\$\()?ts_cmd_serve\b/.test(line);
|
||||
const quoted = /(info|warn|echo -e|success) /.test(line) || /Run: /.test(line) || /Configuring: /.test(line);
|
||||
expect(executed || quoted, `serve mutation outside ts_cmd_serve:\n ${line}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('decides the rename before the serve shape, and applies serve only after the build', () => {
|
||||
// Serve config is keyed by the DNS name it was written under: renaming after
|
||||
// configuring would orphan the mapping (and only `serve reset` could remove the
|
||||
// stale key). tailscale_prepare therefore asks the name first, chooses the shape
|
||||
// second, and main() applies the shape only after the build and the service.
|
||||
const prepare = SOURCE.slice(SOURCE.indexOf('tailscale_prepare() {'), SOURCE.indexOf('tailscale_apply() {'));
|
||||
expect(prepare.indexOf('tailscale_choose_name')).toBeGreaterThan(-1);
|
||||
expect(prepare.indexOf('tailscale_choose_name')).toBeLessThan(prepare.indexOf('tailscale_choose_mapping'));
|
||||
const main = SOURCE.slice(SOURCE.indexOf('\nmain() {'), SOURCE.indexOf('\npreflight_detect() {'));
|
||||
const order = [
|
||||
'choose_network_binding',
|
||||
'choose_launch_mode',
|
||||
'install_or_update_repo',
|
||||
'npm_install_deps',
|
||||
'run_step "Building Codeman"',
|
||||
'tailscale_apply',
|
||||
'print_done_screen',
|
||||
];
|
||||
const positions = order.map((needle) => main.indexOf(needle));
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
expect(positions[i], `${order[i]} missing from main()`).toBeGreaterThan(-1);
|
||||
if (i > 0) expect(positions[i], `${order[i]} must come after ${order[i - 1]}`).toBeGreaterThan(positions[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every flag it parses', () => {
|
||||
// The header comment is the only manual most people read (it is what `curl` shows
|
||||
// them if they look). A flag parse_flags accepts and the header does not mention
|
||||
// is a flag nobody finds.
|
||||
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
|
||||
const parse = SOURCE.slice(SOURCE.indexOf('parse_flags() {'), SOURCE.indexOf('# Sourcing guard'));
|
||||
const flags = Array.from(parse.matchAll(/^\s+(--[a-z-]+)(?:[|)=\s])/gm), (m) => m[1]);
|
||||
expect(flags.length).toBeGreaterThan(5);
|
||||
for (const flag of new Set(flags)) {
|
||||
expect(header.includes(flag), `${flag} is parsed but not documented in the header`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('renames only as an opt-in: the question defaults to no and --yes never renames', () => {
|
||||
const fn = SOURCE.slice(SOURCE.indexOf('tailscale_choose_name() {'), SOURCE.indexOf('tailscale_rename_node() {'));
|
||||
expect(fn).toMatch(/prompt_yes_no "Rename this machine to \$suggested\?" "n"/);
|
||||
expect(fn).toMatch(/\[\[ "\$ASSUME_YES" == "1" \]\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh AI CLI install menu', () => {
|
||||
// The menu is the one interactive path in the script, which is why it used to be the
|
||||
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared
|
||||
@@ -304,3 +407,127 @@ describe('install.sh detect_all_clis and a disabled entry', () => {
|
||||
expect(run.stdout).toContain('found=0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh review fixes for #460', () => {
|
||||
// Each pin here is a finding from the two reviews of PR #460 (the DeepSeek Harness
|
||||
// pass, then the Claude pass), kept as a static guard so the fix cannot quietly rot.
|
||||
const fn = (name: string, until: string) => {
|
||||
const start = SOURCE.indexOf(`${name}() {`);
|
||||
expect(start, `${name}() missing`).toBeGreaterThan(-1);
|
||||
const end = SOURCE.indexOf(until, start);
|
||||
expect(end, `${until} missing after ${name}()`).toBeGreaterThan(start);
|
||||
return SOURCE.slice(start, end);
|
||||
};
|
||||
|
||||
it('keeps an existing password on the flag and env preset paths', () => {
|
||||
// `--lan --service` on a unit that carried a password used to rewrite it without the
|
||||
// password and with the unauthenticated ack; `--tailscale` dropped it the same way.
|
||||
const body = fn('choose_network_binding', 'get_tailscale_path() {');
|
||||
expect(body.match(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\$EXISTING_PASSWORD\}"/g)?.length).toBe(2);
|
||||
expect(body).not.toMatch(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\}"/);
|
||||
// The presets can only keep what was read, so the read comes first.
|
||||
expect(body.indexOf('read_existing_binding')).toBeLessThan(body.indexOf('CODEMAN_HOST:-'));
|
||||
});
|
||||
|
||||
it('composes the hand-start environment in one place', () => {
|
||||
// "Do not start" under a sub-path or a custom port used to print a bare `codeman web`
|
||||
// under URLs that carried both.
|
||||
const hint = fn('start_command_hint', 'export_bind_env() {');
|
||||
const exported = fn('export_bind_env', '# A QR code of the URL');
|
||||
for (const key of [
|
||||
'CODEMAN_HOST',
|
||||
'CODEMAN_PASSWORD',
|
||||
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
|
||||
'CODEMAN_BASE_URL',
|
||||
'CODEMAN_PORT',
|
||||
]) {
|
||||
expect(hint, `${key} missing from start_command_hint`).toContain(key);
|
||||
expect(exported, `${key} missing from export_bind_env`).toContain(key);
|
||||
}
|
||||
const done = fn('print_done_screen', '\nupdate() {');
|
||||
expect(done).toContain('$(start_command_hint)');
|
||||
expect(done).not.toMatch(/CODEMAN_HOST=0\.0\.0\.0 codeman web/);
|
||||
});
|
||||
|
||||
it('flips RECONFIGURE for --password and --port', () => {
|
||||
// Neither used to, so on a completed install both took the quiet update path, which
|
||||
// never rewrites the unit: the password never landed and the port stayed at 3000.
|
||||
const parse = fn('parse_flags', '# Sourcing guard');
|
||||
for (const label of ['--password)', '--password=*)', '--port)', '--port=*)']) {
|
||||
const at = parse.indexOf(label);
|
||||
expect(at, `${label} missing`).toBeGreaterThan(-1);
|
||||
expect(parse.slice(at, parse.indexOf(';;', at)), `${label} does not reconfigure`).toContain('RECONFIGURE="1"');
|
||||
}
|
||||
});
|
||||
|
||||
it('ends the sudo keepalive and exports the binding before the exec', () => {
|
||||
// exec skips the EXIT trap, and the keepalive keys on $$, which becomes the server's
|
||||
// pid: it refreshed the sudo timestamp for the server's whole life.
|
||||
const execAt = SOURCE.indexOf('exec node "$INSTALL_DIR/dist/index.js" web');
|
||||
expect(execAt).toBeGreaterThan(-1);
|
||||
const before = SOURCE.slice(SOURCE.lastIndexOf('source "$profile"', execAt), execAt);
|
||||
expect(before).toContain('export_bind_env');
|
||||
expect(before).toContain('stop_background_helpers');
|
||||
});
|
||||
|
||||
it('lets Ctrl+C skip the HTTPS-toggle poll instead of ending the run', () => {
|
||||
const body = fn('ensure_tailnet_https', 'tailnet_https_poll() {');
|
||||
expect(body).toMatch(/trap '[^']*' INT/);
|
||||
expect(body).toContain('trap - INT');
|
||||
expect(fn('tailnet_https_poll', '# Everything Tailscale that needs a human')).toContain('sleep 5 || true');
|
||||
});
|
||||
|
||||
it('asks before removing a LaunchDaemon it never wrote', () => {
|
||||
const body = fn('uninstall', '\nusage() {');
|
||||
const ask = body.indexOf('prompt_yes_no "Remove that LaunchDaemon too');
|
||||
expect(ask).toBeGreaterThan(-1);
|
||||
expect(body.indexOf('sudo rm -f "$daemon_plist"')).toBeGreaterThan(ask);
|
||||
});
|
||||
|
||||
it('re-syncs the unit after `install.sh name` re-adds the mapping, and ends an update on the done screen', () => {
|
||||
const name = fn('setup_name_subcommand', '\nstatus_subcommand() {');
|
||||
expect(name.indexOf('sync_service_base_url')).toBeGreaterThan(name.indexOf('tailscale_choose_mapping'));
|
||||
expect(name.indexOf('sync_service_base_url')).toBeLessThan(name.indexOf('tailscale_apply'));
|
||||
expect(fn('update', '\nuninstall() {')).toContain('print_done_screen "" ""');
|
||||
});
|
||||
|
||||
it('reads the Tailscale state in the preflight without node, and no longer records TS_JOINED_HERE', () => {
|
||||
const preflight = fn('preflight_detect', '\nprint_preflight_summary() {');
|
||||
expect(preflight).toContain('ts_backend_state');
|
||||
expect(preflight).not.toContain('command -v node');
|
||||
expect(fn('ts_backend_state', '\nts_dns_name() {')).toContain('sed -n');
|
||||
expect(SOURCE).not.toContain('TS_JOINED_HERE');
|
||||
expect(CODE).not.toContain('at port 3000');
|
||||
});
|
||||
|
||||
it('drives the kept password and the start line in a real bash', () => {
|
||||
const DRIVER = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
|
||||
tailscale_prepare() { return 0; }
|
||||
parse_flags $DRIVE_FLAGS
|
||||
choose_network_binding >/dev/null 2>&1
|
||||
echo "host=$BIND_HOST pw=$BIND_PASSWORD ack=$BIND_ACK"
|
||||
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
|
||||
echo "hint=$(start_command_hint)"
|
||||
BIND_HOST=127.0.0.1; BIND_PASSWORD=""; BIND_BASE_URL=""; CODEMAN_PORT=""
|
||||
echo "bare=$(start_command_hint)"
|
||||
`;
|
||||
const drive = (flags: string) => {
|
||||
const env = { ...process.env, DRIVE_FLAGS: flags };
|
||||
delete env.CODEMAN_PASSWORD;
|
||||
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return result.stdout;
|
||||
};
|
||||
const lan = drive('--lan');
|
||||
expect(lan).toContain('host=0.0.0.0 pw=s3cret ack=0');
|
||||
expect(lan).toContain(
|
||||
"hint=CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web"
|
||||
);
|
||||
expect(lan).toContain('bare=codeman web');
|
||||
expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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