mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9466acfc1a | ||
|
|
e899af4305 | ||
|
|
299a21d5f5 | ||
|
|
d8e85285c9 | ||
|
|
0f955327b2 | ||
|
|
d3f2ec0220 | ||
|
|
6ef71ec3b9 | ||
|
|
d47f93abdb | ||
|
|
dcf9437308 | ||
|
|
aa13af1f7f | ||
|
|
9a2e14a93a | ||
|
|
ecb95b5d67 | ||
|
|
a7452dc046 | ||
|
|
46d8b92049 | ||
|
|
0b3e086334 | ||
|
|
fafef0aa00 | ||
|
|
3152ec801d | ||
|
|
2e3e245cc6 | ||
|
|
165cfb52d6 | ||
|
|
6c8bd6c606 | ||
|
|
8d3bde5469 | ||
|
|
78dcb0aa24 | ||
|
|
7fc66e8161 | ||
|
|
1678386f50 | ||
|
|
3859506f9b | ||
|
|
33b2605815 | ||
|
|
d0a887d98a | ||
|
|
24a92c8f3e | ||
|
|
f7852081b7 | ||
|
|
f0e24d8ce2 | ||
|
|
2724c922ce | ||
|
|
57406f6c14 | ||
|
|
8903a72662 | ||
|
|
ba7b8b7bef | ||
|
|
8c73128cd6 | ||
|
|
2abf328db8 | ||
|
|
fa8bb13a27 | ||
|
|
6f64e557e5 | ||
|
|
97a1238c85 | ||
|
|
aa0521602d | ||
|
|
2bc16d5fd9 | ||
|
|
d60a164025 | ||
|
|
727817410c | ||
|
|
28d3bd7da8 | ||
|
|
d0f9bdd251 | ||
|
|
ff98006471 | ||
|
|
5f1be90ae9 | ||
|
|
3da8bb7046 | ||
|
|
ac574d6c64 | ||
|
|
d9e6ebb20a | ||
|
|
88e5b7b200 | ||
|
|
73607663fd | ||
|
|
458ca578e7 | ||
|
|
884713cca5 | ||
|
|
a220c28a14 | ||
|
|
0761de3dae | ||
|
|
c2eaba990b | ||
|
|
e214691429 | ||
|
|
773b405429 | ||
|
|
2df9355367 | ||
|
|
cd64b0a3f7 | ||
|
|
2d573d8a34 | ||
|
|
55a80eab86 | ||
|
|
b1db5515d7 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d47f93a: feat(custom-model): the model picker puts the ready model first
|
||||
|
||||
When a custom endpoint has more than one model, the Run menu's picker now promotes one row to the top instead of showing raw discovery order: the model llama-swap reports loaded and ready right now (tagged "Currently loaded", the one a launch attaches to with zero wait), else the model you last launched on that harness and endpoint (tagged "Last used", remembered per device). The endpoint's default keeps its own pill, nothing is ever auto-chosen, and a plain OpenAI-compatible server or an endpoint that does not answer within a second simply keeps the old order. The probe is bounded on the client too, so a GPU box that is off no longer holds the picker closed for five seconds.
|
||||
|
||||
- d47f93a: feat(split-pane): view two live sessions side by side
|
||||
|
||||
A new Split button in the header (opt-in in App Settings, off by default, desktop only at 1180px and wider) opens a picker and shows a second live session beside the active one: its own terminal, its own WebSocket, and a divider you can drag. When either session ends the view collapses back to one pane, with Pane B promoted to the primary when it is Pane A that ended. Nothing is persisted on purpose in this first cut, so a page reload always returns to a single pane. Pane B is deliberately plainer than the primary pane (no local-echo overlay, CJK input, touch handling or keyboard accessory bar); the design and the v2 boundaries are in discussion #452.
|
||||
|
||||
- 72d437a: Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
|
||||
- d47f93a: feat(mobile): a Compose key for writing prompts on a phone
|
||||
|
||||
The agent keyboard bars on phones replace their Paste key with Compose: a real multiline editor with autocorrect and spellcheck, per-session drafts kept in memory only, image attach that never writes into the terminal early, and a Send that delivers the text as one paste followed by Enter, so a long prompt no longer has to be typed blind into the terminal composer. Anything you had already typed into the terminal is picked up into the editor. Shell sessions keep the direct Paste key. This is the manual first slice from #359; the auto-open setting and terminal tap routing are a separate follow-up.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d47f93a: refactor(run-menu): one table-driven launcher for every external CLI
|
||||
|
||||
The eight near-identical per-CLI launch functions in the Run menu collapsed into one launcher driven by a table that a CI test keeps in step with the CLI registry, and a second no-id-branching guard now covers the frontend the way the backend guard covers the server. No behaviour change: the refactor was verified byte-identical across 288 launch permutations against the previous code.
|
||||
|
||||
- e899af4: Maintainer fixes applied while landing the above. The model picker's promoted row keeps its Default pill (the promotion tag and the default marker are two pills now, and they render as pills in the picker rather than as plain text). The phone composer keeps its bottom gutter on folding devices (the generic fold rule used to erase it), a whitespace-only draft is no longer sent, and its dialog is translated on a zh-CN UI. A split that collapses mid-drag no longer leaves the page stuck in resize-cursor mode, Pane B refuses a session that has no live process, and a burst of refresh frames replays once instead of twice. The `</head>` script injections on the page render use replacer functions, so a CLI label containing `$'` can no longer splice the document into the inline script, and the frontend no-id-branching guard now catches comparisons on any variable name.
|
||||
- 6ef71ec: ### Thanks
|
||||
- @timkjr for split-pane sessions (#453): five review rounds turned around in two days, and the pointer-capture edge case measured in a real browser rather than reasoned about.
|
||||
- @DodgyBadger for the mobile prompt composer (#444), a first contribution that took the scope back down to one slice when asked, and that verified the delivery path against a live tmux pane and a live Claude Code composer instead of trusting the diff.
|
||||
- @opticon454 for putting the ready model first in the picker (#459) and for collapsing the eight Run-menu launch functions into one (#458), proven byte-identical across 288 launch permutations instead of argued.
|
||||
|
||||
## 1.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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,198 @@
|
||||
# Split-Pane Sessions — Design Spec
|
||||
|
||||
**Status**: Implemented (v1)
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
switching panes re-binds the single xterm instance and the single WebSocket
|
||||
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
|
||||
`span-codeman.ps1`) turned out to solve a different problem: it makes one
|
||||
browser window bigger, but that window still shows one session; floating
|
||||
subagent windows are draggable overlays on top of it, not tiled panes. There
|
||||
is no way today to see two live sessions (e.g. `w1-codeman` and
|
||||
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
|
||||
fit both.
|
||||
|
||||
## Goal (v1)
|
||||
|
||||
From the active session, open a **second, independent, fully live session**
|
||||
in a pane beside it — draggable divider, side-by-side only. Closing the
|
||||
second pane collapses back to today's normal single-pane view. No
|
||||
persistence: a page reload always returns to single-pane. Floating
|
||||
subagent/Ultracode windows keep their current behavior unchanged (global,
|
||||
unconstrained across the whole viewport, split or not).
|
||||
|
||||
Explicitly out of scope for v1 (v2 candidates, not designed here):
|
||||
- More than 2 panes / grid layouts
|
||||
- Vertical (stacked) splits
|
||||
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
|
||||
- Persisting the split layout across reload or across devices
|
||||
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
|
||||
to desktop widths the same way `home-sessions.js`'s rail is)
|
||||
- Feature parity between the two panes (see "Pane B is deliberately plainer"
|
||||
below)
|
||||
|
||||
## Current architecture (why this isn't a CSS change)
|
||||
|
||||
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
|
||||
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
|
||||
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
|
||||
`this._xtermSnapshots` map used only to restore scrollback into that one
|
||||
terminal when switching back to a session. Roughly 280 references to this
|
||||
singleton state exist across the file (input handling, resize/fit, sizing-
|
||||
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
|
||||
keyboard accessory bar, link providers, etc.).
|
||||
|
||||
Showing two sessions at once therefore requires a second, independently
|
||||
alive xterm + WebSocket pair running concurrently — not a layout change to
|
||||
one shared instance.
|
||||
|
||||
**Related prior art**: `detachSession(id)` (app.js) already opens one session
|
||||
in a genuinely separate browser window (`isSoloWindow` mode) with its own
|
||||
independent WebSocket, and two of those can already be snapped side-by-side
|
||||
today with zero new code. That covers "two sessions visible at once" but not
|
||||
what this spec is for: one Codeman window with two panes and a divider you
|
||||
can drag without leaving your seat, each still a full participant in that
|
||||
window's floating subagent windows, header, and settings. This spec builds
|
||||
past detach, not a duplicate of it.
|
||||
|
||||
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
|
||||
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
|
||||
(`ws-connection-registry.ts`). Splitting always opens a *different* session
|
||||
in the second pane (self-splitting is disallowed, see below), so this is two
|
||||
sessions each getting their normal one connection — the existing cap is
|
||||
irrelevant here and needs no server change.
|
||||
|
||||
## Key design decision: Pane B is deliberately plainer than Pane A
|
||||
|
||||
Porting all ~280 singleton behaviors to a second, symmetric pane is not
|
||||
worth it for v1 — most of that code is input-quality-of-life for **mobile/
|
||||
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
|
||||
keyboard accessory bar), and this feature is desktop-only by nature (a split
|
||||
view needs a wide viewport). So:
|
||||
|
||||
- **Pane A** (the session that was already active when you opened the split)
|
||||
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
|
||||
code path, zero regression risk.
|
||||
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
|
||||
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
|
||||
resize-on-divider-drag, and plain keyboard input. It does **not** get the
|
||||
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
call sites to compare, rather than guessing the right abstraction now).
|
||||
|
||||
One more asymmetry worth naming here rather than discovering by surprise:
|
||||
while both panes accept keyboard input, the global capture-phase shortcut
|
||||
handler (`app.js`) always resolves against Pane A — it has no notion of
|
||||
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
|
||||
focus clears or closes Pane A, not the session you were actually typing
|
||||
into. Not fixed for v1, same reasoning as the rest of this section.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
|
||||
|
||||
A small class, one instance per secondary pane:
|
||||
- `constructor(sessionId, mountEl)`
|
||||
- `connect()` — creates the xterm instance (same theme/font config as the
|
||||
primary, read from the same settings so it doesn't visually clash), opens
|
||||
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
|
||||
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
|
||||
on window resize
|
||||
- `destroy()` — disposes the xterm instance, closes the WS cleanly
|
||||
|
||||
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
|
||||
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
|
||||
there's no persistence requirement.
|
||||
|
||||
### 2. Split container (layout)
|
||||
|
||||
```
|
||||
.terminal-split-container (flex row, only rendered when split is active)
|
||||
├── .terminal-wrap (existing element, Pane A — untouched)
|
||||
├── .split-divider (new, draggable seam)
|
||||
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
|
||||
small header: session name + × close button)
|
||||
```
|
||||
|
||||
When not split, `.terminal-wrap` renders exactly as it does today (no
|
||||
wrapping container at all, to keep the no-split path byte-identical to
|
||||
current behavior). Splitting inserts the container and reparents
|
||||
`.terminal-wrap` into it as the first child — same reparenting pattern
|
||||
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
|
||||
a new pattern for the codebase.
|
||||
|
||||
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
|
||||
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
|
||||
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
|
||||
unusably thin sliver.
|
||||
|
||||
### 3. Trigger UI
|
||||
|
||||
A **"Split"** button (header, opt-in like the other header buttons —
|
||||
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
|
||||
opens a small picker listing your other open sessions (reuses
|
||||
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
|
||||
session — you cannot split a session against itself). Picking one:
|
||||
1. Creates the split container, reparents `.terminal-wrap`
|
||||
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
|
||||
3. Button state flips to "close split" (or Pane B's own header × does it)
|
||||
|
||||
Closing (via Pane B's × or the header button toggling off):
|
||||
1. `SplitTerminalPane.destroy()`
|
||||
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
|
||||
its original location at 100% width
|
||||
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
|
||||
place today — no new code needed here, it fires naturally once the
|
||||
container's size changes)
|
||||
|
||||
v2 note (not designed): dragging a session tab onto the active pane as an
|
||||
alternate trigger. You confirmed right-click doesn't work today (Codeman
|
||||
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
|
||||
|
||||
### 4. Failure / edge cases
|
||||
|
||||
- **The Pane B session ends or is deleted while split is active** → treat
|
||||
identically to the user closing Pane B manually: destroy the pane, collapse
|
||||
to Pane A at full width.
|
||||
- **The Pane A session ends while split is active** → Pane B is promoted:
|
||||
it becomes the new single full-width pane (reusing today's normal
|
||||
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
|
||||
a real `this.terminal`/`this._ws` binding — simplest correct approach is
|
||||
to just collapse the split and let normal session-select logic reopen
|
||||
Pane B's session as the new primary, rather than trying to promote the
|
||||
lightweight pane object in place).
|
||||
- **Both end** → falls through to today's normal "no active session" /
|
||||
welcome-screen state.
|
||||
- **Subagent/Ultracode floating windows** → no design work needed; they're
|
||||
already positioned independent of `.terminal-wrap`'s layout, so they
|
||||
continue to float over whichever pane(s) are on screen, unconstrained,
|
||||
exactly as today.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
|
||||
existing terminal tests use `TEST_PTY_SCRIPT`).
|
||||
- Route/integration: opening two WS connections to two different sessions
|
||||
from one simulated client concurrently — confirms the existing per-session
|
||||
cap and connection registry need no changes.
|
||||
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
|
||||
UI): open split via button+picker, verify both panes render live output
|
||||
independently, drag divider and confirm both refit, close Pane B and
|
||||
confirm Pane A returns to full width, kill the Pane B session externally
|
||||
and confirm auto-collapse.
|
||||
|
||||
## Open questions for review
|
||||
|
||||
None blocking — the scope-narrowing decisions above (Pane B feature parity,
|
||||
no persistence, side-by-side only, button+picker trigger) came directly from
|
||||
your answers during brainstorming. Flag anything here you want reconsidered.
|
||||
@@ -50,7 +50,13 @@ pointed at.
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default.
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
|
||||
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
|
||||
|
||||
A row of keys above the virtual keyboard, and what it contains depends on the session.
|
||||
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
|
||||
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
|
||||
double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
|
||||
shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
|
||||
message and walking the prompt stack.
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
|
||||
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
|
||||
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
|
||||
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
|
||||
there), and dismiss. Your normal preference is remembered and restored when you
|
||||
switch back to an agent session, so a settings change during a shell session cannot strip
|
||||
the bar away permanently.
|
||||
|
||||
|
||||
@@ -55,12 +55,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
|
||||
@@ -116,6 +116,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -6851,6 +6851,7 @@ class CodemanApp {
|
||||
|
||||
this._flushedOffsets?.delete(sessionId);
|
||||
this._flushedTexts?.delete(sessionId);
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
|
||||
// Drop any durably-queued input for a session that's actually gone (deleted/
|
||||
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
||||
// real session removal, never on a tab switch.
|
||||
|
||||
@@ -1516,8 +1516,57 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||||
// touch handlers. A dedicated constant rather than reusing
|
||||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||||
// from module-evaluation-time code here would be a ReferenceError.
|
||||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||||
|
||||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||||
if (rawPercent < min) return min;
|
||||
if (rawPercent > max) return max;
|
||||
return rawPercent;
|
||||
}
|
||||
|
||||
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
'File Viewer': '文件查看器',
|
||||
'Open file viewer': '打开文件查看器',
|
||||
'Open Codeman across all displays': '在所有显示器上打开 {name}',
|
||||
'Split: open a second session beside this one': '分屏:在旁边打开第二个会话',
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
@@ -312,6 +316,8 @@
|
||||
'运行菜单选择器会为此端点应用该模型。请先发现可用模型。',
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'Last used': '上次使用',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
'Subagent Options': '子智能体选项',
|
||||
@@ -445,6 +451,17 @@
|
||||
'Show Shortcuts': '显示快捷键',
|
||||
'Full shortcut reference': '完整快捷键参考',
|
||||
|
||||
// Mobile prompt composer (keyboard-accessory.js). The textarea's own
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
'Write your prompt…': '请输入提示词…',
|
||||
'Use terminal keyboard': '使用终端键盘',
|
||||
'Uploading…': '上传中…',
|
||||
|
||||
// Mobile overview (phone home screen)
|
||||
'Needs you': '需要你',
|
||||
'Current sessions': '当前会话',
|
||||
|
||||
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
/** Upload a batch and normally insert its paths into the active terminal.
|
||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||
* into its textarea instead. Returns successful paths in selection order. */
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
@@ -1924,6 +1925,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
@@ -3719,6 +3721,7 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
|
||||
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
||||
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
||||
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
|
||||
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
|
||||
* dialog because shell input is not an agent prompt.
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
@@ -635,6 +636,16 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
@@ -648,8 +659,15 @@ const KeyboardAccessoryBar = {
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
|
||||
// so persistence would need the same treatment as the 0600 intent store.
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
@@ -665,10 +683,10 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||
@@ -740,10 +758,10 @@ const KeyboardAccessoryBar = {
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
@@ -781,6 +799,7 @@ const KeyboardAccessoryBar = {
|
||||
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
||||
// session's mode instead of a setting.
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -823,8 +842,13 @@ const KeyboardAccessoryBar = {
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
const activeSessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== activeSessionId) {
|
||||
this._composerOverlay._closeComposer?.({ restoreFocus: false });
|
||||
}
|
||||
this._applyLayout(this._resolveMode());
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
@@ -852,6 +876,20 @@ const KeyboardAccessoryBar = {
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Show when the active session has a prompt parked in memory. The marker
|
||||
* keeps non-Send closes visible without copying the draft back into the PTY
|
||||
* and creating a second source of truth. */
|
||||
_syncComposerDraftIndicator() {
|
||||
const button = this.element?.querySelector('[data-action="compose"]');
|
||||
if (!button) return;
|
||||
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
|
||||
button.classList.toggle('has-draft', hasDraft);
|
||||
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
|
||||
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
@@ -977,6 +1015,9 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'compose':
|
||||
this.composePrompt();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
@@ -1130,6 +1171,230 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Move the whole editable terminal prompt into the composer. Pending text
|
||||
* exists only in the overlay; flushed text already reached the PTY, so erase
|
||||
* that prefix before making the textarea authoritative. */
|
||||
_takePendingLocalEcho(sessionId) {
|
||||
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
|
||||
const pending = app._localEchoOverlay.pendingText || '';
|
||||
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
|
||||
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
|
||||
const flushedLength = Array.from(flushedText).length;
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(sessionId);
|
||||
app._flushedTexts?.delete(sessionId);
|
||||
if (flushedLength > 0) {
|
||||
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedLength), { useMux: true });
|
||||
}
|
||||
return flushedText + pending;
|
||||
},
|
||||
|
||||
/** Insert text at the textarea selection, adding one separating space when
|
||||
* an attachment path would otherwise run into neighboring prompt text. */
|
||||
_insertComposerText(textarea, text) {
|
||||
if (!textarea || !text) return;
|
||||
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
|
||||
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
|
||||
const before = textarea.value.slice(0, start);
|
||||
const after = textarea.value.slice(end);
|
||||
const prefix = before && !/\s$/.test(before) ? ' ' : '';
|
||||
const suffix = after && !/^\s/.test(after) ? ' ' : '';
|
||||
const inserted = `${prefix}${text}${suffix}`;
|
||||
textarea.setRangeText(inserted, start, end, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Forget a draft when its target session no longer exists. */
|
||||
discardComposerDraft(sessionId) {
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._composerUploads.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
||||
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** Open the manual agent prompt composer. Enter remains a newline; only the
|
||||
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
|
||||
composePrompt() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
if (this._isShellSession()) {
|
||||
this.pasteFromClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay prompt-composer-overlay';
|
||||
overlay.dataset.sessionId = sessionId;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Compose prompt');
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog prompt-composer-dialog">
|
||||
<div class="prompt-composer-header">
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">🖼 Image</button>
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
if (typeof i18n?.t === 'function') {
|
||||
textarea.placeholder = i18n.t('Write your prompt…');
|
||||
textarea.setAttribute('aria-label', i18n.t('Prompt'));
|
||||
}
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
this._syncComposerDraftIndicator();
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
||||
else this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
};
|
||||
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
||||
if (preserveDraft) saveDraft();
|
||||
focusTrap.deactivate({ restoreFocus });
|
||||
overlay.remove();
|
||||
if (this._composerOverlay === overlay) this._composerOverlay = null;
|
||||
if (focusTerminal) app.terminal?.focus();
|
||||
};
|
||||
overlay._closeComposer = close;
|
||||
const send = () => {
|
||||
// Whitespace-only counts as empty (it would submit blank lines), but the
|
||||
// text goes out untrimmed so deliberate leading or trailing lines survive.
|
||||
const text = textarea.value;
|
||||
if (!text.trim() || !this._sendComposedPrompt(sessionId, text)) return;
|
||||
app._echoPassthroughSessions?.delete(sessionId);
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
const syncUploadUi = () => {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const count = this._composerUploads.get(sessionId) || 0;
|
||||
const currentImageButton = currentOverlay?.querySelector('.paste-image');
|
||||
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||
if (currentImageButton) {
|
||||
currentImageButton.disabled = count > 0;
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
const paths = await app._uploadAndInsertImages(images, { insert: false });
|
||||
if (paths?.length) {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
|
||||
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
|
||||
else if (app.sessions?.has(sessionId)) {
|
||||
const draft = this._composerDrafts.get(sessionId) || '';
|
||||
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
||||
this._syncComposerDraftIndicator();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
textarea.addEventListener('input', saveDraft);
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
focusTrap.activate();
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
@@ -1259,9 +1524,9 @@ class FocusTrap {
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
deactivate({ restoreFocus = true } = {}) {
|
||||
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
||||
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
this.previouslyFocused.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,11 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-approvals {
|
||||
display: none !important;
|
||||
}
|
||||
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
|
||||
wider `@media (max-width: 1179px)` rule in styles.css — this file only
|
||||
loads up to 1023px, which does not cover the 1024-1179px tablet range
|
||||
the split feature also needs to stay off (two 240px min-width panes plus
|
||||
the divider need ~486px; the divider also has no touch handlers). */
|
||||
|
||||
/* Read My Mind 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
|
||||
+281
-454
@@ -11,6 +11,119 @@
|
||||
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
*
|
||||
* Deliberately a LOCAL table rather than a server-injected catalogue: several
|
||||
* unit tests exercise these run*() methods inside a bare `vm.createContext()`
|
||||
* sandbox with no `window` global at all (see test/run-mode-ui.test.ts) —
|
||||
* referencing `window` there unguarded would throw, not degrade. `buildConfig`
|
||||
* returns the CLI's top-level legacy config field for a LOCAL launch, or
|
||||
* `null` for a CLI that sends none (pi: no bypass flag exists, so there is
|
||||
* nothing to send — see runPi's own history below for why that must stay
|
||||
* true).
|
||||
*/
|
||||
const RUN_MODE_LAUNCH = {
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }),
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: (globalSettings) => ({
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
}),
|
||||
},
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }),
|
||||
},
|
||||
antigravity: {
|
||||
label: 'Antigravity',
|
||||
installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||
// antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md
|
||||
// calls it `unsupported`) — never fold a pending pick into its launch body.
|
||||
supportsCustomModel: false,
|
||||
buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }),
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
supportsCustomModel: true,
|
||||
// Deliberately NO piConfig: pi has no permission prompts, so there is no
|
||||
// bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
// decision (an interactive prompt the user answers in the terminal).
|
||||
// Sending `approveProjectTrust: true` here would silently opt every
|
||||
// browser-launched pi session into executing repo-supplied TypeScript.
|
||||
buildConfig: () => null,
|
||||
},
|
||||
omp: {
|
||||
label: 'OMP',
|
||||
installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => null,
|
||||
},
|
||||
grok: {
|
||||
label: 'Grok',
|
||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
supportsCustomModel: true,
|
||||
// Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends
|
||||
// `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
// work, so the Run button opts into grok's bypassPermissions mode
|
||||
// (`--always-approve`; config-level deny rules still apply on top). The
|
||||
// multi-user clamp forces it back off for non-granted owners server-side.
|
||||
buildConfig: () => ({ grokConfig: { alwaysApprove: true } }),
|
||||
},
|
||||
deepseek: {
|
||||
label: 'DeepSeek',
|
||||
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
|
||||
// The two-part availability check is deliberate. `dsh` being installed is
|
||||
// not enough — DeepSeek ships no terminal front door, so a box can have a
|
||||
// perfect binary and nothing a pane can run.
|
||||
unrunnableHint:
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui',
|
||||
supportsCustomModel: true,
|
||||
// Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
// sibling Run button sends its bypass switch. The harness has no bypass
|
||||
// FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the
|
||||
// multi-user clamp forces it back down to `workspace-write` server-side.
|
||||
//
|
||||
// `statusReporting` is deliberately LEFT UNSET, i.e. ON: it is what upgrades
|
||||
// this mode from output-stabilization guessing to definitive idle/blocked
|
||||
// hook events (the harness reports to Codeman as its supervisor, see
|
||||
// deepseek-status-shim.ts). Never send `statusReporting: false` from here.
|
||||
buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH
|
||||
* above, kept as its own Set (`EXTERNAL_CLI_MODES.has(mode)`) rather than an
|
||||
* array recomputed per call. Single source for what used to be two hand-copied
|
||||
* 8-way `session.mode === '<id>' || ...` chains inside one function
|
||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||
* ninth CLI landed in one and not the other.
|
||||
*/
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Build envOverrides payload from case + global settings.
|
||||
@@ -395,34 +508,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return await this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return await this.runCodex();
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
if (mode === 'grok') {
|
||||
return await this.runGrok();
|
||||
}
|
||||
if (mode === 'deepseek') {
|
||||
return await this.runDeepSeek();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
return await this.runClaude();
|
||||
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||
return await this.runClaude();
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
@@ -633,14 +725,101 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (models.length === 1) {
|
||||
return this.runCustomModelEntry(mode, endpointId, models[0]);
|
||||
}
|
||||
this._openCustomModelPickModal(mode, host);
|
||||
await this._openCustomModelPickModal(mode, host);
|
||||
},
|
||||
|
||||
/** Renders the "which model" picker for a (harness, endpoint) pair with more than one discovered model. */
|
||||
_openCustomModelPickModal(mode, host) {
|
||||
/** localStorage key for the last model launched on a given (harness, endpoint) pair — per-device by design, like every other `codeman:*` UI preference, never synced. */
|
||||
_customModelLastUsedKey(mode, endpointId) {
|
||||
return `codeman:customModelLastUsed:${mode}:${endpointId}`;
|
||||
},
|
||||
|
||||
/** Reads the last model chosen for this (harness, endpoint) pair, or null. Never throws — a blocked/full localStorage just means no promotion, not a broken picker. */
|
||||
_getCustomModelLastUsed(mode, endpointId) {
|
||||
try {
|
||||
return localStorage.getItem(this._customModelLastUsedKey(mode, endpointId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Remembers `modelId` as the last one launched for this (harness, endpoint) pair. */
|
||||
_setCustomModelLastUsed(mode, endpointId, modelId) {
|
||||
try {
|
||||
localStorage.setItem(this._customModelLastUsedKey(mode, endpointId), modelId);
|
||||
} catch {
|
||||
// best-effort — losing the "last used" hint is cosmetic, never worth surfacing
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
|
||||
* endpoint, so the picker can offer it first instead of making the user remember what
|
||||
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
|
||||
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
|
||||
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
|
||||
* never throws, since a failed probe should just skip promotion, not break the picker.
|
||||
*
|
||||
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
|
||||
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
|
||||
* a saved endpoint keeps its discovered models cached, so "the box behind this endpoint
|
||||
* is asleep or firewalled" is a normal way to reach this path, not an exotic one, and
|
||||
* the modal must not sit invisible (Run menu already closed, nothing else on screen)
|
||||
* for the full 5s a slow/dead endpoint can take. The losing side of the race is left to
|
||||
* resolve on its own — `.catch(() => null)` only stops an unhandled-rejection warning
|
||||
* when it eventually fails, it never cancels the in-flight fetch.
|
||||
*
|
||||
* `timeoutMs` exists to let a test drive this in milliseconds instead of the real
|
||||
* 800 — same reasoning as `_watchLlamaSwapLoading`'s own `pollIntervalMs`: this code
|
||||
* runs inside a JSDOM window's own realm, whose `setTimeout` is not the one
|
||||
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
|
||||
* actually waiting on it. Real callers never pass it.
|
||||
*/
|
||||
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
|
||||
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
|
||||
() => null
|
||||
);
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
||||
const status = await Promise.race([probe, timeout]);
|
||||
if (!status?.isLlamaSwap) return null;
|
||||
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
|
||||
return ready?.model || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
|
||||
* discovered model. Async since it now awaits the currently-loaded-model probe below,
|
||||
* so a SECOND call (a different custom-model entry clicked while the first one's probe
|
||||
* is still in flight — the probe has its own 5s timeout) must not let the first call's
|
||||
* later-arriving response clobber the second's already-rendered, already-correct modal.
|
||||
* `_customModelPickGeneration` is the same guard-a-mutable-counter pattern
|
||||
* `_watchLlamaSwapLoading` uses for the same reason: every DOM write below, including
|
||||
* `_pendingCustomModelPick` itself, stays deferred until after the await, and a call
|
||||
* that finds a newer generation already claimed bails out untouched rather than only
|
||||
* skipping the model-list write and leaving title/hint/`_pendingCustomModelPick`
|
||||
* inconsistent with what's on screen.
|
||||
*/
|
||||
async _openCustomModelPickModal(mode, host) {
|
||||
const modal = document.getElementById('customModelPickModal');
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!modal || !list) return;
|
||||
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
|
||||
const isCurrent = () => this._customModelPickGeneration === generation;
|
||||
|
||||
// Whichever model llama-swap actually has loaded right now beats a merely
|
||||
// remembered choice — it's what a launch would attach to with zero wait, while
|
||||
// "last used" might have been swapped out by another session since. Neither
|
||||
// reorders past the top: exactly one model is promoted, everything else keeps
|
||||
// its discovery order.
|
||||
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
|
||||
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
|
||||
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || lastUsed;
|
||||
const models = [...(host.models || [])];
|
||||
if (promoted && models.includes(promoted)) {
|
||||
models.splice(models.indexOf(promoted), 1);
|
||||
models.unshift(promoted);
|
||||
}
|
||||
|
||||
this._pendingCustomModelPick = { mode, endpointId: host.id };
|
||||
const cliLabel = (window.__codemanCustomModelClis || []).find((c) => c.id === mode)?.label || mode;
|
||||
// A static title (translatable by i18n.js's exact-string walker) plus a
|
||||
@@ -649,13 +828,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
||||
document.getElementById('customModelPickHint').textContent =
|
||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||
list.innerHTML = (host.models || [])
|
||||
list.innerHTML = models
|
||||
.map((m) => {
|
||||
const isDefault = m === host.defaultModelId;
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||
// promoted model IS the default more often than not. One slot holding whichever
|
||||
// applied first silently dropped the Default marking for exactly that row.
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${isDefault ? ' <span class="set-scope">Default</span>' : ''}
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
@@ -771,6 +956,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* (Codex, confirmed live) than on claude's own `--resume`-based restart.
|
||||
*/
|
||||
async runCustomModelEntry(mode, endpointId, modelId) {
|
||||
// "Last used" is recorded by each path itself, ONLY once the model is actually
|
||||
// applied — never here, unconditionally, on the mere attempt. A context-window
|
||||
// warning or a swap-conflict question can still say no after this call, and the
|
||||
// context-warning case is the one that bites: declining it means this exact
|
||||
// model cannot work with this CLI at all, so promoting it as "Last used" next
|
||||
// time the picker opens would be actively wrong, not just premature.
|
||||
if (mode === 'claude') {
|
||||
return this._runCustomModelEntryViaRestart(mode, endpointId, modelId);
|
||||
}
|
||||
@@ -863,7 +1054,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
answered = { ...answered, confirmedSwap: true };
|
||||
data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, ...answered } });
|
||||
}
|
||||
this._lastCustomModelLaunchResult = data?.success !== false ? data?.data : undefined;
|
||||
const launched = data?.success !== false;
|
||||
this._lastCustomModelLaunchResult = launched ? data?.data : undefined;
|
||||
// Only once actually launched, and only for a call that carried a custom-model pick
|
||||
// at all — `_launchQuickStartInstances` runs every quick-start body (custom-model or
|
||||
// not) through this same function, so a plain launch must not fall through here with
|
||||
// an undefined endpointId/modelId that quietly no-ops the (mode, endpointId) key.
|
||||
if (launched && bodyObj.customModel) {
|
||||
this._setCustomModelLastUsed(bodyObj.mode, bodyObj.customModel.endpointId, bodyObj.customModel.modelId);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -991,6 +1190,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// The apply has actually succeeded and both questions (if asked) are answered
|
||||
// yes — only now is this a real "last used" for the picker's next open, not
|
||||
// before either confirmation had a chance to decline it.
|
||||
this._setCustomModelLastUsed(mode, endpointId, modelId);
|
||||
|
||||
// The apply above already succeeded — the session IS pointed at the endpoint — but
|
||||
// llama-swap itself may still be unloading the old model and loading this one, which
|
||||
// can take well over a minute. Without this, a prompt sent during that window either
|
||||
@@ -1917,110 +2121,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
return firstSessionId;
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
/**
|
||||
* Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except
|
||||
* claude/shell, which have their own flows — claude for its remote/docker
|
||||
* branching and parallel-create path, shell for needing no CLI probe at
|
||||
* all). The eight run<Mode>() methods below are thin named wrappers: their
|
||||
* names stay because index.html's welcome-screen buttons and the run-mode
|
||||
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
const entry = RUN_MODE_LAUNCH[mode];
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
// rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const statusRes = await fetch(`/api/${mode}/status`);
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OpenCode',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Codex session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
if (entry.unrunnableHint && !status.runnable) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
// No `effort` field for ANY entry in RUN_MODE_LAUNCH: effort is
|
||||
// Claude-specific (runClaude() alone sends it, and the backend turns it
|
||||
// into `claude --settings`); none of these CLIs has an /effort. Each of
|
||||
// the eight bodies this launcher replaced carried that rule as a comment.
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Codex',
|
||||
entry.label,
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
mode,
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
...(entry.supportsCustomModel && this._pendingCustomModelForLaunch
|
||||
? { customModel: this._pendingCustomModelForLaunch }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
@@ -2038,368 +2197,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
return this._runCliMode('opencode');
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
return this._runCliMode('codex');
|
||||
},
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Gemini',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('gemini');
|
||||
},
|
||||
|
||||
async runAntigravity() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Antigravity',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'antigravity',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('antigravity');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Pi (pi.dev) session.
|
||||
*
|
||||
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
|
||||
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
* decision (an interactive prompt the user answers in the terminal). Sending
|
||||
* `approveProjectTrust: true` here would silently opt every browser-launched pi
|
||||
* session into executing repo-supplied TypeScript.
|
||||
*/
|
||||
async runPi() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Pi session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/pi/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Pi',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'pi',
|
||||
sessionName,
|
||||
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
|
||||
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('pi');
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OMP session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OMP',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('omp');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
||||
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
* work, so the Run button opts into grok's bypassPermissions mode
|
||||
* (`--always-approve`; config-level deny rules still apply on top). The
|
||||
* multi-user clamp forces it back off for non-granted owners server-side.
|
||||
*/
|
||||
async runGrok() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Grok session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/grok/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Grok',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'grok',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
grokConfig: { alwaysApprove: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('grok');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a DeepSeek Harness (`dsh`) session.
|
||||
*
|
||||
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
||||
* autonomous work. The harness has no bypass FLAG, so this rides the
|
||||
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
||||
* back down to `workspace-write` for non-granted owners server-side.
|
||||
*
|
||||
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
||||
* output-stabilization guessing to definitive idle/blocked hook events.
|
||||
*
|
||||
* The two-part availability check is deliberate. `dsh` being installed is not
|
||||
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
||||
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
||||
* command that fixes it, is the difference between "the Run button is broken"
|
||||
* and a 30-second fix.
|
||||
*/
|
||||
async runDeepSeek() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/deepseek/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!status.runnable) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'DeepSeek',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'deepseek',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('deepseek');
|
||||
},
|
||||
|
||||
|
||||
@@ -2467,7 +2294,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -2497,7 +2324,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isExternalCli = isAltMode;
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
|
||||
@@ -407,6 +407,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -2120,6 +2121,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2350,6 +2352,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
// Desktop-only header button, per-device, and absent from
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
showSplitButton: _ssp,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -2742,6 +2748,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -2947,6 +2954,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Split button — hidden by default, and hard-gated to desktop widths
|
||||
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
|
||||
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
|
||||
// pattern — the CSS in styles.css is the backstop, this is the check).
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
this._applySplitButtonVisibility?.(showSplitButton);
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -3363,6 +3377,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -675,6 +675,12 @@ body.tab-rail-resizing * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-pane-resizing,
|
||||
body.split-pane-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tab-rail,
|
||||
.tab-rail-resize-handle {
|
||||
@@ -2282,6 +2288,39 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Split-pane header button: hidden by default (opt-in via App Settings →
|
||||
Header & Panels → Header buttons → Split, `showSplitButton`). Pure
|
||||
client-side toggle applied by applyHeaderVisibilitySettings(). */
|
||||
.btn-split--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
for assistive tech). */
|
||||
.btn-icon-header.btn-split.split-open {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-split.split-open:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
|
||||
two 240px min-width panes plus the divider need ~486px, the divider has no
|
||||
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
|
||||
the 1024-1179px tablet range on its own. Same threshold and reasoning as
|
||||
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
|
||||
(constants.js) — keep the three in sync. */
|
||||
@media (max-width: 1179px) {
|
||||
.btn-icon-header.btn-split {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2547,6 +2586,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-split,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
@@ -6829,6 +6869,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
|
||||
settings surface's .set-scope pill, but that pill is styled only inside
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
|
||||
settings-surface block), so in here it rendered as plain body text and
|
||||
"qwen3 Currently loaded" read as one model name. Same pill, same skin
|
||||
tokens (never a hardcoded colour: --text-muted / --border are what each
|
||||
html[data-skin] block redefines), and nothing about the modal's layout or
|
||||
z-index. The row is a flex container, so the pills sit after the name
|
||||
with the row's own gap and the trailing whitespace collapses. */
|
||||
#customModelPickModal .set-scope {
|
||||
font-size: 0.52rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
@@ -13613,6 +13674,14 @@ body.touch-device.cjk-input-visible .main {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.accessory-btn-compose.has-draft::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--yellow);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -13733,6 +13802,64 @@ body.touch-device.cjk-input-visible .main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prompt-composer-overlay {
|
||||
overflow-y: auto;
|
||||
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.prompt-composer-dialog {
|
||||
max-width: 560px;
|
||||
/* The overlay's top inset and bottom gutter, plus the strip a folding device
|
||||
reserves below the dialog (0px everywhere else; see the fold rules at the
|
||||
end of this file). */
|
||||
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom) - var(--fold-block-end));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prompt-composer-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 2px 2px 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prompt-composer-header span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-composer-textarea {
|
||||
min-height: min(34dvh, 240px);
|
||||
max-height: 50dvh;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prompt-composer-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prompt-composer-actions button {
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.prompt-composer-terminal {
|
||||
flex: 1 0 100%;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-composer-terminal:active {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||
.path-input-group {
|
||||
display: flex;
|
||||
@@ -15299,6 +15426,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
.main.webview-active .webview-layer { display: flex; }
|
||||
.main.webview-active .terminal-wrap { display: none; }
|
||||
/* A split (Pane A + divider + Pane B) hides as one unit when a web tab is
|
||||
active, mirroring the .terminal-wrap rule above — .terminal-wrap is
|
||||
reparented INSIDE .terminal-split-container while a split is open, so
|
||||
hiding only .terminal-wrap would leave Pane B and the divider stranded on
|
||||
screen over the dashboard iframe. No state is destroyed, so returning to
|
||||
the session tab shows the split intact. */
|
||||
.main.webview-active .terminal-split-container { display: none; }
|
||||
|
||||
.webview-frame {
|
||||
display: none;
|
||||
@@ -18552,6 +18686,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
/* The mobile prompt composer is a .paste-overlay with a gutter of its own: a
|
||||
three-value `padding` shorthand whose bottom is 12px plus the safe area. The
|
||||
generic rule above is a later longhand at the same specificity, so it ERASED
|
||||
that gutter (measured at 393x852: padding-bottom 0 instead of 12px, and with
|
||||
the fold variables set the hinge strip stood in for the gutter instead of
|
||||
adding to it). Restate the composer's bottom gutter on top of the strip; the
|
||||
side has no gutter of its own. The dialog's height cap subtracts the same
|
||||
strip where it is declared (.prompt-composer-dialog). */
|
||||
.prompt-composer-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom) + var(--fold-block-end));
|
||||
}
|
||||
|
||||
/* The response viewer is a bottom sheet, so a vertical hinge running through it
|
||||
is fine, since it is a wide surface like the terminal and inset dialogs are what
|
||||
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
|
||||
@@ -18565,3 +18712,128 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
/* Split-Pane Sessions: container inserted only while a split is active.
|
||||
.terminal-wrap (Pane A) is reparented into this as the first child; it
|
||||
keeps every existing rule unchanged since nothing here restyles it. */
|
||||
.terminal-split-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-split-container > .terminal-wrap {
|
||||
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
|
||||
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
|
||||
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
|
||||
.main's overflow clips Pane B's right edge by exactly the divider's
|
||||
width. Shrinking lets the two panes give up that 6px between them. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex: 0 0 6px;
|
||||
cursor: col-resize;
|
||||
background: var(--border-color, #333);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.terminal-pane-b {
|
||||
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-pane-b-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close {
|
||||
/* A native <button> now backs this (keyboard-reachable close), so reset its
|
||||
default chrome back to the plain glyph this rule always drew. */
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-pane-b-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Split-picker menu: a small popover listing sessions to split with, appended
|
||||
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
||||
top/right against the Split header button's own rect). z-index above the
|
||||
header (100) with headroom to spare, matching the sibling .run-mode-menu
|
||||
dropdown's 1000. Dismissed by openSplitPane()'s own inline onclick, or by
|
||||
the click-outside/Escape listeners installed alongside it. */
|
||||
.split-picker-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
padding: 4px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.split-picker-item {
|
||||
/* A native <button> now backs each row (keyboard-reachable picker), so
|
||||
reset its default chrome back to the plain list-row look this always
|
||||
drew — full-width, left-aligned, no border/background of its own. */
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-picker-item:hover {
|
||||
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.split-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return Promise.resolve();
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||
// across the whole replay rather than just the fetch that precedes it.
|
||||
return new Promise((resolve) => {
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
});
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Pane B has no gates of its own by default, so every app-level chord
|
||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||
// registry-aware predicates so a rebind or a disable restores plain
|
||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
||||
// to, so intercepting it here would only break paste.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||
// swallow — in a plain shell session this is the user's own
|
||||
// job-control tool and must reach the PTY, but in every other mode
|
||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||
// loop dead. Pane B has its own PTY/session and must not send a
|
||||
// suspend into a non-shell one just because the primary pane's own
|
||||
// gate lives elsewhere.
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey &&
|
||||
this.sessionMode !== 'shell'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||
// newline from a submit. Without this gate, Pane B's onData would
|
||||
// send that bare \r straight over the WS and submit an incomplete
|
||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||
// explicit, never-falls-through copy chord, and the predicate above
|
||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||
// a branch that assigns c.key only for a couple of special cases
|
||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||
// interrupt byte reaching the PTY (verified live: it does not).
|
||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||
// Re-implemented against this.terminal rather than reusing
|
||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||
// and would copy the wrong pane's selection.
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
this.terminal?.clearSelection?.();
|
||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying — clear for feedback (a padding-only
|
||||
// selection cleans to '' and this press still falls through to the
|
||||
// PTY as 0x03, matching the primary pane's own rule).
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
global.app.showToast?.('Nothing to copy', 'warning');
|
||||
}
|
||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load existing scrollback before going live. The WS below is
|
||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||
// 'terminal' events), so without this Pane B stays blank until the
|
||||
// target session happens to produce new output. It LOOKED
|
||||
// intermittent rather than always-broken because _sendResize() below
|
||||
// often nudges the shared session's real tmux window to a new size,
|
||||
// and tmux repaints its current screen on resize — that repaint was
|
||||
// getting captured and streamed here, incidentally populating the
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
// The await covers the whole chunked replay, not just the fetch, so a
|
||||
// live frame from the socket below can never land in the middle of it.
|
||||
await this._loadBuffer();
|
||||
if (this._destroyed) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._wsReady = true;
|
||||
this._sendResize();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
||||
// buffer loader for the same reason connect() does.
|
||||
this._refreshBuffer();
|
||||
}
|
||||
} catch {
|
||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
||||
// closed socket per the WebSocket spec (no exception, no log). No
|
||||
// reconnect logic here — Pane B is deliberately plainer than the
|
||||
// primary pane (see the fileoverview above); a drop just stops
|
||||
// resizing until the parent recreates the pane. But onData already
|
||||
// silently drops keystrokes while _wsReady is false (below), so
|
||||
// without a visible marker a dropped socket left Pane B looking
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||
// terminal, neither of which applies to this independent pane, so this is
|
||||
// a standalone equivalent rather than a call into it.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||
// get one full replay. `fetch` here goes through the global wrapper
|
||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||
// WebSocket URL above, which does not.
|
||||
//
|
||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||
// (writeChunked resolves after its last chunk), so two replays can never
|
||||
// interleave their chunks into one terminal. A second call while one is
|
||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||
// a trailing re-run instead.
|
||||
async _loadBuffer() {
|
||||
if (this._bufferLoading) return;
|
||||
this._bufferLoading = true;
|
||||
try {
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
}
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||
// and no further frame is coming to correct stale content.
|
||||
_refreshBuffer() {
|
||||
if (this._bufferLoading) {
|
||||
this._bufferRefreshPending = true;
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
||||
// divider's own reachable 20% floor position, causing real
|
||||
// output-wrapping bugs.
|
||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
// onclose fires asynchronously AFTER close(); without this it ran
|
||||
// its "disconnected" write against a pane already torn down.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
this.fitAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
global.SplitTerminalPane = SplitTerminalPane;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
* + matchMedia backstop: a JS width check (so openSplitPane() below can
|
||||
* refuse even if a click somehow reaches the button) plus a live listener,
|
||||
* because a window narrowed WHILE the button is showing must hide it
|
||||
* without waiting for a settings save or reload. The CSS `@media
|
||||
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
|
||||
* direction: it hides the button even if this JS never runs at all.
|
||||
*/
|
||||
_applySplitButtonVisibility(enabled) {
|
||||
this._splitButtonSettingEnabled = enabled;
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||
// Narrowing past the gate must not leave an open split on screen with no
|
||||
// way to reach the button that would close it — the two 240px min-widths
|
||||
// plus the divider overflow a narrow window and .main clips Pane B's edge.
|
||||
if (!wide && this._splitPane) this.closeSplitPane();
|
||||
if (!splitBtn) return;
|
||||
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
|
||||
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
|
||||
this._splitButtonWidthListenerInstalled = true;
|
||||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||||
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPicker(event) {
|
||||
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
||||
// OPENING click so it never reaches the outside-click listener this
|
||||
// same call is about to register — without it, a click landing on the
|
||||
// button's own inner <svg> (matched by neither `menu.contains()` nor
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
}
|
||||
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
||||
this.sessions,
|
||||
this.sessionOrder,
|
||||
this.activeSessionId,
|
||||
this.detachedSessions
|
||||
);
|
||||
// Route a pre-existing menu through the SAME dismiss path used
|
||||
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
|
||||
// menu has live document listeners (see below), and a raw removal left
|
||||
// them attached forever — only the single-slot field below got
|
||||
// overwritten, so every prior pair but the last was orphaned on
|
||||
// `document` with no way to ever find and remove it again.
|
||||
this._dismissSplitPicker();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'splitPickerMenu';
|
||||
menu.className = 'split-picker-menu';
|
||||
if (candidates.length === 0) {
|
||||
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
||||
} else {
|
||||
menu.innerHTML = candidates
|
||||
.map(
|
||||
(c) =>
|
||||
// data-i18n-skip: the whole row's text IS a session name — i18n.js
|
||||
// does exact-string lookup over text nodes, and a session
|
||||
// literally named e.g. "Sessions" would otherwise get translated
|
||||
// on zh-CN (see the .session-name skip on the pane header below).
|
||||
`<button type="button" class="split-picker-item" data-i18n-skip onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
if (splitBtn) {
|
||||
const rect = splitBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
// Dismiss on outside click or Escape — same one-shot listener pattern as
|
||||
// session-ui.js's other transient popovers (toggleCaseSettings(),
|
||||
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
|
||||
// menu (still bubbling) doesn't immediately close it — reinforced by
|
||||
// the button's own stopPropagation() above, which is what actually
|
||||
// stops that same click reaching `document` at all. Picking an item
|
||||
// (above) calls the SAME dismiss method, so these listeners never
|
||||
// outlive the menu either way.
|
||||
//
|
||||
// Self-removing by identity: each handler removes ITSELF (and its
|
||||
// sibling) the moment it fires, rather than leaning solely on the
|
||||
// `this._splitPickerDismissHandlers` field. That field is still kept in
|
||||
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
|
||||
// item's onclick above, or a still-open menu at the top of this method
|
||||
// — can find and remove the CURRENT pair), but no path here can ever
|
||||
// again leave a pair attached to `document` with nothing referencing it.
|
||||
const closeOnOutsideClick = (e) => {
|
||||
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
|
||||
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
},
|
||||
|
||||
_dismissSplitPicker() {
|
||||
document.getElementById('splitPickerMenu')?.remove();
|
||||
if (this._splitPickerDismissHandlers) {
|
||||
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPane(sessionId) {
|
||||
// Desktop-only hard gate, independent of the button's own hidden state —
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
// behind the opaque overlay with nothing visible to show for it.
|
||||
if (!this.activeSessionId) return;
|
||||
// A web tab hides `.terminal-wrap`'s container via CSS with nothing
|
||||
// gating the button itself, and `activeSessionId` survives openWebview()
|
||||
// — without this, picking a session opens Pane B's socket behind a
|
||||
// hidden container with nothing on screen to show for it.
|
||||
if (this.activeWebviewId) return;
|
||||
// A stale picker click (opened before switching tabs) or clicking Pane
|
||||
// B's own session tab while split can otherwise land here with
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
||||
if (sessionId === this.activeSessionId) return;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// re-applied here: the menu can sit open while a listed session's CLI
|
||||
// exits (pid → null) or gets popped out to its own window, and nothing
|
||||
// re-runs the picker filter for a row that already rendered. Same
|
||||
// outcome as the picker gives such a session (not offered, silently):
|
||||
// one with no PTY has nothing reading its pane, so Pane B would show
|
||||
// nothing and drop every keystroke behind a healthy-looking socket, and
|
||||
// a detached session's own window already owns its PTY size.
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.pid === null) return;
|
||||
if (this.detachedSessions?.has?.(sessionId)) return;
|
||||
if (this._splitPane) this.closeSplitPane();
|
||||
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
const parent = wrap.parentElement;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-split-container';
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
const paneB = document.createElement('div');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
|
||||
parent.insertBefore(container, wrap);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
});
|
||||
this._splitPane.connect().catch(() => {
|
||||
/* Best-effort, matching the primary pane's own tolerance for a failed
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||
// terminal-ui.js debounces 300ms and would eventually catch up, but
|
||||
// relying on that left the pane showing stale-width content (existing
|
||||
// box-drawing lines, banners) until the user hit "Redraw Terminal".
|
||||
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
|
||||
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||
this._updateSplitButtonState(true);
|
||||
},
|
||||
|
||||
closeSplitPane(options = {}) {
|
||||
if (!this._splitPane) return;
|
||||
// A split can collapse MID-DRAG (either session ending, the window
|
||||
// narrowing past the gate, a click on Pane B's own tab). The drag's own
|
||||
// onUp is what normally clears `body.split-pane-resizing` (a col-resize
|
||||
// cursor plus user-select:none on EVERY element, styles.css), and it
|
||||
// relied on pointer capture routing pointerup back to a divider this
|
||||
// method detaches below, so a mid-drag collapse left the whole page
|
||||
// locked in resize mode until a reload. Tear the drag down first.
|
||||
this._splitDividerDragTeardown?.();
|
||||
this._splitDividerDragTeardown = null;
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
if (!container) return;
|
||||
const wrap = container.querySelector('.terminal-wrap');
|
||||
const parent = container.parentElement;
|
||||
wrap.style.flexBasis = '';
|
||||
parent.insertBefore(wrap, container);
|
||||
container.remove();
|
||||
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||
// while activeSessionId is still the id the server just removed, so a
|
||||
// resize from here would be aimed at a session that no longer exists;
|
||||
// the promoted session gets its own resize from selectSession().
|
||||
if (!options.skipPrimaryResize) {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
// (openSplitPicker's own early return) close an already-open split — and
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('split-open', open);
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
},
|
||||
|
||||
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||
let dragging = false;
|
||||
let dragRaf = null;
|
||||
let pendingClientX = null;
|
||||
let capturedPointerId = null;
|
||||
|
||||
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
// ending, or the picker's own close button) — closeSplitPane() removes
|
||||
// `.terminal-split-container` from the DOM, which detaches `divider`
|
||||
// too, so `divider.parentElement` is null on the very next frame and
|
||||
// every drag threw here until mouseup finally removed the listener.
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||||
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||
wrap.style.flexBasis = `${percent}%`;
|
||||
paneB.style.flexBasis = `${100 - percent}%`;
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this._splitPane?.localFit();
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
pendingClientX = e.clientX;
|
||||
if (dragRaf) return;
|
||||
dragRaf = requestAnimationFrame(() => {
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
});
|
||||
};
|
||||
|
||||
// Everything pointerdown ARMS, undone in one place: the body-level
|
||||
// cursor/selection lock, the divider's dragging class, pointer capture,
|
||||
// the move/up/cancel listeners and a queued reflow frame. Shared by onUp
|
||||
// (a normal drag end) and by closeSplitPane(), via the teardown handle
|
||||
// stored below, for a split that collapses mid-drag: the pointerup that
|
||||
// would have run onUp is routed by pointer capture to a divider
|
||||
// closeSplitPane() has detached, so it never arrives. Idempotent, since
|
||||
// the teardown runs whether or not a drag is in progress.
|
||||
const endDrag = () => {
|
||||
dragging = false;
|
||||
divider.classList.remove('dragging');
|
||||
document.body.classList.remove('split-pane-resizing');
|
||||
if (capturedPointerId !== null) {
|
||||
try {
|
||||
divider.releasePointerCapture(capturedPointerId);
|
||||
} catch {
|
||||
/* Already released (pointercancel/lostpointercapture beat us here). */
|
||||
}
|
||||
capturedPointerId = null;
|
||||
}
|
||||
divider.removeEventListener('pointermove', onMove);
|
||||
divider.removeEventListener('pointerup', onUp);
|
||||
divider.removeEventListener('pointercancel', onUp);
|
||||
if (dragRaf) {
|
||||
cancelAnimationFrame(dragRaf);
|
||||
dragRaf = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
// A reflow frame still queued at release carries the final pointer
|
||||
// position; apply it once, synchronously, so the panes end where the
|
||||
// pointer did rather than one frame short.
|
||||
const hadQueuedFrame = dragRaf !== null;
|
||||
endDrag();
|
||||
if (hadQueuedFrame) applyDragPercent(pendingClientX);
|
||||
// Send the real PTY resize exactly once here, at drag end, for BOTH
|
||||
// panes — never per-move (matching the codebase's established
|
||||
// trailing-edge debounce convention, see throttledResize in
|
||||
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
|
||||
// SIGWINCH/reflow states into scrollback or spawn a `tmux
|
||||
// resize-window` child per frame.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
this._splitPane?.fit();
|
||||
};
|
||||
|
||||
// Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
|
||||
// of mousedown/document-level mousemove: a plain mousedown drag selects
|
||||
// the text under the cursor as it crosses both terminals, and pointer
|
||||
// capture routes move/up straight to `divider` regardless of what's under
|
||||
// the cursor mid-drag, so no document-level listener leak is possible if
|
||||
// the pointer is released off-window. `body.split-pane-resizing` (mirrors
|
||||
// `body.tab-rail-resizing`) locks the cursor/selection for the drag.
|
||||
divider.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
divider.classList.add('dragging');
|
||||
document.body.classList.add('split-pane-resizing');
|
||||
try {
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
capturedPointerId = e.pointerId;
|
||||
} catch {
|
||||
/* Capture failed — the drag still works via the listeners below. */
|
||||
}
|
||||
divider.addEventListener('pointermove', onMove);
|
||||
divider.addEventListener('pointerup', onUp);
|
||||
divider.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
this._splitDividerDragTeardown = endDrag;
|
||||
},
|
||||
});
|
||||
|
||||
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||||
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (this._splitSessionId === data.id) {
|
||||
this.closeSplitPane();
|
||||
} else if (this._splitPane && this.activeSessionId === data.id) {
|
||||
// Pane A's session ended: promote Pane B by closing the split and
|
||||
// selecting its session as the new (single) active pane. This is an
|
||||
// app-driven selection, not the user clicking a tab, so it must not
|
||||
// spend the promoted session's idle alert (see the Approvals Inbox
|
||||
// acknowledgement rule in CLAUDE.md — only a human opening a session
|
||||
// acknowledges it).
|
||||
const promoted = this._splitSessionId;
|
||||
// activeSessionId is still data.id here (the original handler below is
|
||||
// what retires it), so closeSplitPane()'s closing resize would be aimed
|
||||
// at the session the server just removed. Skip it; selectSession() sizes
|
||||
// the promoted session itself.
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
|
||||
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
|
||||
// selection itself once the delete lands — same race _onSessionDeleted's
|
||||
// own active-session handoff guards against (see its comment). Selecting
|
||||
// here too would fight it for which tab wins.
|
||||
if (promoted && !this._closingSessions.has(data.id)) {
|
||||
this.selectSession(promoted, { auto: true });
|
||||
}
|
||||
}
|
||||
return _originalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
// I2: closes an active split BEFORE the primary pane rebinds to the same
|
||||
// session Pane B is showing (clicking Pane B's own session tab while split,
|
||||
// or any other selectSession() call that targets _splitSessionId). Without
|
||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// dimensions via its own `{t:'z',...}` resize frame.
|
||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||
this.closeSplitPane();
|
||||
}
|
||||
return _originalSelectSession.call(this, sessionId, ...args);
|
||||
};
|
||||
@@ -1092,6 +1092,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Pane B (split view) has its own container and its own fit()/resize
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -4069,8 +4075,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
const flushedLength = Array.from(flushed.text || '').length;
|
||||
if (flushedLength > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushedLength)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
@@ -5124,6 +5131,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5149,6 +5160,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5200,6 +5215,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5420,6 +5440,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+16
-5
@@ -1581,9 +1581,17 @@ export class WebServer extends EventEmitter {
|
||||
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
|
||||
// The id is gated to JSON + <-escaped so it can't break out of the inline
|
||||
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
|
||||
//
|
||||
// Every `</head>` injection below passes a replacer FUNCTION, never a
|
||||
// replacement STRING: `String.replace` interprets `$&`, `$'`, `` $` `` and
|
||||
// `$<n>` inside a string replacement, so a payload carrying `$'` would splice
|
||||
// the rest of the document (the whole <body>) into the inline script, past
|
||||
// any escaping applied to the payload itself. The custom-model list below
|
||||
// carries a user-settable `label` (clis.json), which is the site that made
|
||||
// this real; the others follow the same rule so the class of bug stays out.
|
||||
if (soloSessionId) {
|
||||
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
|
||||
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
html = html.replace('</head>', () => `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
}
|
||||
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
|
||||
// have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
|
||||
@@ -1655,7 +1663,7 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
|
||||
// generate an entry for: read generically off the registry's `capabilities`
|
||||
@@ -1672,16 +1680,19 @@ export class WebServer extends EventEmitter {
|
||||
const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis));
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
const v = this.gestureBundleVersion();
|
||||
// Relative src so the injected `<base href>` resolves it under the mount
|
||||
// prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
|
||||
html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`
|
||||
);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
|
||||
@@ -99,7 +99,10 @@ describe('App Settings modal structure', () => {
|
||||
for (const [, attrs, body] of previewed) {
|
||||
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
|
||||
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
|
||||
// A decimal (e.g. "11.5") is allowed — Split sits between Multi-monitor
|
||||
// (11) and Ultracode Agents (12) in the real header, and Number()
|
||||
// parses it fine for the preview's own sort.
|
||||
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+(\.\d+)?"/);
|
||||
// A text token replaces the icon for readouts (plan usage, CPU, font size).
|
||||
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
|
||||
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it('returns the response directly when no confirmation is needed', async () => {
|
||||
it('returns the response directly when no confirmation is needed, and records "last used"', async () => {
|
||||
const { win, app } = bootApp();
|
||||
withFetch(win, (body) => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false, body } }));
|
||||
const data = await app._quickStartWithCustomModelConfirm({
|
||||
@@ -152,9 +152,17 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult).toEqual(data.data);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
|
||||
});
|
||||
|
||||
it('confirming re-sends with confirmedSwap and returns the second response', async () => {
|
||||
it('a plain launch with no customModel at all never touches the "last used" key (undefined endpointId/modelId would otherwise silently no-op it)', async () => {
|
||||
const { win, app } = bootApp();
|
||||
withFetch(win, () => ({ success: true, data: { sessionId: 's1' } }));
|
||||
await app._quickStartWithCustomModelConfirm({ mode: 'codex' });
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:undefined')).toBeNull();
|
||||
});
|
||||
|
||||
it('confirming re-sends with confirmedSwap, returns the second response, and only THEN records "last used"', async () => {
|
||||
const { win, app } = bootApp();
|
||||
app._confirmModelSwap = async () => true;
|
||||
let calls = 0;
|
||||
@@ -183,9 +191,10 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(calls).toBe(2);
|
||||
expect(data.data.sessionId).toBe('s1');
|
||||
expect(app._lastCustomModelLaunchResult.modelSwapInProgress).toBe(true);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
|
||||
});
|
||||
|
||||
it('cancelling never re-sends, and reports a cancellation error', async () => {
|
||||
it('cancelling never re-sends, reports a cancellation error, and must NEVER record "last used" for a launch that never happened', async () => {
|
||||
const { win, app } = bootApp();
|
||||
app._confirmModelSwap = async () => false;
|
||||
let calls = 0;
|
||||
@@ -208,6 +217,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toMatch(/cancelled/i);
|
||||
expect(app._lastCustomModelLaunchResult).toBeUndefined();
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -272,6 +272,207 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
expect(win.document.getElementById('customModelPickList')!.textContent).toContain('Default');
|
||||
});
|
||||
|
||||
it('promotes the model llama-swap currently has loaded and ready to the top of the list, tagged', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons.map((b) => b.textContent)).toHaveLength(3);
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||
// Nothing else got relabelled or reordered past the promoted row.
|
||||
expect(buttons[1].textContent).toContain('qwen3');
|
||||
expect(buttons[2].textContent).toContain('llama3');
|
||||
});
|
||||
|
||||
it('falls back to the last model launched on this (harness, endpoint) pair when nothing is currently loaded', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
// Simulate a prior launch on this exact (harness, endpoint) pair having picked llama3.
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama3');
|
||||
expect(buttons[0].textContent).toContain('Last used');
|
||||
expect(buttons[0].textContent).not.toContain('Currently loaded');
|
||||
});
|
||||
|
||||
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||
});
|
||||
|
||||
it('a currently-loaded row that is also the endpoint default shows BOTH tags, as two separate spans', async () => {
|
||||
// The common case on a single-purpose GPU box: the one model that is loaded is the
|
||||
// saved default too. An exclusive tag slot (promotion, else Default) silently dropped
|
||||
// the Default marking for exactly that row.
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3', 'phi4'],
|
||||
defaultModelId: 'phi4',
|
||||
},
|
||||
],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('phi4');
|
||||
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||
expect(tags).toEqual(['Currently loaded', 'Default']);
|
||||
// Rows with neither a promotion nor the default carry no tag at all.
|
||||
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a "last used" row that is also the endpoint default shows both tags too', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3', 'phi4'],
|
||||
defaultModelId: 'llama3',
|
||||
},
|
||||
],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama3');
|
||||
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||
expect(tags).toEqual(['Last used', 'Default']);
|
||||
});
|
||||
|
||||
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||
// "loading", not "ready" — and a model id this host's own /v1/models no longer serves.
|
||||
return { isLlamaSwap: true, running: [{ model: 'ghost-model', state: 'loading' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.textContent).not.toContain('Currently loaded');
|
||||
expect(list.textContent).not.toContain('ghost-model');
|
||||
const buttons = [...list.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('qwen3');
|
||||
});
|
||||
|
||||
it('remembers the launched model as "last used" only once the apply actually succeeds, not on the mere attempt', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
app.activeSessionId = 'old-session';
|
||||
// A real new session, and a real successful apply with no questions asked — the
|
||||
// restart path only reaches its _setCustomModelLastUsed call past both.
|
||||
app.run = async () => {
|
||||
app.activeSessionId = 'new-session';
|
||||
};
|
||||
app._api = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
|
||||
});
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('a slower currently-loaded probe for an earlier pick must never clobber a faster, later pick for a different endpoint', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
const hostA = { id: 'host-a', label: 'Host A', baseUrl: 'http://a', models: ['a1', 'a2'] };
|
||||
const hostB = { id: 'host-b', label: 'Host B', baseUrl: 'http://b', models: ['b1', 'b2'] };
|
||||
let resolveA!: (v: unknown) => void;
|
||||
const pendingA = new Promise((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/host-a/running-status') return pendingA;
|
||||
if (path === '/api/model-endpoints/host-b/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return null;
|
||||
};
|
||||
|
||||
// Host A's picker opens first but its probe never resolves until we say so below —
|
||||
// Host B's opens second and resolves immediately, so it renders first.
|
||||
const openA = app._openCustomModelPickModal('claude', hostA);
|
||||
await app._openCustomModelPickModal('claude', hostB);
|
||||
|
||||
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
|
||||
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
|
||||
|
||||
// Host A's probe finally answers, after Host B has already rendered.
|
||||
resolveA({ isLlamaSwap: false, running: [] });
|
||||
await openA;
|
||||
|
||||
// The late-arriving Host A response must be a no-op: still Host B on screen.
|
||||
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
|
||||
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
|
||||
const listText = win.document.getElementById('customModelPickList')!.textContent;
|
||||
expect(listText).toContain('b1');
|
||||
expect(listText).toContain('b2');
|
||||
expect(listText).not.toContain('a1');
|
||||
expect(listText).not.toContain('a2');
|
||||
});
|
||||
|
||||
it('picking a row in the modal closes it and launches with that exact model', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }],
|
||||
@@ -333,6 +534,48 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
|
||||
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
|
||||
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
|
||||
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
|
||||
// does not patch, so this is the only way to test the bound without actually waiting
|
||||
// on it (or, worse, hanging on a promise that deliberately never resolves).
|
||||
|
||||
it('never lets an endpoint that never answers keep the picker waiting past the client-side bound', async () => {
|
||||
const { app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3'] }],
|
||||
});
|
||||
// A `running-status` probe that simply never resolves — the exact shape of an
|
||||
// endpoint that is asleep or firewalled, distinct from one that answers an error.
|
||||
app._apiJson = () => new Promise(() => {});
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('an endpoint that answers well within the bound is unaffected by it', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
|
||||
expect(result).toBeNull();
|
||||
// Give the loser of the race a turn to actually reject and hit its own .catch —
|
||||
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
|
||||
// for the whole test file, not a failed assertion in this test.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
it('does not apply the endpoint to a session that was already open when the launch fails', async () => {
|
||||
const { app } = bootApp({});
|
||||
@@ -1025,8 +1268,8 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
return { win, app, applyBodies };
|
||||
}
|
||||
|
||||
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true', async () => {
|
||||
const { app, applyBodies } = launchHarness([
|
||||
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true, and only THEN records "last used"', async () => {
|
||||
const { win, app, applyBodies } = launchHarness([
|
||||
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
|
||||
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
|
||||
]);
|
||||
@@ -1043,10 +1286,11 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3' },
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
|
||||
]);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('declining the in-app context-warning modal keeps the native backend and never re-sends the apply', async () => {
|
||||
const { app, applyBodies } = launchHarness([
|
||||
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
|
||||
const { win, app, applyBodies } = launchHarness([
|
||||
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
|
||||
]);
|
||||
app._confirmContextWarning = async () => false;
|
||||
@@ -1059,6 +1303,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
|
||||
expect(applyBodies).toHaveLength(1); // no second (confirmed) call
|
||||
expect(toastMessage).toMatch(/context window too small/i);
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -313,11 +313,17 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
/**
|
||||
* The elements whose padding cascade is simulated: every derived overlay as
|
||||
* a bare element, plus the open command palette, which is a `.modal` wearing
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand.
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand, plus
|
||||
* the mobile prompt composer, a `.paste-overlay` wearing a second class that
|
||||
* carries its own `padding` shorthand. The derived list cannot see the
|
||||
* composer (it inherits the centring declarations rather than declaring
|
||||
* them), and simulating `['paste-overlay']` alone stayed green while the
|
||||
* generic `.paste-overlay` fold rule erased the composer's bottom gutter.
|
||||
*/
|
||||
const ELEMENTS: { name: string; classes: string[] }[] = [
|
||||
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
|
||||
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
|
||||
{ name: '.paste-overlay.prompt-composer-overlay', classes: ['paste-overlay', 'prompt-composer-overlay'] },
|
||||
];
|
||||
|
||||
it('simulates the cascade the browser measured', () => {
|
||||
@@ -330,7 +336,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
const picker = ['path-picker-overlay'];
|
||||
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
|
||||
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
|
||||
const palette = ELEMENTS.at(-1)!.classes;
|
||||
const palette = ELEMENTS.find((e) => e.name === '.modal.command-palette-modal.active')!.classes;
|
||||
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
|
||||
expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem');
|
||||
expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0');
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
||||
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
||||
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||
*
|
||||
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
|
||||
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
|
||||
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
|
||||
* its own piece of work verified by a browser/mobile suite the CI gate cannot
|
||||
* see." Widening this guard to the whole directory would force either fixing
|
||||
* or allowlisting dozens of branches in files nobody has touched or reviewed
|
||||
* for this change, which is scope B2 never took on.
|
||||
*
|
||||
* Port: none (pure static analysis).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
||||
|
||||
/**
|
||||
* Every currently-surviving branch, each with the COUNT of physical call
|
||||
* sites carrying it and the reason none of them is a `CliCapabilities`
|
||||
* field, keyed `<file>::<the matched expression>` — deliberately NO line
|
||||
* number. An earlier version keyed on `<file>::<line>::<expression>`, and
|
||||
* inserting one comment line at the top of `session-ui.js` shifted every
|
||||
* subsequent line number, so all 21 entries went stale and the same 21
|
||||
* branches were then reported as "new". `session-ui.js` is one of the most
|
||||
* contended files in the repo, so a guard that goes red on any unrelated
|
||||
* edit to it sends the next person after the wrong problem.
|
||||
*
|
||||
* The `count` is what closes the gap dropping the line number opened: a key
|
||||
* alone says "this expression is approved somewhere in this file", so a
|
||||
* BRAND NEW `mode === 'codex'` site anywhere in `session-ui.js` would reuse
|
||||
* the same key as the two approved ones and pass silently. The count makes
|
||||
* that a mismatch — one more occurrence than declared — and the "counts
|
||||
* match" test below catches it, while a genuinely new expression (a CLI id
|
||||
* with no ALLOWED_BRANCHES entry at all) is still caught by the separate
|
||||
* "no unapproved id branches" test either way.
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
"session-ui.js::mode === 'shell'": {
|
||||
count: 2,
|
||||
reason:
|
||||
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
|
||||
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
|
||||
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
|
||||
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
|
||||
'hardcoded, see the PR #458 review thread)',
|
||||
},
|
||||
|
||||
"session-ui.js::mode === 'claude'": {
|
||||
count: 4,
|
||||
reason:
|
||||
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
||||
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
|
||||
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
|
||||
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
|
||||
"by design, mirroring the backend capabilities.ralph gate), and the runMode setter's " +
|
||||
'validity check',
|
||||
},
|
||||
|
||||
// The 8 external CLIs share the same two call sites and the same reason at
|
||||
// each: the button-label ternary (see the shell entry above for why it
|
||||
// stays hardcoded) and the runMode property setter's validity allowlist
|
||||
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
|
||||
// no shell arm at all and no evidence of what callers rely on it).
|
||||
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'antigravity'": {
|
||||
count: 2,
|
||||
reason: 'button-label ternary + runMode setter validity check',
|
||||
},
|
||||
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
|
||||
// The docker adopt-preflight status line and the docker link/adopt toast
|
||||
// both list the agent CLIs probed INSIDE the container and leave `shell`
|
||||
// out of that human-readable "found ..." summary (it is always present and
|
||||
// is not an agent CLI). Written as `(m) => m !== 'shell'`, the naming the
|
||||
// original named-variable pattern could not see; the widened pattern
|
||||
// normalizes the `m` to `mode` (see BRANCH_PATTERN below).
|
||||
"session-ui.js::mode !== 'shell'": {
|
||||
count: 2,
|
||||
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
|
||||
},
|
||||
|
||||
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||
"mobile-overview.js::mode !== 'shell'": {
|
||||
count: 1,
|
||||
reason: 'shell needs no CLI, so it is exempt from the availability gate',
|
||||
},
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
||||
const ID_ALT = IDS.join('|');
|
||||
|
||||
/**
|
||||
* The backend guard's four shapes (see its own comment for why all four
|
||||
* matter), with ONE deliberate widening on the first.
|
||||
*
|
||||
* The backend pattern accepts a comparison only when its left-hand side is
|
||||
* literally named `mode`, `id` or `agentType`, so both
|
||||
* `const m = this._runMode; if (m === 'codex')` and
|
||||
* `if (this._runMode !== 'gemini')` slip past it, and `session-ui.js` already
|
||||
* uses exactly that naming (`(m) => m !== 'shell'`, twice). The review of
|
||||
* PR #458 surfaced that blind spot, so here the left-hand side is ANY
|
||||
* identifier (`[\w$]+`, the leaf of a member chain), normalized to `mode` in
|
||||
* the allowlist key by `scan()` so a local rename never churns the entries.
|
||||
* Measured over both scanned files before widening: every extra hit was a
|
||||
* genuine mode comparison (the two `m !== 'shell'` filters, allowlisted
|
||||
* above), so the widening added no false positive; a future one gets an
|
||||
* allowlist entry with its reason like any other. The backend guard keeps
|
||||
* its narrower form and is deliberately not changed here.
|
||||
*
|
||||
* Still unseen, and worth knowing: a Yoda comparison (`'codex' === mode`),
|
||||
* and an id list held in a variable (`EXTERNAL.includes(mode)`), since the
|
||||
* third shape needs the literal list inline.
|
||||
*/
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
// <identifier> === 'codex' / <identifier> !== 'codex' (any left-hand identifier, see above)
|
||||
`\\b[\\w$]+\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// case 'codex':
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
||||
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
||||
].join('|'),
|
||||
'g'
|
||||
);
|
||||
|
||||
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
|
||||
function uncommented(source: string): string {
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
expression: string;
|
||||
line: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function scan(): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const file of SCANNED_FILES) {
|
||||
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
// Normalize the comparison shape's left-hand identifier (whatever the
|
||||
// local is called: `id`, `agentType`, `m`, `_runMode`) to `mode`; the
|
||||
// lookahead leaves the `case`/`.includes(` shapes untouched.
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^[\w$]+(?=\s*[!=]==)/, 'mode');
|
||||
findings.push({ file, expression, line: i + 1, key: `${file}::${expression}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
const findings = scan();
|
||||
|
||||
function actualCounts(): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const f of findings) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
||||
it('scans both files (sanity)', () => {
|
||||
// If this drops to zero the scanner or the file list drifted and every
|
||||
// assertion below would pass vacuously.
|
||||
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
||||
expect(scannedBytes).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
it('builds its id list from the live catalog (sanity)', () => {
|
||||
expect(IDS).toContain('claude');
|
||||
expect(IDS).toContain('deepseek');
|
||||
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
it('still detects a branch when one exists (anti-vacuity)', () => {
|
||||
const samples = [
|
||||
"if (session.mode === 'codex') { doSomething(); }",
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
// The two forms the named-variable pattern was blind to (see BRANCH_PATTERN).
|
||||
"const m = this._runMode; if (m === 'codex') { doSomething(); }",
|
||||
"if (this._runMode !== 'gemini') { doSomething(); }",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
||||
}
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
||||
});
|
||||
|
||||
it('has no unapproved id branches', () => {
|
||||
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
||||
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length === 0
|
||||
? ''
|
||||
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
|
||||
'Two ways out, in order of preference:\n' +
|
||||
' 1. Derive the difference from a shared module-level constant, the way\n' +
|
||||
' _runCliMode()/RUN_MODE_LAUNCH/EXTERNAL_CLI_MODES do.\n' +
|
||||
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
|
||||
' ALLOWED_BRANCHES in this file WITH the reason.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every allowlisted branch occurs exactly its declared number of times', () => {
|
||||
// This is what closes the gap the line-number removal opened (see the
|
||||
// ALLOWED_BRANCHES header comment): a key alone cannot tell "the two
|
||||
// approved sites" from "the two approved sites plus a brand new third
|
||||
// one reusing the same expression" — the count can. A mismatch in
|
||||
// either direction is real: higher means an unreviewed NEW branch
|
||||
// landed reusing an approved expression, lower means one of the
|
||||
// reviewed call sites was removed and the entry is now a stale lie
|
||||
// about the codebase (the count going to 0 is the old "stale entry"
|
||||
// case, now folded into this same check rather than a separate one).
|
||||
const actual = actualCounts();
|
||||
const mismatches: string[] = [];
|
||||
for (const [key, { count: expected }] of Object.entries(ALLOWED_BRANCHES)) {
|
||||
const got = actual.get(key) ?? 0;
|
||||
if (got !== expected) {
|
||||
mismatches.push(` ${key} expected ${expected}, found ${got}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
mismatches,
|
||||
mismatches.length === 0
|
||||
? ''
|
||||
: `ALLOWED_BRANCHES count mismatch(es):\n${mismatches.join('\n')}\n\n` +
|
||||
'A count LOWER than declared means a reviewed call site was removed — update or delete ' +
|
||||
'the entry. A count HIGHER than declared means a NEW branch landed reusing an already-' +
|
||||
'approved expression — review it and bump the count (or fix the branch) explicitly, ' +
|
||||
'rather than let it ride in on an existing approval.'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
|
||||
interface TrapListener {
|
||||
(e: Record<string, unknown>): void;
|
||||
@@ -91,8 +93,7 @@ function loadPasteHarness(): Harness {
|
||||
});
|
||||
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'image-input.js' });
|
||||
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
|
||||
const pastedText: string[] = [];
|
||||
@@ -133,6 +134,20 @@ function loadPasteHarness(): Harness {
|
||||
};
|
||||
}
|
||||
|
||||
function loadImageInputApp() {
|
||||
const context = vm.createContext({ console, window: {}, document: {} });
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
const app = new CodemanApp();
|
||||
app.activeSessionId = 'session-1';
|
||||
app.showToast = vi.fn();
|
||||
app.sendInput = vi.fn(async () => {});
|
||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||
return app as Record<string, any>;
|
||||
}
|
||||
|
||||
describe('Ctrl+V paste trap', () => {
|
||||
it('sends clipboard text to the terminal once for a single paste event', () => {
|
||||
const h = loadPasteHarness();
|
||||
@@ -174,3 +189,24 @@ describe('Ctrl+V paste trap', () => {
|
||||
expect(h.attachedTraps()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image upload insertion policy', () => {
|
||||
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
|
||||
const app = loadImageInputApp();
|
||||
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
|
||||
|
||||
const paths = await app._uploadAndInsertImages(files, { insert: false });
|
||||
|
||||
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves terminal insertion by default', async () => {
|
||||
const app = loadImageInputApp();
|
||||
|
||||
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||
|
||||
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @fileoverview CI-visible coverage for the manual mobile prompt composer.
|
||||
*
|
||||
* The Playwright mobile suite is excluded from the CI gate, so the behaviors
|
||||
* most likely to regress live here against the real browser module: native
|
||||
* textarea replacement, local-echo adoption, per-session drafts, bracketed
|
||||
* multiline delivery and image-path insertion.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
|
||||
|
||||
type Timer = { callback: () => void; delay: number };
|
||||
|
||||
function loadComposer(sessionId = 'session-1') {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
const window = dom.window;
|
||||
const timers: Timer[] = [];
|
||||
const localEcho = {
|
||||
pendingText: '',
|
||||
clear: vi.fn(() => {
|
||||
localEcho.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app = {
|
||||
activeSessionId: sessionId,
|
||||
sessions: new Map([
|
||||
['session-1', { mode: 'claude' }],
|
||||
['session-2', { mode: 'claude' }],
|
||||
]),
|
||||
terminal: { paste: vi.fn(), focus: vi.fn(), modes: { bracketedPasteMode: true } },
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: localEcho,
|
||||
_flushedOffsets: new Map<string, number>(),
|
||||
_flushedTexts: new Map<string, string>(),
|
||||
_echoPassthroughSessions: new Set<string>(),
|
||||
_predictiveEcho: { clearPredictions: vi.fn() },
|
||||
_sendInputAsync: vi.fn(),
|
||||
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
|
||||
showToast: vi.fn(),
|
||||
};
|
||||
const schedule = (callback: () => void, delay = 0) => {
|
||||
timers.push({ callback, delay });
|
||||
return timers.length;
|
||||
};
|
||||
const factory = new Function(
|
||||
'window',
|
||||
'document',
|
||||
'Event',
|
||||
'app',
|
||||
'MobileDetection',
|
||||
'URLSearchParams',
|
||||
'fetch',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'requestAnimationFrame',
|
||||
`${accessorySource}\nreturn KeyboardAccessoryBar;`
|
||||
);
|
||||
const bar = factory(
|
||||
window,
|
||||
window.document,
|
||||
window.Event,
|
||||
app,
|
||||
{ isTouchDevice: () => true },
|
||||
window.URLSearchParams,
|
||||
vi.fn(),
|
||||
schedule,
|
||||
vi.fn(),
|
||||
(callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
app,
|
||||
bar,
|
||||
document: window.document,
|
||||
localEcho,
|
||||
timers,
|
||||
runTimers() {
|
||||
for (const timer of timers.splice(0)) timer.callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function textarea(document: Document): HTMLTextAreaElement {
|
||||
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function mountComposeButton(bar: any, document: Document): HTMLButtonElement {
|
||||
bar.element = document.createElement('div');
|
||||
bar.element.innerHTML = bar._simpleButtons;
|
||||
document.body.appendChild(bar.element);
|
||||
bar._syncComposerDraftIndicator();
|
||||
return bar.element.querySelector('[data-action="compose"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('mobile prompt composer', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('replaces Paste with Compose on agent bars while shell keeps direct Paste', () => {
|
||||
const template = (name: string) => accessorySource.match(new RegExp(name + '\\s*:\\s*`([\\s\\S]*?)`'))?.[1] ?? '';
|
||||
|
||||
expect(template('_simpleButtons')).toContain('data-action="compose"');
|
||||
expect(template('_simpleButtons')).not.toContain('data-action="paste"');
|
||||
expect(template('_extendedButtons')).toContain('data-action="compose"');
|
||||
expect(template('_extendedButtons')).not.toContain('data-action="paste"');
|
||||
expect(template('_shellButtons')).toContain('data-action="paste"');
|
||||
expect(template('_shellButtons')).not.toContain('data-action="compose"');
|
||||
});
|
||||
|
||||
it('uses a compact accessible icon for Compose in both agent layouts', () => {
|
||||
const { bar, document } = loadComposer();
|
||||
|
||||
for (const markup of [bar._simpleButtons, bar._extendedButtons]) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = markup;
|
||||
const button = wrapper.querySelector('[data-action="compose"]') as HTMLButtonElement;
|
||||
|
||||
expect(button.getAttribute('aria-label')).toBe('Compose prompt');
|
||||
expect(button.getAttribute('title')).toBe('Compose prompt');
|
||||
expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(button.textContent?.trim()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a native autocorrect-aware textarea and stores replacement text exactly once', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
|
||||
expect(input.getAttribute('autocorrect')).toBe('on');
|
||||
expect(input.getAttribute('autocapitalize')).toBe('sentences');
|
||||
expect(input.getAttribute('spellcheck')).toBe('true');
|
||||
|
||||
input.value = 'Please fix teh bug';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
input.value = 'Please fix the bug';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('Please fix the bug');
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adopts and clears locally-buffered terminal input on open', () => {
|
||||
const { app, bar, document, localEcho } = loadComposer();
|
||||
localEcho.pendingText = '-written prompt';
|
||||
app._flushedOffsets.set('session-1', 4);
|
||||
app._flushedTexts.set('session-1', 'half');
|
||||
|
||||
bar.composePrompt();
|
||||
|
||||
expect(textarea(document).value).toBe('half-written prompt');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(4), { useMux: true });
|
||||
expect(localEcho.clear).toHaveBeenCalledOnce();
|
||||
expect(localEcho.suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(app._flushedOffsets.has('session-1')).toBe(false);
|
||||
expect(app._flushedTexts.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses Unicode code points when erasing flushed text', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._flushedOffsets.set('session-1', 3);
|
||||
app._flushedTexts.set('session-1', 'a😀');
|
||||
|
||||
bar.composePrompt();
|
||||
|
||||
expect(textarea(document).value).toBe('a😀');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(2), { useMux: true });
|
||||
});
|
||||
|
||||
it('closes on tab switch and keeps drafts isolated by session', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'first session draft';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
|
||||
app.activeSessionId = 'session-2';
|
||||
bar.refreshForActiveSession();
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
expect(() => bar.refreshForActiveSession()).not.toThrow();
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
textarea(document).value = 'second session draft';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
|
||||
app.activeSessionId = 'session-1';
|
||||
bar.refreshForActiveSession();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
expect(composeButton.getAttribute('aria-label')).toBe('Compose prompt, draft saved');
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('first session draft');
|
||||
});
|
||||
|
||||
it('drops a draft and closes its composer when the session is deleted', () => {
|
||||
const { bar, document } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'temporary secret';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
|
||||
bar.discardComposerDraft('session-1');
|
||||
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
});
|
||||
|
||||
it('wires session cleanup to composer draft cleanup', () => {
|
||||
const cleanupStart = appSource.indexOf(' _cleanupSessionData(sessionId) {');
|
||||
const cleanup = appSource.slice(cleanupStart, cleanupStart + 1200);
|
||||
|
||||
expect(cleanup).toContain('KeyboardAccessoryBar.discardComposerDraft?.(sessionId)');
|
||||
});
|
||||
|
||||
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
|
||||
const { app, bar, document, timers, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'first line\nsecond line';
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new document.defaultView!.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||
expect(app._sendInputAsync).toHaveBeenNthCalledWith(1, 'session-1', '\x1b[200~first line\rsecond line\x1b[201~');
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
|
||||
runTimers();
|
||||
expect(app._sendInputAsync).toHaveBeenNthCalledWith(2, 'session-1', '\r', { useMux: true });
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('');
|
||||
});
|
||||
|
||||
it('sends after replay resets xterm’s mirrored bracketed-paste mode', () => {
|
||||
const { app, bar, document, runTimers } = loadComposer();
|
||||
app.terminal.modes.bracketedPasteMode = false;
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'still\nmultiline';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~still\rmultiline\x1b[201~');
|
||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
runTimers();
|
||||
expect(app._sendInputAsync).toHaveBeenLastCalledWith('session-1', '\r', { useMux: true });
|
||||
});
|
||||
|
||||
it('releases echo passthrough after a composed prompt is queued', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._echoPassthroughSessions.add('session-1');
|
||||
app._echoPassthroughSessions.add('session-2');
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'send from composer';
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._echoPassthroughSessions.has('session-1')).toBe(false);
|
||||
expect(app._echoPassthroughSessions.has('session-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an oversized prompt as a draft instead of queueing a rejected frame', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'x'.repeat(65525);
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too long'), 'error');
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
expect(bar._composerDrafts.get('session-1')).toHaveLength(65525);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only draft as empty instead of submitting blank lines', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
textarea(document).value = ' \n\n ';
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('derives the prompt budget from the 64 KiB input frame minus both paste markers', () => {
|
||||
// ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH without an ACK,
|
||||
// so a prompt of exactly the budget must produce a frame of exactly 64 KiB.
|
||||
const { app, bar, document } = loadComposer();
|
||||
expect(bar._composerMaxLength).toBe(64 * 1024 - '\x1b[200~\x1b[201~'.length);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'y'.repeat(bar._composerMaxLength);
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||
expect((app._sendInputAsync.mock.calls[0][1] as string).length).toBe(64 * 1024);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'keep this';
|
||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||
expect(composeButton.title).toBe('Resume saved prompt draft');
|
||||
runTimers();
|
||||
localEcho.pendingText = '; then continue';
|
||||
bar.composePrompt();
|
||||
expect(textarea(document).value).toBe('keep this; then continue');
|
||||
});
|
||||
|
||||
it('uploads images without writing into the PTY and inserts their paths into the draft', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
input.value = 'review';
|
||||
input.selectionStart = input.selectionEnd = input.value.length;
|
||||
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
expect((document.querySelector('.paste-image') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
finishUpload(['/tmp/image-one.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('review /tmp/image-one.png'));
|
||||
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { insert: false });
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
let finishUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||
bar.composePrompt();
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
finishUpload(['/tmp/late-image.png']);
|
||||
await vi.waitFor(() => expect(textarea(document).value).toBe('/tmp/late-image.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
let finishDeletedUpload!: (paths: string[]) => void;
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => (finishDeletedUpload = resolveUpload))
|
||||
);
|
||||
const reopenedInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
Object.defineProperty(reopenedInput, 'files', { configurable: true, value: [image] });
|
||||
reopenedInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
app.sessions.delete('session-1');
|
||||
bar.discardComposerDraft('session-1');
|
||||
finishDeletedUpload(['/tmp/deleted-session.png']);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(bar._composerDrafts.has('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Send disabled until every concurrent image upload finishes', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
const finishUploads: Array<(paths: string[]) => void> = [];
|
||||
app._uploadAndInsertImages.mockImplementation(
|
||||
() => new Promise<string[]>((resolveUpload) => finishUploads.push(resolveUpload))
|
||||
);
|
||||
bar.composePrompt();
|
||||
const input = textarea(document);
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
const pasteImage = () => {
|
||||
const item = { type: 'image/png', getAsFile: () => image };
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
|
||||
input.dispatchEvent(event);
|
||||
};
|
||||
|
||||
pasteImage();
|
||||
pasteImage();
|
||||
expect(finishUploads).toHaveLength(2);
|
||||
finishUploads[0](['/tmp/first.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
finishUploads[1](['/tmp/second.png']);
|
||||
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
|
||||
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -478,7 +478,7 @@ describe('Virtual Keyboard', () => {
|
||||
'tab',
|
||||
'shift-left',
|
||||
'shift-right',
|
||||
'paste',
|
||||
'compose',
|
||||
'readmymind',
|
||||
'esc',
|
||||
'dismiss',
|
||||
|
||||
@@ -55,32 +55,46 @@ describe('OpenCode session initial resize', () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that runOpenCode does NOT
|
||||
// pre-set activeSessionId before calling selectSession.
|
||||
// If it did, selectSession would early-return and skip sendResize.
|
||||
it('selectSession is not bypassed when the shared launcher sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that the OpenCode launch path does
|
||||
// NOT pre-set activeSessionId before calling selectSession. If it did,
|
||||
// selectSession would early-return and skip sendResize.
|
||||
//
|
||||
// PR B2 consolidated runOpenCode() (and 7 siblings) into one shared
|
||||
// _runCliMode(mode) — runOpenCode is now a one-line wrapper
|
||||
// (`return this._runCliMode('opencode')`), so inspecting ITS source would
|
||||
// never see the real launch logic and this check would pass vacuously
|
||||
// regardless of what _runCliMode actually does. Inspect _runCliMode itself.
|
||||
({ context, page } = await freshPage());
|
||||
await navigateAndWait(page);
|
||||
|
||||
// Read the runOpenCode source from the live app and verify
|
||||
// it doesn't assign activeSessionId before selectSession
|
||||
const hasPreAssignment = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app;
|
||||
const source = app.runOpenCode.toString();
|
||||
const { selectIdx, assignIdx } = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { _runCliMode: { toString: () => string } } }).app;
|
||||
const source = app._runCliMode.toString();
|
||||
|
||||
// Check: the source should NOT have activeSessionId = ... before selectSession
|
||||
// Find positions of both patterns
|
||||
const assignIdx = source.indexOf('this.activeSessionId = data.sessionId');
|
||||
const selectIdx = source.indexOf('this.selectSession(data.sessionId)');
|
||||
|
||||
// If assign doesn't exist at all, that's the correct fix
|
||||
if (assignIdx === -1) return false;
|
||||
|
||||
// If assign comes before select, that's the bug
|
||||
return assignIdx < selectIdx;
|
||||
// The launcher hands the FIRST created session to selectSession
|
||||
// (`_launchQuickStartInstances()` returns `firstSessionId`). An earlier
|
||||
// version of this check looked for `this.selectSession(data.sessionId)`,
|
||||
// a string that exists nowhere in session-ui.js, so both lookups came
|
||||
// back -1 and the assertion could never fail. Hence the anti-vacuity
|
||||
// check below: the select call itself must be found.
|
||||
const selectIdx = source.indexOf('this.selectSession(firstSessionId)');
|
||||
// ANY assignment to activeSessionId (whatever the right-hand side is
|
||||
// called), not `==`/`===` comparisons and not the comment that mentions
|
||||
// pre-setting it without a `this.` prefix.
|
||||
const assign = /this\.activeSessionId\s*=(?!=)/.exec(source);
|
||||
return { selectIdx, assignIdx: assign ? assign.index : -1 };
|
||||
});
|
||||
|
||||
expect(hasPreAssignment).toBe(false);
|
||||
// Anti-vacuity: if the select call is renamed again, fail here rather
|
||||
// than pass on two -1s.
|
||||
expect(selectIdx).toBeGreaterThan(-1);
|
||||
// Correct: no assignment at all. Bug: an assignment that lands BEFORE
|
||||
// selectSession runs, which makes selectSession early-return.
|
||||
expect(
|
||||
assignIdx === -1 || assignIdx > selectIdx,
|
||||
`activeSessionId is assigned at ${assignIdx}, before selectSession at ${selectIdx}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sends resize to server after creating a session via quick-start', async () => {
|
||||
|
||||
@@ -200,12 +200,12 @@ describe('mobile filesystem picker actions', () => {
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
getFlushed: () => ({ count: 4, text: 'a😀b' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
_flushedTexts: new Map([['session-1', 'a😀b']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
@@ -216,7 +216,7 @@ describe('mobile filesystem picker actions', () => {
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(3));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
|
||||
@@ -23,6 +23,8 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c
|
||||
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.js';
|
||||
import { enabledClis } from '../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
// renderIndexHtml probes the real PATH for every CLI, which would make the
|
||||
// assertions below depend on whatever happens to be installed on the machine
|
||||
@@ -79,6 +81,14 @@ vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
vi.mock('../src/git-clone.js', () => ({
|
||||
isGitAvailable: vi.fn(() => false),
|
||||
}));
|
||||
// The custom-model list carries `label`, a string a user's own clis.json can set.
|
||||
// Wrap enabledClis so ONE test below can hand renderIndexHtml a label with `$'`
|
||||
// in it while every other test still reads the real stock registry through the
|
||||
// real implementation.
|
||||
vi.mock('../src/config/cli-registry/registry.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/config/cli-registry/registry.js')>();
|
||||
return { ...actual, enabledClis: vi.fn(actual.enabledClis) };
|
||||
});
|
||||
|
||||
const TEMPLATE = [
|
||||
'<head>',
|
||||
@@ -222,6 +232,35 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
expect(eval(escaped)[0].label).toBe('</script><script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it("inserts a label containing $' verbatim instead of splicing the document into the script", async () => {
|
||||
// `String.replace` with a STRING replacement interprets `$'` as "the text
|
||||
// after the match", so a clis.json label carrying it used to re-inject the
|
||||
// rest of the document (the whole <body>) into the inline script, past
|
||||
// escapeScriptJson, which only neutralizes `<`. Every `</head>` injection
|
||||
// passes a replacer FUNCTION instead, whose return value is inserted
|
||||
// verbatim. The other `$` forms ride along so a partial escape cannot pass.
|
||||
const claude = STOCK_CLIS.find((e) => e.id === 'claude')!;
|
||||
const label = "Claude $' $& $` $1 $$";
|
||||
const real = vi.mocked(enabledClis).getMockImplementation()!;
|
||||
vi.mocked(enabledClis).mockImplementation(() => [{ ...claude, label }]);
|
||||
try {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html.match(/<body>/g)).toHaveLength(1);
|
||||
const clis = JSON.parse(html.match(/window\.__codemanCustomModelClis=(\[.*?\]);/)![1]);
|
||||
expect(clis).toEqual([{ id: 'claude', label }]);
|
||||
} finally {
|
||||
vi.mocked(enabledClis).mockImplementation(real);
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts a solo id containing $' verbatim, under the same replacer rule", async () => {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, "sess$'x");
|
||||
expect(html.match(/<body>/g)).toHaveLength(1);
|
||||
expect(html).toContain(`window.__CODEMAN_SOLO__="sess$'x"`);
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Table-driven pin for `run()`'s dispatch in session-ui.js
|
||||
* (PR #458). Before the run-menu consolidation, `run()` was an eight-arm
|
||||
* `if (mode === 'codex') return this.runCodex(); ...` chain and each arm was
|
||||
* pinned only by the name it called; after it, every non-claude, non-shell
|
||||
* mode reaches ONE shared launcher, `_runCliMode(mode)`, gated on
|
||||
* `EXTERNAL_CLI_MODES` (the key set of `RUN_MODE_LAUNCH`). Nothing pinned that
|
||||
* gate: a mode dropped from the table would fall through to `runClaude()` and
|
||||
* start a Claude session under a Codex label with no error, while an unknown
|
||||
* mode reaching `_runCliMode()` would throw on `entry.label` of an undefined
|
||||
* entry.
|
||||
*
|
||||
* The external ids are read off `RUN_MODE_LAUNCH` itself (same JSDOM
|
||||
* extraction as test/run-mode-launch-table-drift.test.ts, which separately
|
||||
* pins that key set against stock.ts), so a ninth CLI is covered the day it
|
||||
* lands in the table.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface HarnessApp {
|
||||
_runMode?: string;
|
||||
_runInFlight?: boolean;
|
||||
_runMinLockMs?: number;
|
||||
run: () => Promise<unknown>;
|
||||
runClaude: ReturnType<typeof vi.fn>;
|
||||
runShell: ReturnType<typeof vi.fn>;
|
||||
_runCliMode: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
app: HarnessApp;
|
||||
runBtn: HTMLButtonElement;
|
||||
externalIds: string[];
|
||||
}
|
||||
|
||||
function loadHarness(): Harness {
|
||||
const dom = new JSDOM('<!doctype html><body><button id="runBtn"></button></body>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'dangerously',
|
||||
});
|
||||
const win = dom.window as unknown as {
|
||||
eval: (s: string) => void;
|
||||
document: Document;
|
||||
CodemanApp: new () => HarnessApp;
|
||||
__TEST_RUN_MODE_LAUNCH: Record<string, unknown>;
|
||||
};
|
||||
win.eval('window.CodemanApp = function CodemanApp() {};');
|
||||
// The assignment rides in the SAME evaluated string as the module:
|
||||
// RUN_MODE_LAUNCH is a bare top-level `const`, visible only to this eval call
|
||||
// (see test/run-mode-launch-table-drift.test.ts for the measurement).
|
||||
win.eval(`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`);
|
||||
const app = new win.CodemanApp();
|
||||
app._runMinLockMs = 0; // run() otherwise holds its lock for >= 500ms per call
|
||||
app.runClaude = vi.fn(async () => 'claude');
|
||||
app.runShell = vi.fn(async () => 'shell');
|
||||
app._runCliMode = vi.fn(async (mode: string) => `cli:${mode}`);
|
||||
return {
|
||||
app,
|
||||
runBtn: win.document.getElementById('runBtn') as HTMLButtonElement,
|
||||
externalIds: Object.keys(win.__TEST_RUN_MODE_LAUNCH),
|
||||
};
|
||||
}
|
||||
|
||||
describe('run() dispatch (session-ui.js)', () => {
|
||||
const { externalIds } = loadHarness();
|
||||
|
||||
it('reads at least the eight external CLIs off RUN_MODE_LAUNCH (anti-vacuity)', () => {
|
||||
expect(externalIds.length).toBeGreaterThanOrEqual(8);
|
||||
expect(externalIds).not.toContain('claude');
|
||||
expect(externalIds).not.toContain('shell');
|
||||
});
|
||||
|
||||
it("'claude' reaches runClaude() and nothing else", async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = 'claude';
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'shell' reaches runShell(), which needs no CLI probe at all", async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = 'shell';
|
||||
await expect(app.run()).resolves.toBe('shell');
|
||||
expect(app.runShell).toHaveBeenCalledTimes(1);
|
||||
expect(app.runClaude).not.toHaveBeenCalled();
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const id of externalIds) {
|
||||
it(`'${id}' reaches _runCliMode('${id}') and never runClaude()`, async () => {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = id;
|
||||
await expect(app.run()).resolves.toBe(`cli:${id}`);
|
||||
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).toHaveBeenCalledWith(id);
|
||||
expect(app.runClaude).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
it('an unknown mode lands on runClaude(), never on the shared launcher', async () => {
|
||||
// `_runCliMode(mode)` reads `RUN_MODE_LAUNCH[mode].label` unguarded, so an
|
||||
// unknown id reaching it would throw rather than launch anything.
|
||||
for (const mode of ['nope', 'CLAUDE', 'code x']) {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = mode;
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
expect(app.runShell).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('an unset or empty _runMode defaults to claude', async () => {
|
||||
for (const mode of [undefined, '']) {
|
||||
const { app } = loadHarness();
|
||||
app._runMode = mode;
|
||||
await expect(app.run()).resolves.toBe('claude');
|
||||
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
||||
expect(app._runCliMode).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the launch lock for the whole launch and releases it afterwards', async () => {
|
||||
const { app, runBtn } = loadHarness();
|
||||
app._runMode = 'codex';
|
||||
const pending = app.run();
|
||||
expect(app._runInFlight).toBe(true);
|
||||
expect(runBtn.disabled).toBe(true);
|
||||
expect(runBtn.getAttribute('aria-busy')).toBe('true');
|
||||
// A second click while the first launch is in flight is a no-op.
|
||||
await expect(app.run()).resolves.toBeUndefined();
|
||||
await pending;
|
||||
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._runInFlight).toBe(false);
|
||||
expect(runBtn.disabled).toBe(false);
|
||||
expect(runBtn.hasAttribute('aria-busy')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview `RUN_MODE_LAUNCH` (session-ui.js, PR B2) restates four things
|
||||
* `stock.ts` already owns: label, an install command, whether the CLI
|
||||
* supports a custom-model launch, and the external-mode key set itself.
|
||||
* They agree today, but nothing enforced it — the dangerous drift is
|
||||
* `supportsCustomModel`: the Run menu's "CLI (endpoint)" rows come from the
|
||||
* server-injected `window.__codemanCustomModelClis` (built from
|
||||
* `capabilities.customModelInjection.kind !== 'unsupported'`), so a CLI that
|
||||
* gains a real injection recipe later would be OFFERED in that menu while
|
||||
* `_runCliMode` still drops the `customModel` field for it — the session
|
||||
* launches on the vendor's cloud while the UI claims the local endpoint.
|
||||
*
|
||||
* Drives the REAL session-ui.js via JSDOM (`runScripts: 'dangerously'`, same
|
||||
* approach as test/custom-model-one-shot-launch.test.ts), extracting the
|
||||
* module-level `RUN_MODE_LAUNCH` const by appending one assignment line to
|
||||
* the SAME source string before the one `eval()` call — it is not attached
|
||||
* to `window` on its own (top-level `const` lives in the script's own
|
||||
* lexical scope, not the global object), and a SEPARATE later `eval()` call
|
||||
* cannot see an earlier call's top-level bindings either (measured: each
|
||||
* `window.eval()` invocation gets its own top-level lexical environment in
|
||||
* jsdom), so the assignment has to ride in the same evaluated string.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface RunModeLaunchEntry {
|
||||
label: string;
|
||||
installHint: string;
|
||||
supportsCustomModel: boolean;
|
||||
buildConfig: (globalSettings: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function loadRunModeLaunch(): Record<string, RunModeLaunchEntry> {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', runScripts: 'dangerously' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => unknown };
|
||||
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||
// The assignment MUST be part of the same evaluated string as
|
||||
// SESSION_UI_JS — RUN_MODE_LAUNCH is a bare top-level `const`, so it only
|
||||
// exists in the lexical scope of THIS eval call.
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`
|
||||
);
|
||||
return (win as unknown as { __TEST_RUN_MODE_LAUNCH: Record<string, RunModeLaunchEntry> }).__TEST_RUN_MODE_LAUNCH;
|
||||
}
|
||||
|
||||
describe('RUN_MODE_LAUNCH (session-ui.js) stays in step with stock.ts', () => {
|
||||
const runModeLaunch = loadRunModeLaunch();
|
||||
const byId = new Map(STOCK_CLIS.map((e) => [e.id as string, e]));
|
||||
|
||||
it('covers exactly the non-claude, non-shell stock CLIs — no more, no fewer', () => {
|
||||
const expectedIds = STOCK_CLIS.map((e) => e.id as string)
|
||||
.filter((id) => id !== 'claude' && id !== 'shell')
|
||||
.sort();
|
||||
expect(Object.keys(runModeLaunch).sort()).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('label matches CliEntry.label for every entry', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const stockEntry = byId.get(id);
|
||||
expect(stockEntry, `no stock entry for ${id}`).toBeTruthy();
|
||||
expect(entry.label, `${id} label drifted from stock.ts`).toBe(stockEntry!.label);
|
||||
}
|
||||
});
|
||||
|
||||
it('installHint embeds the real linux install command', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const command = byId.get(id)!.discovery.install.command?.linux;
|
||||
if (!command) continue; // shell-less entries (none today) carry no command to check
|
||||
expect(entry.installHint, `${id} installHint no longer matches stock.ts's linux install command`).toContain(
|
||||
command
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsCustomModel matches capabilities.customModelInjection.kind !== "unsupported"', () => {
|
||||
// This is the one that fails SILENTLY if it drifts (see file header):
|
||||
// window.__codemanCustomModelClis (server.ts) is built from this same
|
||||
// stock.ts field, so a mismatch here means the Run-menu picker and the
|
||||
// actual launch body disagree about which CLIs are custom-model-capable.
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const supported = byId.get(id)!.capabilities.customModelInjection.kind !== 'unsupported';
|
||||
expect(entry.supportsCustomModel, `${id}.supportsCustomModel drifted from stock.ts's capability`).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -166,8 +166,18 @@ describe('Run launch synchronization', () => {
|
||||
|
||||
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||
//
|
||||
// `(\w*)` in the param list, not `()`, and the leading `_?`: PR B2
|
||||
// consolidated the eight run<Mode>() bodies into one shared
|
||||
// `_runCliMode(mode)`, and the ORIGINAL `\(\)`-only pattern matched every
|
||||
// one-line wrapper (`async runOpenCode() { return this._runCliMode(...) }`)
|
||||
// but not `_runCliMode` itself, where the real terminal-ownership logic
|
||||
// now lives — so this guard could see 8 clean one-liners and stay green
|
||||
// while the actual bug shipped unseen for all eight external CLIs at
|
||||
// once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode`
|
||||
// left this test 32/32 green under the old pattern.
|
||||
const bodies = new Map<string, string>();
|
||||
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||
const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm;
|
||||
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||
const start = m.index + m[0].length;
|
||||
const end = src.indexOf('\n },', start);
|
||||
@@ -181,6 +191,7 @@ describe('Run launch synchronization', () => {
|
||||
expect.arrayContaining([
|
||||
'runClaude',
|
||||
'runShell',
|
||||
'_runCliMode',
|
||||
'runOpenCode',
|
||||
'runCodex',
|
||||
'runGemini',
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// test/split-pane-auto-collapse-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like session-close-fallback.test.ts).
|
||||
//
|
||||
// Fast, CI-visible unit coverage for the `_onSessionDeleted` prototype patch in
|
||||
// terminal-split.js (whole-branch review finding I6). The "Pane B ends" branch
|
||||
// already has real-Chromium coverage in test/split-pane-auto-collapse.browser.test.ts,
|
||||
// but that suite is excluded from `npm test` (see Testing in CLAUDE.md), and the
|
||||
// "Pane A ends, Pane B gets promoted" branch had NO coverage anywhere — it is the
|
||||
// one whose correctness depends on exact ordering: `_splitSessionId` must be
|
||||
// captured BEFORE `closeSplitPane()` runs (which nulls it), or the promoted
|
||||
// session id is lost. This file pins that ordering plus the sibling branches
|
||||
// (Pane B ends, unrelated session ends) so a regression fails in the normal CI
|
||||
// gate rather than only in the browser suite nobody runs by default.
|
||||
//
|
||||
// The same vm harness also pins two merge-time fixes from the final review of
|
||||
// #453 that no browser test reaches: closeSplitPane() tearing down a divider
|
||||
// drag that is still in progress (the body-level `split-pane-resizing` lock
|
||||
// otherwise outlived the split), and openSplitPane() re-applying the picker's
|
||||
// own exclusions for a row that went stale while the menu sat open.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** A class-list stub backed by a Set, enough for add/remove/contains. */
|
||||
function fakeClassList() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (c: string) => void classes.add(c),
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
contains: (c: string) => classes.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The only `document` surface the methods under test touch: `body.classList`
|
||||
* (the drag's cursor/selection lock) and `querySelector` (the split container
|
||||
* and the header button, both absent here, which is the "already collapsed /
|
||||
* no button rendered" path every early-return in closeSplitPane() takes).
|
||||
* `querySelector` is reassignable per test so a test can plant a sentinel.
|
||||
*/
|
||||
const fakeDocument = {
|
||||
body: { classList: fakeClassList() },
|
||||
querySelector: (_selector: string): unknown => null,
|
||||
getElementById: (_id: string): unknown => null,
|
||||
};
|
||||
|
||||
const rafCalls: Array<() => void> = [];
|
||||
const cancelledRafs: number[] = [];
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
// innerWidth clears the desktop-only gate so openSplitPane() reaches the
|
||||
// exclusions under test; SPLIT_PANE_MIN_WIDTH is the bare global
|
||||
// constants.js would otherwise define.
|
||||
window: { innerWidth: 1600 },
|
||||
SPLIT_PANE_MIN_WIDTH: 1180,
|
||||
document: fakeDocument,
|
||||
requestAnimationFrame: (fn: () => void) => rafCalls.push(fn),
|
||||
cancelAnimationFrame: (id: number) => cancelledRafs.push(id),
|
||||
});
|
||||
// A minimal fake CodemanApp — terminal-split.js only needs `_onSessionDeleted`
|
||||
// and `selectSession` to already exist on the prototype (it wraps both), and
|
||||
// neither wrapped body executes at module-load time (only inside method
|
||||
// calls), so no xterm/WebSocket/CodemanSplitPane globals are needed here.
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
_onSessionDeleted(data) {
|
||||
(this.__originalDeletedCalls ??= []).push(data);
|
||||
}
|
||||
selectSession(id) {
|
||||
(this.__originalSelectSessionCalls ??= []).push(id);
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(`${fakeAppSrc}\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp as {
|
||||
prototype: {
|
||||
_onSessionDeleted: (this: unknown, data: { id: string }) => unknown;
|
||||
closeSplitPane: (this: unknown, options?: { skipPrimaryResize?: boolean }) => unknown;
|
||||
openSplitPane: (this: unknown, sessionId: string) => unknown;
|
||||
_installSplitDividerDrag: (this: unknown, divider: unknown, wrap: unknown, paneB: unknown) => unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
type TestApp = {
|
||||
activeSessionId: string | null;
|
||||
_splitSessionId: string | null;
|
||||
_splitPane: { destroy: ReturnType<typeof vi.fn> } | null;
|
||||
_closingSessions: Set<string>;
|
||||
closeSplitPane: ReturnType<typeof vi.fn>;
|
||||
selectSession: ReturnType<typeof vi.fn>;
|
||||
__originalDeletedCalls?: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
/** A split-active instance: Pane A === activeSessionId, Pane B === _splitSessionId. */
|
||||
function makeSplitActiveApp(): TestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
// Empty by default: the app's OWN closeSession() is not mid-await for this
|
||||
// delete, so the promotion below is expected to fire. See the dedicated
|
||||
// test further down for the non-empty (_closingSessions owns it) case.
|
||||
app._closingSessions = new Set();
|
||||
// closeSplitPane is mocked but mirrors the REAL implementation's one
|
||||
// observable side effect relevant here: it nulls _splitPane/_splitSessionId.
|
||||
// If the wrapper captured _splitSessionId AFTER calling closeSplitPane
|
||||
// instead of before, this would surface as selectSession(undefined) below.
|
||||
app.closeSplitPane = vi.fn(() => {
|
||||
app._splitPane = null;
|
||||
app._splitSessionId = null;
|
||||
});
|
||||
app.selectSession = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('terminal-split.js _onSessionDeleted wrapper (I6)', () => {
|
||||
it('Pane A ends: closes the split and promotes Pane B via selectSession(ORIGINAL splitSessionId)', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
// activeSessionId is still the deleted id while the split collapses (the
|
||||
// original handler, called last, is what retires it), so the closing
|
||||
// resize must be skipped or it targets a session the server already
|
||||
// removed; selectSession() below sizes the promoted session itself.
|
||||
expect(app.closeSplitPane).toHaveBeenCalledWith({ skipPrimaryResize: true });
|
||||
// Pinned ordering: selectSession must receive the id _splitSessionId held
|
||||
// BEFORE closeSplitPane ran (which nulls it), not whatever it holds after.
|
||||
// { auto: true } because this is an app-driven promotion, not the user
|
||||
// clicking a tab — it must not spend the promoted session's idle alert.
|
||||
expect(app.selectSession).toHaveBeenCalledWith('session-b', { auto: true });
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane B ends: closes the split without promoting anything', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-b' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
// Pane A's session is alive and stays active: the closing resize is
|
||||
// wanted here, so no skip option must be passed.
|
||||
expect(app.closeSplitPane).toHaveBeenCalledWith();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-b' }]);
|
||||
});
|
||||
|
||||
it('an unrelated session ending leaves the split untouched', () => {
|
||||
const app = makeSplitActiveApp();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-c' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).not.toBeNull();
|
||||
expect(app._splitSessionId).toBe('session-b');
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-c' }]);
|
||||
});
|
||||
|
||||
it('the original _onSessionDeleted always fires, split-active or not', () => {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = null;
|
||||
app._splitPane = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
|
||||
it('Pane A ends via the user closing its OWN tab: still collapses the split, but skips the promotion', () => {
|
||||
// closeSession() (app.js) adds the id to _closingSessions BEFORE awaiting
|
||||
// the delete, then owns the follow-up selection itself once it lands —
|
||||
// selecting Pane B's session here too would race it for which tab wins.
|
||||
const app = makeSplitActiveApp();
|
||||
app._closingSessions.add('session-a');
|
||||
|
||||
CodemanApp.prototype._onSessionDeleted.call(app, { id: 'session-a' });
|
||||
|
||||
expect(app.closeSplitPane).toHaveBeenCalledTimes(1);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.__originalDeletedCalls).toEqual([{ id: 'session-a' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── closeSplitPane() vs a divider drag in progress ──────────────────────────
|
||||
|
||||
type DragEl = ReturnType<typeof fakeElement>;
|
||||
|
||||
/** A DOM element stub: class list, a listener registry, pointer-capture spies. */
|
||||
function fakeElement() {
|
||||
const listeners = new Map<string, Set<(e: unknown) => void>>();
|
||||
return {
|
||||
style: {} as Record<string, string>,
|
||||
classList: fakeClassList(),
|
||||
parentElement: null as unknown,
|
||||
listeners,
|
||||
addEventListener(type: string, fn: (e: unknown) => void) {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (e: unknown) => void) {
|
||||
listeners.get(type)?.delete(fn);
|
||||
},
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function listenerCount(el: DragEl, type: string): number {
|
||||
return el.listeners.get(type)?.size ?? 0;
|
||||
}
|
||||
|
||||
type DragTestApp = TestApp & {
|
||||
_splitDividerDragTeardown?: (() => void) | null;
|
||||
sendResize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('closeSplitPane() tears down a divider drag that is still in progress', () => {
|
||||
afterEach(() => {
|
||||
fakeDocument.querySelector = () => null;
|
||||
fakeDocument.body.classList.remove('split-pane-resizing');
|
||||
});
|
||||
|
||||
/**
|
||||
* A split-active app with the REAL _installSplitDividerDrag() wired to stub
|
||||
* elements, then armed the way the browser arms it: a primary-button
|
||||
* pointerdown on the divider. `document.querySelector` hands closeSplitPane()
|
||||
* a stub container so it runs its full body (reparent, remove, resize) rather
|
||||
* than the already-collapsed early return.
|
||||
*/
|
||||
function makeDraggingApp() {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as DragTestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app._splitSessionId = 'session-b';
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
app._closingSessions = new Set();
|
||||
app.sendResize = vi.fn(() => Promise.resolve(true));
|
||||
const divider = fakeElement();
|
||||
const wrap = fakeElement();
|
||||
const paneB = fakeElement();
|
||||
const container = { querySelector: () => wrap, parentElement: { insertBefore: vi.fn() }, remove: vi.fn() };
|
||||
fakeDocument.querySelector = (selector: string) => (selector === '.terminal-split-container' ? container : null);
|
||||
|
||||
CodemanApp.prototype._installSplitDividerDrag.call(app, divider, wrap, paneB);
|
||||
const [onDown] = divider.listeners.get('pointerdown')!;
|
||||
onDown({ button: 0, pointerId: 7, preventDefault: vi.fn() });
|
||||
return { app, divider, container };
|
||||
}
|
||||
|
||||
it('precondition: pointerdown arms the page-wide resize lock, capture and the drag listeners', () => {
|
||||
const { divider } = makeDraggingApp();
|
||||
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(true);
|
||||
expect(divider.classList.contains('dragging')).toBe(true);
|
||||
expect(divider.setPointerCapture).toHaveBeenCalledWith(7);
|
||||
for (const type of ['pointermove', 'pointerup', 'pointercancel']) {
|
||||
expect(listenerCount(divider, type), type).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('clears body.split-pane-resizing, releases capture and drops the drag listeners', () => {
|
||||
const { app, divider, container } = makeDraggingApp();
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app);
|
||||
|
||||
// The lock is `cursor: col-resize; user-select: none` on EVERY element
|
||||
// (styles.css); left set, it outlives the split until a reload.
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(false);
|
||||
expect(divider.classList.contains('dragging')).toBe(false);
|
||||
expect(divider.releasePointerCapture).toHaveBeenCalledWith(7);
|
||||
for (const type of ['pointermove', 'pointerup', 'pointercancel']) {
|
||||
expect(listenerCount(divider, type), type).toBe(0);
|
||||
}
|
||||
expect(app._splitDividerDragTeardown).toBeNull();
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(container.remove).toHaveBeenCalledTimes(1);
|
||||
// An ordinary close still resizes the (live) primary pane's session.
|
||||
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||
});
|
||||
|
||||
it('cancels a reflow frame the drag had queued', () => {
|
||||
const { app, divider } = makeDraggingApp();
|
||||
const [onMove] = divider.listeners.get('pointermove')!;
|
||||
onMove({ clientX: 400 });
|
||||
const queuedRafId = rafCalls.length;
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app);
|
||||
|
||||
expect(cancelledRafs).toContain(queuedRafId);
|
||||
});
|
||||
|
||||
it('closeSplitPane({ skipPrimaryResize: true }) collapses without resizing the primary session', () => {
|
||||
const { app, container } = makeDraggingApp();
|
||||
|
||||
CodemanApp.prototype.closeSplitPane.call(app, { skipPrimaryResize: true });
|
||||
|
||||
expect(app.sendResize).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(container.remove).toHaveBeenCalledTimes(1);
|
||||
expect(fakeDocument.body.classList.contains('split-pane-resizing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── openSplitPane() re-applies the picker's exclusions ──────────────────────
|
||||
|
||||
type OpenTestApp = TestApp & {
|
||||
activeWebviewId: string | null;
|
||||
sessions: Map<string, { pid: number | null; name?: string }>;
|
||||
detachedSessions: Set<string>;
|
||||
};
|
||||
|
||||
describe('openSplitPane() re-applies the picker exclusions at open time', () => {
|
||||
// buildSplitPickerSessions() (constants.js) never lists a detached session
|
||||
// or one with `pid === null`, but the menu can sit open while a listed
|
||||
// session's CLI exits or gets popped out, and the row's click carries only
|
||||
// the id. openSplitPane() must refuse those the same way the picker would
|
||||
// have (silently, like its neighbouring gates) BEFORE touching the DOM.
|
||||
const DOM_REACHED = 'sentinel: openSplitPane reached the DOM stage';
|
||||
|
||||
function makeApp(): OpenTestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as OpenTestApp;
|
||||
app.activeSessionId = 'session-a';
|
||||
app.activeWebviewId = null;
|
||||
app._splitPane = null;
|
||||
app._splitSessionId = null;
|
||||
app._closingSessions = new Set();
|
||||
app.closeSplitPane = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
app.sessions = new Map([
|
||||
['session-a', { pid: 101, name: 'w1-active' }],
|
||||
['session-exited', { pid: null, name: 'w2-exited' }],
|
||||
['session-detached', { pid: 103, name: 'w3-detached' }],
|
||||
['session-ok', { pid: 104, name: 'w4-ok' }],
|
||||
]);
|
||||
app.detachedSessions = new Set(['session-detached']);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeDocument.querySelector = vi.fn(() => {
|
||||
throw new Error(DOM_REACHED);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
fakeDocument.querySelector = () => null;
|
||||
});
|
||||
|
||||
it('control: a listed, attached session gets past every guard to the DOM stage', () => {
|
||||
const app = makeApp();
|
||||
expect(() => CodemanApp.prototype.openSplitPane.call(app, 'session-ok')).toThrow(DOM_REACHED);
|
||||
expect(fakeDocument.querySelector).toHaveBeenCalledWith('.terminal-wrap');
|
||||
});
|
||||
|
||||
it('refuses a session whose CLI has exited (pid === null) without touching the DOM', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-exited')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a detached (popped-out) session', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-detached')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an id with no session record at all', () => {
|
||||
const app = makeApp();
|
||||
expect(CodemanApp.prototype.openSplitPane.call(app, 'session-ghost')).toBeUndefined();
|
||||
expect(fakeDocument.querySelector).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).toBeNull();
|
||||
});
|
||||
|
||||
it('a refusal leaves an already-open split alone', () => {
|
||||
const app = makeApp();
|
||||
app._splitPane = { destroy: vi.fn() };
|
||||
app._splitSessionId = 'session-ok';
|
||||
|
||||
CodemanApp.prototype.openSplitPane.call(app, 'session-exited');
|
||||
|
||||
expect(app.closeSplitPane).not.toHaveBeenCalled();
|
||||
expect(app._splitPane).not.toBeNull();
|
||||
expect(app._splitSessionId).toBe('session-ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// test/split-pane-auto-collapse.browser.test.ts
|
||||
/** @fileoverview Real Chromium coverage for split auto-collapse when either session ends (Task 6). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3177;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane auto-collapse in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('deleting the Pane B session auto-collapses the split', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
// Delete Pane B's session from "outside" (simulating the SSE event another
|
||||
// client's delete would produce, by hitting the DELETE route directly).
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idB);
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const collapsed = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
splitPaneNulled: (window as any).app._splitPane === null,
|
||||
}));
|
||||
expect(collapsed.hasContainer).toBe(true);
|
||||
expect(collapsed.splitPaneNulled).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, idA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadSplitPaneHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanSplitPane: any }).CodemanSplitPane;
|
||||
}
|
||||
|
||||
describe('CodemanSplitPane.clampDividerPercent', () => {
|
||||
it('passes through a value inside the clamp range', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(50)).toBe(50);
|
||||
expect(clampDividerPercent(35.5)).toBe(35.5);
|
||||
});
|
||||
|
||||
it('clamps below the floor to the floor', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(5)).toBe(20);
|
||||
});
|
||||
|
||||
it('clamps above the ceiling to the ceiling', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(95)).toBe(80);
|
||||
});
|
||||
|
||||
it('honors custom min/max', () => {
|
||||
const { clampDividerPercent } = loadSplitPaneHelper();
|
||||
expect(clampDividerPercent(10, 15, 85)).toBe(15);
|
||||
expect(clampDividerPercent(90, 15, 85)).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanSplitPane.buildSplitPickerSessions', () => {
|
||||
it('excludes the active session and preserves tab order', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w1-mcp-memory' }],
|
||||
['c', { name: null }],
|
||||
]);
|
||||
const sessionOrder = ['a', 'b', 'c'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, 'a');
|
||||
expect(result).toEqual([
|
||||
{ id: 'b', label: 'w1-mcp-memory' },
|
||||
{ id: 'c', label: 'Session' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops order entries with no matching session (stale ids)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const sessionOrder = ['a', 'ghost'];
|
||||
const result = buildSplitPickerSessions(sessions, sessionOrder, null);
|
||||
expect(result).toEqual([{ id: 'a', label: 'w1-codeman' }]);
|
||||
});
|
||||
|
||||
it('returns an empty list when only the excluded session exists', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([['a', { name: 'w1-codeman' }]]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a'], 'a');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes a session with no PTY attached (pid === null)', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-exited', pid: null }],
|
||||
['c', { name: 'w3-alive', pid: 12345 }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b', 'c'], 'a');
|
||||
expect(result).toEqual([{ id: 'c', label: 'w3-alive' }]);
|
||||
});
|
||||
|
||||
it('excludes a detached session even when it also has no PTY', () => {
|
||||
const { buildSplitPickerSessions } = loadSplitPaneHelper();
|
||||
const sessions = new Map([
|
||||
['a', { name: 'w1-codeman' }],
|
||||
['b', { name: 'w2-detached', pid: null }],
|
||||
]);
|
||||
const result = buildSplitPickerSessions(sessions, ['a', 'b'], 'a', new Set(['b']));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// test/split-pane-hidden-button-css.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the split-pane whole-branch review finding C1: the
|
||||
// header ships `.btn-split.btn-split--hidden` in index.html (an opt-in
|
||||
// header button, gated behind `showSplitButton`), but no CSS anywhere gave
|
||||
// `--hidden` markers meaning for that class, so the Split button rendered
|
||||
// VISIBLE to every user on every viewport regardless of the setting.
|
||||
//
|
||||
// Every OTHER opt-in header button follows a marker-class pattern: the base
|
||||
// rule is `display:inline-flex !important` and a more-specific
|
||||
// `.btn-x.btn-x--hidden { display: none !important; }` rule hides it
|
||||
// (`.btn-multimonitor--hidden` etc. in styles.css). C1 fixed the missing rule
|
||||
// for `.btn-split--hidden`; this test is the guard so the NEXT such class
|
||||
// fails loudly here instead of shipping invisible-until-noticed, the same
|
||||
// static-parse shape as test/mobile-header-buttons-policy.test.ts (read
|
||||
// first for the parsing conventions this file reuses).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
/** Every distinct `*--hidden` class token referenced anywhere in index.html. */
|
||||
function loadHiddenMarkerClasses(): Set<string> {
|
||||
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
||||
const classes = new Set<string>();
|
||||
for (const m of html.matchAll(/class="([^"]*)"/g)) {
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token.endsWith('--hidden')) classes.add(token);
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*--hidden` class that has a CSS rule (anywhere — top-level or inside
|
||||
* any at-rule, e.g. a phone-only @media block) whose selector targets that
|
||||
* exact class and whose declarations set `display: none` (with or without
|
||||
* `!important`).
|
||||
*/
|
||||
function loadCssHiddenClasses(cssFile: string): Set<string> {
|
||||
const css = readFileSync(join(PUBLIC, cssFile), 'utf-8');
|
||||
const hidden = new Set<string>();
|
||||
postcss.parse(css).walkRules((rule) => {
|
||||
let hides = false;
|
||||
rule.walkDecls('display', (decl) => {
|
||||
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
||||
});
|
||||
if (!hides) return;
|
||||
for (const token of rule.selector.match(/\.[a-z0-9-]*--hidden\b/gi) || []) {
|
||||
hidden.add(token.slice(1));
|
||||
}
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
describe('Every "*--hidden" marker class has a matching CSS hide rule (static guard)', () => {
|
||||
const markerClasses = loadHiddenMarkerClasses();
|
||||
const cssHidden = new Set([...loadCssHiddenClasses('styles.css'), ...loadCssHiddenClasses('mobile.css')]);
|
||||
|
||||
it('finds at least one *--hidden marker class in index.html (sanity)', () => {
|
||||
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
||||
expect(markerClasses.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every "*--hidden" class in index.html has a `display: none` rule in styles.css or mobile.css', () => {
|
||||
for (const cls of markerClasses) {
|
||||
expect(
|
||||
cssHidden.has(cls),
|
||||
`index.html references class "${cls}" (an opt-in-hide marker) but no rule in styles.css or ` +
|
||||
`mobile.css sets "display: none" for it — the element it marks ships VISIBLE regardless of the ` +
|
||||
`setting that is supposed to gate it. Add ".${cls} { display: none !important; }" (see the sibling ` +
|
||||
`.btn-multimonitor--hidden / .btn-redraw-terminal--hidden rules in styles.css for the pattern).`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the split-pane Split button specifically (C1 regression)', () => {
|
||||
expect(markerClasses.has('btn-split--hidden')).toBe(true);
|
||||
expect(cssHidden.has('btn-split--hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/** @fileoverview Real Chromium coverage for split open/close orchestration and the session picker (Task 5). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3176;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('split-pane orchestration in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
async function createShellSession(): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
// POST /api/sessions nests the session under data.session, and mode:'shell'
|
||||
// does not spawn a PTY on creation alone (pid: null, no pane) — an explicit
|
||||
// POST .../shell is what actually starts it (both found and fixed by Task 4's
|
||||
// implementer against this exact pattern; carried forward here so this task
|
||||
// does not rediscover the same two bugs).
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
it('opening and closing a split reparents and restores .terminal-wrap', async () => {
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
expect(await page.evaluate(() => document.querySelector('.terminal-split-container') === null)).toBe(true);
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const duringSplit = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') !== null,
|
||||
wrapIsChildOfContainer: document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
|
||||
hasPaneB: document.querySelector('.terminal-pane-b') !== null,
|
||||
}));
|
||||
expect(duringSplit.hasContainer).toBe(true);
|
||||
expect(duringSplit.wrapIsChildOfContainer).toBe(true);
|
||||
expect(duringSplit.hasPaneB).toBe(true);
|
||||
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const afterClose = await page.evaluate(() => ({
|
||||
hasContainer: document.querySelector('.terminal-split-container') === null,
|
||||
wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
|
||||
}));
|
||||
expect(afterClose.hasContainer).toBe(true);
|
||||
expect(afterClose.wrapRestored).toBe(true);
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('the split picker excludes the active session', async () => {
|
||||
const id = await createShellSession();
|
||||
|
||||
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
|
||||
await page.waitForFunction((sid) => (window as any).app.activeSessionId === sid, id, { timeout: 10000 });
|
||||
|
||||
const pickerExcludesActive = await page.evaluate((sid) => {
|
||||
(window as any).app.openSplitPicker();
|
||||
const items = Array.from(document.querySelectorAll('.split-picker-item'));
|
||||
return !items.some((el) => el.getAttribute('data-session-id') === sid);
|
||||
}, id);
|
||||
expect(pickerExcludesActive).toBe(true);
|
||||
|
||||
await page.evaluate(async (sid) => {
|
||||
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
|
||||
}, id);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A immediately when a split opens', async () => {
|
||||
// Regression guard: opening a split moved Pane A from full width to 50%
|
||||
// in the DOM, but nothing told its session's PTY/tmux window about the
|
||||
// new size — only the passive, 300ms-debounced ResizeObserver in
|
||||
// terminal-ui.js eventually caught up, leaving stale-width content on
|
||||
// screen until the user manually hit "Redraw Terminal". openSplitPane()
|
||||
// now force-resizes Pane A synchronously as part of the same call.
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.terminal-pane-b', { timeout: 10000 });
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
|
||||
it('force-resizes Pane A once at the end of a divider drag', async () => {
|
||||
// Regression guard: the divider's onMove handler only called
|
||||
// fitAddon.fit() for Pane A — a LOCAL xterm reflow that never told Pane
|
||||
// A's own PTY/tmux window the new size, so existing content stayed laid
|
||||
// out for the pre-drag width. onUp now force-resizes Pane A once, at
|
||||
// drag end (not per-move, to avoid flooding the PTY with SIGWINCHes
|
||||
// during a fast drag).
|
||||
const idA = await createShellSession();
|
||||
const idB = await createShellSession();
|
||||
|
||||
await page.evaluate((id) => (window as any).app.selectSession(id), idA);
|
||||
await page.waitForFunction((id) => (window as any).app.activeSessionId === id, idA, { timeout: 10000 });
|
||||
await page.evaluate((id) => (window as any).app.openSplitPane(id), idB);
|
||||
await page.waitForSelector('.split-divider', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window as any as { app: any };
|
||||
(window as any).__resizeCalls = [];
|
||||
(window as any).__origSendResize = (window as any).app.sendResize;
|
||||
(window as any).app.sendResize = function (...args: any[]) {
|
||||
(window as any).__resizeCalls.push(args);
|
||||
return (window as any).__origSendResize.apply(app.app, args);
|
||||
};
|
||||
});
|
||||
|
||||
const divider = await page.$('.split-divider');
|
||||
const box = await divider!.boundingBox();
|
||||
if (!box) throw new Error('divider has no bounding box');
|
||||
const startX = box.x + box.width / 2;
|
||||
const startY = box.y + box.height / 2;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 80, startY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const forcedResize = await page.evaluate(
|
||||
(id) =>
|
||||
((window as any).__resizeCalls as Array<[string, { force?: boolean }]>).some(
|
||||
([sessionId, opts]) => sessionId === id && opts?.force === true
|
||||
),
|
||||
idA
|
||||
);
|
||||
expect(forcedResize).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.sendResize = (window as any).__origSendResize;
|
||||
});
|
||||
await page.evaluate(() => (window as any).app.closeSplitPane());
|
||||
await page.waitForFunction(() => document.querySelector('.terminal-split-container') === null, null, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
async (ids) => {
|
||||
await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
|
||||
await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
|
||||
},
|
||||
{ a: idA, b: idB }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// test/split-pane-per-device-setting.test.ts
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for the review finding that landed the blocker: moving
|
||||
// showSplitButton into settings-ui.js's per-device `displayKeys` set is only
|
||||
// HALF of making a setting per-device. The other half is stripping it out of
|
||||
// the object `saveAppSettings()` PUTs to `/api/settings` — displayKeys is a
|
||||
// client-side merge policy, not a wire filter. Without the strip, every save
|
||||
// sent `showSplitButton` in the body, `SettingsUpdateSchema` (.strict()) does
|
||||
// not declare it, the server answered 400 INVALID_INPUT, and because the
|
||||
// call site never checked `res.ok` the UI still reported "Settings saved"
|
||||
// while NOTHING persisted — workspaceHooksEnabled, agentSkillEnabled,
|
||||
// tunnelEnabled, claudeModel, every toggle, on every save, on every device.
|
||||
//
|
||||
// Mirrors test/terminal-auto-copy.test.ts's "keeps the toggle per-device"
|
||||
// guard for autoCopySelection — same three-way rule, same shape of test.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
function read(file: string): string {
|
||||
return readFileSync(join(PUBLIC, file), 'utf8');
|
||||
}
|
||||
|
||||
describe('showSplitButton stays per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8');
|
||||
|
||||
it('is in the client-side displayKeys merge policy', () => {
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'showSplitButton'");
|
||||
});
|
||||
|
||||
it('is stripped out of the object saveAppSettings() PUTs to the server', () => {
|
||||
// The strip is a destructure: `showSplitButton: _ssp,` pulls the key out
|
||||
// of `settings` so it never reaches `...serverSettings` in the PUT body.
|
||||
expect(settingsUi).toContain('showSplitButton: _ssp,');
|
||||
});
|
||||
|
||||
it('is never declared in the .strict() SettingsUpdateSchema', () => {
|
||||
// Not even in a comment — a mention there reads as "this is a real
|
||||
// field" to the next person grepping schemas.ts for it.
|
||||
expect(schemas).not.toContain('showSplitButton');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// test/split-pane-picker-listener-leak-unit.test.ts
|
||||
// Port: N/A (no server/browser — loaded via `vm`, like split-pane-auto-collapse-unit.test.ts).
|
||||
//
|
||||
// Fast, CI-visible regression coverage for a listener leak in `openSplitPicker()` /
|
||||
// `_dismissSplitPicker()` (terminal-split.js): repeatedly opening the split picker
|
||||
// (e.g. clicking the `.btn-split` button, whose real clicks land on its inner
|
||||
// <svg>) used to leave a `click`/`keydown` listener pair attached to `document`
|
||||
// on every cycle, because a pre-existing menu was torn down with a raw
|
||||
// `existing.remove()` instead of through `_dismissSplitPicker()`, and the
|
||||
// single-slot `_splitPickerDismissHandlers` field was overwritten rather than
|
||||
// used to clean up the previous pair first.
|
||||
//
|
||||
// This is a `vm`-driven DOM-listener-count assertion rather than a real-browser
|
||||
// interaction test (`test/split-pane-orchestration.browser.test.ts` already
|
||||
// covers the real click-through-svg interaction and is excluded from `npm test`
|
||||
// per CLAUDE.md's Testing section) — it exercises the exact document
|
||||
// addEventListener/removeEventListener calls the fix and the bug both hinge on,
|
||||
// with no xterm/WebSocket/tmux involved.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** A tiny fake DOM element: enough surface for createElement/appendChild/contains/remove/closest. */
|
||||
function makeFakeElement(tag: string) {
|
||||
const children: Array<ReturnType<typeof makeFakeElement>> = [];
|
||||
let id: string | undefined;
|
||||
let parent: ReturnType<typeof makeFakeElement> | null = null;
|
||||
const el = {
|
||||
tag,
|
||||
style: {} as Record<string, string>,
|
||||
innerHTML: '',
|
||||
className: '',
|
||||
get id() {
|
||||
return id;
|
||||
},
|
||||
set id(v: string | undefined) {
|
||||
id = v;
|
||||
},
|
||||
appendChild(child: ReturnType<typeof makeFakeElement>) {
|
||||
children.push(child);
|
||||
(child as { _parent: unknown })._parent = el;
|
||||
return child;
|
||||
},
|
||||
contains(target: unknown): boolean {
|
||||
if (target === el) return true;
|
||||
return children.some((c) => c.contains(target));
|
||||
},
|
||||
// Real Element.closest(): walk up from THIS element (inclusive), matching
|
||||
// a bare class selector like `.btn-split` against a space-separated
|
||||
// className — enough to exercise the fix's `e.target.closest('.btn-split')`
|
||||
// check without a real DOM.
|
||||
closest(selector: string): ReturnType<typeof makeFakeElement> | null {
|
||||
const cls = selector.replace(/^\./, '');
|
||||
let node: ReturnType<typeof makeFakeElement> | null = el;
|
||||
while (node) {
|
||||
if (node.className.split(/\s+/).includes(cls)) return node;
|
||||
node = (node as unknown as { __parent: ReturnType<typeof makeFakeElement> | null }).__parent;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
remove() {
|
||||
if (parent) {
|
||||
parent = null;
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { bottom: 0, right: 0, left: 0, top: 0, width: 0, height: 0 };
|
||||
},
|
||||
set _parent(p: ReturnType<typeof makeFakeElement>) {
|
||||
parent = p;
|
||||
(el as unknown as { __parent: ReturnType<typeof makeFakeElement> }).__parent = p;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A minimal `document` mock tracking real addEventListener/removeEventListener identity. */
|
||||
function makeFakeDocument() {
|
||||
const elementsById = new Map<string, ReturnType<typeof makeFakeElement>>();
|
||||
const listeners: Record<string, Array<(...args: unknown[]) => unknown>> = {
|
||||
click: [],
|
||||
keydown: [],
|
||||
};
|
||||
|
||||
return {
|
||||
createElement(tag: string) {
|
||||
return makeFakeElement(tag);
|
||||
},
|
||||
body: {
|
||||
appendChild(el: ReturnType<typeof makeFakeElement>) {
|
||||
if (el.id) elementsById.set(el.id, el);
|
||||
},
|
||||
},
|
||||
getElementById(id: string) {
|
||||
const el = elementsById.get(id);
|
||||
if (el) {
|
||||
// Support the real `?.remove()` call site removing it from the registry.
|
||||
const originalRemove = el.remove.bind(el);
|
||||
el.remove = () => {
|
||||
elementsById.delete(id);
|
||||
originalRemove();
|
||||
};
|
||||
}
|
||||
return el;
|
||||
},
|
||||
querySelector(sel: string) {
|
||||
return sel === '.btn-split' ? makeFakeElement('button') : null;
|
||||
},
|
||||
addEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
(listeners[type] ??= []).push(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (...args: unknown[]) => unknown) {
|
||||
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn);
|
||||
},
|
||||
__listenerCount(type: string) {
|
||||
return (listeners[type] ?? []).length;
|
||||
},
|
||||
// Test-only helpers: fire a snapshot of the currently-registered
|
||||
// listeners (a handler removing itself mid-dispatch must not skip or
|
||||
// double-invoke a sibling — snapshotting avoids that ambiguity here).
|
||||
__dispatchClick(target: unknown) {
|
||||
for (const fn of [...(listeners.click ?? [])]) fn({ target });
|
||||
},
|
||||
__dispatchKeydown(key: string) {
|
||||
for (const fn of [...(listeners.keydown ?? [])]) fn({ key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadCodemanAppClass(documentMock: ReturnType<typeof makeFakeDocument>) {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const terminalSplitSrc = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: { innerWidth: 1024 },
|
||||
document: documentMock,
|
||||
setTimeout,
|
||||
// terminal-split.js's picker markup calls the global `escapeHtml()` helper
|
||||
// (defined in constants.js at runtime); a plain identity stub is enough
|
||||
// here since no candidate labels/ids in this test contain HTML.
|
||||
escapeHtml: (s: unknown) => String(s),
|
||||
});
|
||||
const fakeAppSrc = `
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.sessionOrder = [];
|
||||
this.activeSessionId = 'active-session';
|
||||
this._splitPane = null;
|
||||
}
|
||||
}
|
||||
`;
|
||||
vm.runInContext(
|
||||
`${fakeAppSrc}\nwindow.CodemanSplitPane = { buildSplitPickerSessions: () => [] };\n${terminalSplitSrc}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (
|
||||
context as { __CodemanApp: new () => { openSplitPicker: (e?: unknown) => void; _dismissSplitPicker: () => void } }
|
||||
).__CodemanApp;
|
||||
}
|
||||
|
||||
describe('terminal-split.js split-picker document-listener leak (repeated open/close)', () => {
|
||||
let documentMock: ReturnType<typeof makeFakeDocument>;
|
||||
let CodemanApp: ReturnType<typeof loadCodemanAppClass>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
documentMock = makeFakeDocument();
|
||||
CodemanApp = loadCodemanAppClass(documentMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not grow document click/keydown listeners across repeated open cycles with no dismissal', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
// Simulates the real failure sequence: the button's onclick re-fires
|
||||
// `openSplitPicker()` on every click (e.g. landing on the button's inner
|
||||
// <svg>) without an intervening outside click or Escape ever resolving.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers(); // flush the deferred `setTimeout(() => addEventListener('click', ...))`
|
||||
}
|
||||
|
||||
// Exactly one pair should be live — the CURRENT menu's — never one per cycle.
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('drops to zero listeners after the outside-click handler fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
app._dismissSplitPicker();
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('never accumulates listeners across many open→dismiss cycles', () => {
|
||||
const app = new CodemanApp();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
app._dismissSplitPicker();
|
||||
}
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it("does not dismiss when the click lands on the split button's own inner icon (closest() match, not exact-node equality)", () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
// Mirrors the real DOM: the button carries the onclick and the
|
||||
// `.btn-split` class, but the actual click target is its inner <svg>.
|
||||
// The old `e.target !== splitBtn` check matched this (target !== button)
|
||||
// and dismissed the menu the same click had just (re)opened.
|
||||
const button = makeFakeElement('button');
|
||||
button.className = 'btn-split';
|
||||
const svg = button.appendChild(makeFakeElement('svg'));
|
||||
|
||||
documentMock.__dispatchClick(svg);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when a genuine outside click fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('click')).toBe(1);
|
||||
|
||||
const outside = makeFakeElement('div');
|
||||
documentMock.__dispatchClick(outside);
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
|
||||
it('dismisses and removes both listeners by identity when Escape fires', () => {
|
||||
const app = new CodemanApp();
|
||||
app.openSplitPicker();
|
||||
vi.runAllTimers();
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(1);
|
||||
|
||||
documentMock.__dispatchKeydown('Escape');
|
||||
|
||||
expect(documentMock.__listenerCount('click')).toBe(0);
|
||||
expect(documentMock.__listenerCount('keydown')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// test/split-pane-terminal-unit.test.ts
|
||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
||||
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
||||
//
|
||||
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from
|
||||
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
||||
// handler (onclose used to survive it and fire its "disconnected" write into a
|
||||
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
||||
// single-flight. Two refresh frames in a row used to start two concurrent
|
||||
// replays, each clearing the terminal under the other's chunked write; a
|
||||
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
||||
// than dropped, because the in-flight fetch may predate the drop the new frame
|
||||
// reports and no further frame comes to correct stale content.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||
|
||||
type FakeTerminal = {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type FakeSocket = {
|
||||
onopen: unknown;
|
||||
onmessage: unknown;
|
||||
onclose: unknown;
|
||||
onerror: unknown;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type PaneUnderTest = {
|
||||
ws: FakeSocket | null;
|
||||
terminal: FakeTerminal | null;
|
||||
_destroyed: boolean;
|
||||
_bufferLoading: boolean;
|
||||
_bufferRefreshPending: boolean;
|
||||
destroy(): void;
|
||||
_loadBuffer(): Promise<void>;
|
||||
_refreshBuffer(): void;
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: {},
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE: 1024 * 1024,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
|
||||
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', {}, { mode });
|
||||
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
|
||||
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
||||
}
|
||||
|
||||
function jsonResponse(terminalBuffer: string) {
|
||||
return { json: async () => ({ data: { terminalBuffer } }) };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Lets every microtask the vm-side promise chain queued run. */
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||
const pane = makePane();
|
||||
const terminal = pane.terminal;
|
||||
const ws: FakeSocket = { onopen: vi.fn(), onmessage: vi.fn(), onclose: vi.fn(), onerror: vi.fn(), close: vi.fn() };
|
||||
pane.ws = ws;
|
||||
|
||||
pane.destroy();
|
||||
|
||||
// close() fires onclose asynchronously, so a handler left attached ran its
|
||||
// "disconnected" write against a pane whose terminal was already disposed.
|
||||
expect(ws.onopen).toBeNull();
|
||||
expect(ws.onmessage).toBeNull();
|
||||
expect(ws.onclose).toBeNull();
|
||||
expect(ws.onerror).toBeNull();
|
||||
expect(ws.close).toHaveBeenCalledTimes(1);
|
||||
expect(pane.ws).toBeNull();
|
||||
expect(terminal.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(pane.terminal).toBeNull();
|
||||
expect(pane._destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/s1/terminal?full=1');
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('one');
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a shell pane asks for the bounded tail, matching connect()', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('tail'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?tail=${1024 * 1024}`);
|
||||
});
|
||||
|
||||
it('refreshes arriving mid-fetch neither clear nor fetch again, and run ONCE after the replay lands', async () => {
|
||||
const pane = makePane();
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
const second = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Two more frames while the first replay is still in flight.
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
|
||||
first.resolve(jsonResponse('replay-1'));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('replay-1');
|
||||
// Exactly one trailing re-run for the two coalesced frames, not two.
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
second.resolve(jsonResponse('replay-2'));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('replay-2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._bufferRefreshPending).toBe(false);
|
||||
});
|
||||
|
||||
it('holds the flag across the chunked write, not just the fetch', async () => {
|
||||
const pane = makePane();
|
||||
// Three chunks: two full ones plus a tail, so the last two are queued on
|
||||
// requestAnimationFrame and the replay is mid-write after the fetch lands.
|
||||
const big = 'x'.repeat(TERMINAL_CHUNK_SIZE * 2 + 5);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(big));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
expect(pane._bufferLoading).toBe(true);
|
||||
|
||||
// A refresh mid-write must not clear the terminal under the chunks still
|
||||
// to come, nor start a second fetch.
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('after'));
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(4);
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a pending refresh is dropped once the pane is destroyed', async () => {
|
||||
const pane = makePane();
|
||||
const first = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(first.promise);
|
||||
|
||||
pane._refreshBuffer();
|
||||
pane._refreshBuffer();
|
||||
pane.destroy();
|
||||
first.resolve(jsonResponse('late'));
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag so the next refresh can run', async () => {
|
||||
const pane = makePane();
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse('back'));
|
||||
pane._refreshBuffer();
|
||||
await settle();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3175;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('SplitTerminalPane in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('connects, echoes real PTY output, and cleans up on destroy', async () => {
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
// Session creation alone leaves pid:null and no pane (per CLAUDE.md's
|
||||
// Testing section) — the shell PTY only spawns once this is called, and
|
||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||
// below would hang until its own timeout for reasons unrelated to
|
||||
// SplitTerminalPane.
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
pane.connect();
|
||||
|
||||
// Wait for the WS to open, then send a real input frame — testMode's
|
||||
// echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
|
||||
// is what proves the WS round-trip actually reaches a real PTY and back,
|
||||
// not just that xterm can render locally-written text.
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));
|
||||
|
||||
const hasEcho = await new Promise((resolve) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
const poll = () => {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() > deadline) resolve(false);
|
||||
else setTimeout(poll, 100);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
pane.destroy();
|
||||
const cleanedUp = mount.querySelector('.xterm') === null;
|
||||
document.body.removeChild(mount);
|
||||
|
||||
return { hasEcho, cleanedUp };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.hasEcho).toBe(true);
|
||||
expect(result.cleanedUp).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('shows existing scrollback immediately on connect, before any new output', async () => {
|
||||
// Regression guard: connect() previously only opened the WS and waited for
|
||||
// live 'terminal' events (ws-routes.ts sends nothing on connect), so a pane
|
||||
// opened onto an already-quiet session stayed blank until either new output
|
||||
// arrived or a resize happened to trigger a tmux repaint. Writing a marker
|
||||
// and letting the echo settle BEFORE connect() proves the fetched buffer,
|
||||
// not a live echo, is what populates the pane.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
// Write directly to the session (not through SplitTerminalPane, which
|
||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||
// connect() below will use) rather than a fixed delay — the shell's
|
||||
// own startup can race an early write and, on this box, a startup
|
||||
// script issues a `clear` that erases scrollback (modern ncurses
|
||||
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
|
||||
await fetch(`/api/sessions/${id}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }),
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
for (;;) {
|
||||
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
|
||||
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
|
||||
if (buffer.includes('PRE_EXISTING_MARKER')) break;
|
||||
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
const hasMarker = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
|
||||
// xterm's write() parses asynchronously (it queues data and processes it
|
||||
// on a later microtask/frame), so the fetched buffer connect() writes is
|
||||
// not necessarily in the rendered buffer the instant connect() resolves.
|
||||
// Poll rather than check once — no new input is sent here, so any pass
|
||||
// still comes from the ?full=1 fetch inside connect(), never a live echo.
|
||||
let found = false;
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!found && Date.now() < deadline) {
|
||||
const buf = pane.terminal.buffer.active;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes('PRE_EXISTING_MARKER')) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return found;
|
||||
}, sessionId);
|
||||
|
||||
expect(hasMarker).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
|
||||
it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => {
|
||||
// Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no
|
||||
// attachCustomKeyEventHandler of its own, so the document capture-phase
|
||||
// shortcut handler's preventDefault() (which does not stop xterm) left
|
||||
// every one of these chords ALSO writing its raw byte/escape sequence into
|
||||
// Pane B's live PTY on top of whatever the app action did to Pane A.
|
||||
const sessionId = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await page.evaluate(async (id) => {
|
||||
const mount = document.createElement('div');
|
||||
mount.style.width = '400px';
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
await pane.connect();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||
check();
|
||||
});
|
||||
|
||||
const sent: string[] = [];
|
||||
const realSend = pane.ws.send.bind(pane.ws);
|
||||
pane.ws.send = (payload: string) => {
|
||||
sent.push(payload);
|
||||
return realSend(payload);
|
||||
};
|
||||
|
||||
pane.terminal.focus();
|
||||
// Dispatch straight at xterm's own textarea, matching how a real
|
||||
// keypress reaches attachCustomKeyEventHandler — page.keyboard.press()
|
||||
// goes through the OS/CDP input pipeline and would also trigger the
|
||||
// app's document-capture handler (opening a real command palette),
|
||||
// which is not what this test is isolating.
|
||||
const textarea = (pane.terminal as any)._core?.textarea || (pane.terminal as any).textarea;
|
||||
const fire = (init: KeyboardEventInit) => {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
textarea.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
// keyCode is what xterm's evaluateKeyboardEvent switches on to decide
|
||||
// whether to produce a data frame at all — at keyCode 0 (unset) it can
|
||||
// never emit bytes, so the assertion below held regardless of whether
|
||||
// the custom key handler's gate actually fired. Real values (K=75,
|
||||
// 1=49, B=66) are what a real keypress carries.
|
||||
const app = window.app as any;
|
||||
fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette
|
||||
fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch
|
||||
|
||||
// Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode`
|
||||
// opt passed to the constructor above), so `this.sessionMode !== 'shell'`
|
||||
// holds and the gate must block it, mirroring a non-shell (agent) mode.
|
||||
fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true });
|
||||
|
||||
// Shift+Enter: must never reach the PTY as a bare \r (that would submit
|
||||
// an incomplete prompt instead of inserting a newline) — it goes out as
|
||||
// a POST to /api/sessions/:id/send-key instead.
|
||||
const sendKeyCalls: unknown[] = [];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = ((...args: Parameters<typeof fetch>) => {
|
||||
const url = String(args[0]);
|
||||
if (url.includes('/send-key')) {
|
||||
sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null);
|
||||
}
|
||||
return realFetch(...args);
|
||||
}) as typeof fetch;
|
||||
fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true });
|
||||
window.fetch = realFetch;
|
||||
|
||||
// Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal,
|
||||
// Ctrl+C must copy it (never send 0x03) and must copy Pane B's
|
||||
// selection, not Pane A's. app._copyText is stubbed rather than relying
|
||||
// on a real clipboard, which headless Chromium may refuse permission
|
||||
// for.
|
||||
pane.terminal.write('SPLITPANE_COPY_MARKER');
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
pane.terminal.selectAll();
|
||||
let copiedText: string | null = null;
|
||||
const realCopyText = app._copyText;
|
||||
app._copyText = async (text: string) => {
|
||||
copiedText = text;
|
||||
return true;
|
||||
};
|
||||
fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
app._copyText = realCopyText;
|
||||
|
||||
// Ctrl+Shift+C with NO selection: the blanket "no 'i' frames" check
|
||||
// below is NOT what proves this gate works — xterm's own
|
||||
// evaluateKeyboardEvent never emits data for a shifted ctrl-letter in
|
||||
// the first place (verified live: removing the gate entirely still
|
||||
// produces zero WS frames for this exact key), so an absent 'i' frame
|
||||
// is true whether or not the app-level shiftKey branch fires. What the
|
||||
// branch actually buys is `preventDefault()`, so the browser's own
|
||||
// handling of the chord (e.g. Chrome's Inspect-Element binding) is
|
||||
// pre-empted, mirroring Pane A's own "never falls through" contract —
|
||||
// asserted directly via the dispatched event's defaultPrevented.
|
||||
pane.terminal.clearSelection();
|
||||
const ctrlShiftCPrevented = fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true, shiftKey: true });
|
||||
|
||||
// Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when
|
||||
// the sidebar layout is actually active (app.js:4325) — under the
|
||||
// default header-strip layout the app doesn't treat Alt+B as its own
|
||||
// shortcut either, so Pane A forwards the same `ESC b` to its own PTY.
|
||||
// Assert the gate where it is meant to hold: sidebar layout active.
|
||||
//
|
||||
// Setting only the `data-session-list` attribute is not enough: this
|
||||
// event bubbles (matching how a real keypress reaches xterm), so it
|
||||
// also reaches app.js's OWN document-level capture-phase shortcut
|
||||
// dispatcher, which matches the same Alt+B binding and calls the real
|
||||
// toggleSessionSidebar() — that reads the persisted settings (still
|
||||
// 'header'), re-runs applySessionListLayout(), and resets the
|
||||
// attribute back to 'header' before xterm's own (later, non-capture)
|
||||
// key handler ever sees it. Persisting the setting through the app's
|
||||
// own settings cache keeps the attribute stable across that bubble.
|
||||
const prevSettings = { ...app.loadAppSettingsFromStorage() };
|
||||
app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' };
|
||||
app.applySessionListLayout();
|
||||
fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle
|
||||
app._cachedAppSettings = prevSettings;
|
||||
app.applySessionListLayout();
|
||||
|
||||
pane.destroy();
|
||||
document.body.removeChild(mount);
|
||||
return { sent, sendKeyCalls, copiedText, ctrlShiftCPrevented };
|
||||
}, sessionId);
|
||||
|
||||
expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
|
||||
expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]);
|
||||
expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER');
|
||||
expect(result.ctrlShiftCPrevented).toBe(true);
|
||||
|
||||
await page.evaluate(async (id) => {
|
||||
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
|
||||
}, sessionId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user