Compare commits

...
Author SHA1 Message Date
github-actions[bot]Claude Fable 5.1github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9466acfc1a chore: version packages (#461)
* chore: version packages

* chore: sync CLAUDE.md version to 1.32.0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-21 06:00:49 +02:00
Codeman maintainer e899af4305 chore: changeset for the merge-time fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:53:19 +02:00
Codeman maintainer 299a21d5f5 fix(split-pane): merge-time fixes for split-pane sessions (#453)
The maintainer's promised merge-time fixes from the final review of #453:

1. closeSplitPane() tears down a divider drag still in progress, so a split
   that collapses mid-drag no longer leaves body.split-pane-resizing (the
   page-wide col-resize cursor and user-select lock) set until a reload.
2. openSplitPane() re-applies the picker's own exclusions (detached session,
   pid === null, no session record) for a row that went stale while the
   menu sat open, refusing silently like its neighbouring gates.
3. architecture-invariants: the hard-hide of .btn-split is the
   @media (max-width: 1179px) rule in styles.css, not mobile.css.
4. SplitTerminalPane.destroy() nulls onclose (and onerror) beside onopen
   and onmessage.
5. Picker rows drop the data-session-id attribute nothing read.
6. The Pane-A-ends branch collapses with skipPrimaryResize, so the closing
   resize is no longer aimed at the session the server just removed.
7. The {t:'r'} refresh path is single-flight across the fetch and the
   chunked write, coalescing a mid-replay refresh into one trailing re-run.

Tests: split-pane-auto-collapse-unit gains the drag-teardown, exclusion and
skip-resize cases; the new split-pane-terminal-unit covers destroy() and the
refresh single-flight. All were run against the pre-fix module to confirm
they fail there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit dbd39aed015ae5ae5870aba398bf4b4ab5118e47)
2026-09-21 04:53:19 +02:00
Codeman maintainer d8e85285c9 fix(mobile): merge-time fixes for the prompt composer (#444)
- styles.css: restate the composer overlay's own bottom gutter after the fold rules
  (the generic .paste-overlay longhand erased it: 0px flat, hinge strip replacing it
  folded) and subtract the fold strip from the dialog's max-height
- test/foldable-layout.test.ts: simulate the cascade for
  .paste-overlay.prompt-composer-overlay (fails without the CSS fix); pin the palette
  anchor by name instead of ELEMENTS.at(-1)
- keyboard-accessory.js: guard the app global in refreshForActiveSession() like the
  rest of the file
- keyboard-accessory.js: a whitespace-only draft is empty (Send no longer submits
  blank lines); the text still goes out untrimmed
- keyboard-accessory.js: derive _composerMaxLength and the frame refusal from one
  64 KiB frame limit minus both bracketed-paste markers so they cannot drift
- keyboard-accessory.js: translate the textarea placeholder and label at build time,
  since the DOM translator skips <textarea> subtrees
- i18n.js: zh-CN entries for the composer dialog copy
- docs/wiki/Mobile-Guide.md: describe the Compose key instead of a clipboard key
- CLAUDE.md: a "Mobile prompt composer" paragraph after the accessory bar one
- test/mobile-prompt-composer.test.ts: pin the whitespace rule and the derived budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f6725ba52da17b0bdbee8be3b5011e7cae514f69)
2026-09-21 04:39:28 +02:00
Codeman maintainer 0f955327b2 fix(cli-registry): merge-time fixes for the run-menu consolidation (#458)
- test/opencode-resize.test.ts: retarget the launcher guard at the real code (this.selectSession(firstSessionId), any this.activeSessionId assignment) with an anti-vacuity check; the old strings existed nowhere, so it could never fail
- session-ui.js: restore as comments the two invariants the merged bodies lost (deepseek leaves statusReporting unset, i.e. ON; no effort field for external CLIs, it is Claude-specific)
- docs/cli-registry.md: move the frontend-guard paragraph below the two backend-guard paragraphs so they keep their antecedent, and note the widened comparison shape
- test/frontend-cli-no-id-branching.test.ts: the comparison shape accepts any left-hand identifier (const m = this._runMode; m === 'codex' was invisible), normalized to `mode`; the two `m !== 'shell'` display filters are allowlisted and the remaining blind spots documented
- test/run-mode-dispatch.test.ts: table-driven pin of run() dispatch (claude to runClaude, each RUN_MODE_LAUNCH id to _runCliMode(id), shell to runShell, unknown to runClaude, lock held and released)
- CLAUDE.md: name the second CI-gated guard next to the backend one
- server.ts: every </head> injection passes a replacer function; a clis.json label containing $' re-injected the rest of the document past escapeScriptJson (two render tests pin it, proven failing on the string form)
- _isAltCliMode(): no reference anywhere in the tree, nothing to fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1ea363ff808a62861559bc141e724b163cc1c56e)
2026-09-21 04:37:45 +02:00
Codeman maintainer d3f2ec0220 fix(custom-model): merge-time fixes for the promoted-model picker (#459)
The maintainer's promised follow-ups to opticon454's picker promotion,
applied on the landing branch after the merge (ecb95b5d):

- session-ui.js: the promotion tag ("Currently loaded" / "Last used") and
  the "Default" pill are two separate spans, so a promoted row that is
  also the endpoint's defaultModelId shows both instead of silently
  losing its Default marking; two tests pin it (both fail on the old
  exclusive-slot rendering).
- styles.css: a dedicated #customModelPickModal .set-scope rule, since
  the pill was only styled inside the three settings modals and rendered
  as plain body text here; same skin tokens, modal layout untouched.
- docs/wiki/Custom-Model-Endpoints.md: describe the promotion (currently
  loaded, else last used per device), the separate Default pill, and
  that nothing is ever auto-chosen.
- CLAUDE.md + docs/custom-model-endpoints.md: credit the real "Last used"
  writers (_runCustomModelEntryViaRestart and
  _quickStartWithCustomModelConfirm; runCustomModelEntry only dispatches
  since 88e5b7b2) and drop the now-wrong "both defer to Default" sentence.
- Not done: moving the one-shot "last used" write into
  _runCustomModelEntryOneShot, because the existing one-shot tests assert
  that _quickStartWithCustomModelConfirm writes the key itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0cb0f911adc14a852ba5c2951768a4aa87c25657)
2026-09-21 04:30:01 +02:00
Codeman maintainer 6ef71ec3b9 chore: thanks for 1.32.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:28:22 +02:00
Codeman maintainer d47f93abdb chore: changesets for #453, #444, #459 and #458
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:27:17 +02:00
Codeman maintainer dcf9437308 Merge pull request #460 from Ark0N/feat/installer-v2
feat(install): three questions up front, an unattended build, and a URL you can scan
2026-09-21 04:23:15 +02:00
Codeman maintainer aa13af1f7f Merge pull request #444 from DodgyBadger/feat/mobile-prompt-composer
feat(mobile): add manual prompt composer
2026-09-21 04:23:15 +02:00
Codeman maintainer 9a2e14a93a Merge pull request #453 from timkjr/feat/split-pane-sessions
feat: split-pane sessions — view two live terminals side by side
2026-09-21 04:23:14 +02:00
Codeman maintainer ecb95b5d67 Merge pull request #459 from opticon454/feature/run-menu-picker-currently-loaded-model
feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
2026-09-21 04:23:14 +02:00
Codeman maintainer a7452dc046 Merge pull request #458 from opticon454/followups
feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
2026-09-21 04:23:13 +02:00
Codeman maintainer 72d437ab63 fix(install): fold in both reviews of #460
The two reviews on the PR (DeepSeek Harness, then Claude) found one class of
bug twice and a list of smaller ones; all of them land here, each pinned in
test/install-sh-invariants.test.ts and, where it is bash logic, driven in the
bash:3.2 CI step as well.

The Start line the done screen prints is now composed in one place
(start_command_hint) from every non-default value, the same five the exec
branch exports through export_bind_env, so "do not start" under a sub-path or
a custom port no longer prints a bare `codeman web`. The --lan / --tailscale /
env preset paths read ${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}: a flag re-run on
a unit that carried a password used to rewrite it without the password and
with the unauthenticated ack. --password and --port flip RECONFIGURE so they
reach the unit instead of taking the quiet update path, and `install.sh name`
re-syncs the unit's base URL after the mapping is re-added.

Also: the sudo keepalive is ended before the exec into the foreground server
(exec skips the EXIT trap, and the loop keys on $$); Ctrl+C in the HTTPS-toggle
poll is trapped for the poll only and skips Tailscale for the run instead of
killing the installer; uninstall asks before removing a LaunchDaemon this
installer never wrote; a foreign daemon gets a launchctl kickstart hint and the
done screen stops claiming the new build is running; the preflight summary
reads the Tailscale state with a line grep when node is not installed yet; the
LAN security notice uses the configured port; a bare re-run ends on the done
screen; a build failure after a rename names the install.sh tailscale
recovery; TS_JOINED_HERE (written, never read) is gone; the plan doc and
architecture-invariants say what the code does. A minor changeset is included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 03:03:31 +02:00
Codeman maintainer 1ba0684438 docs(install): describe installer v2 and the Tailscale naming options
README, the Installation / Remote-Access / Running-As-A-Service wiki pages,
docs/security-architecture.md and CLAUDE.md describe the three-question flow,
the flags, the subcommands, the sub-path answer for an occupied :443 and why
the rename is opt-in. docs/installer-v2-plan.md is the design and the
verification record (what was measured, what still needs a fresh machine);
docs/tailscale-installer-plan.md points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:12 +02:00
Codeman maintainer af744bdb54 feat(install): ask three questions up front, then install unattended and end on the URL with a QR code
The installer used to ask about ten things, half of them after a multi-minute
build, and the question that matters most (how do I reach the dashboard) came
last. It now looks at what is on the machine, asks at most three questions
(access, an optional tailnet name, service), and does the rest unattended.

- Every step that needs a human runs before the build: one consent for all
  missing packages, one sudo prompt kept warm for the run, the AI CLI menu,
  and the Tailscale install/login/operator/HTTPS-toggle preflight (the toggle
  is polled and the admin page opened in a browser, instead of "re-check
  now?").
- The build, the service and `tailscale serve` run behind spinners with their
  output in ~/.codeman/install.log; the tail is shown on failure and a failed
  dependency install names its step.
- The done screen leads with the URL (tailnet, network, this machine) and a
  terminal QR code from the qrcode package Codeman already ships.
  `install.sh status` prints it again.
- Tailscale is two halves: tailscale_prepare (question phase) decides the
  serve SHAPE, tailscale_apply (after the build) issues the one serve command.
  When :443 already belongs to another app, Codeman goes under a sub-path
  (serve --set-path /codeman + CODEMAN_BASE_URL in the unit; serve strips the
  prefix, Codeman's ingress tolerates that, --base-url covers the URLs it
  emits) or a second port, instead of replace-or-nothing.
- Renaming the node to codeman-<hostname> is opt-in and defaults to no
  everywhere (the tailnet name is the machine's ssh identity); --name and
  `install.sh name` do it, uninstall offers the old name back. Serve config is
  keyed by the DNS name, so a rename takes our mapping down first and re-adds
  it under the new name.
- Flags pipe through `bash -s --`: --tailscale|--lan|--local, --name|--no-rename,
  --service|--run|--no-start, --yes, --password, --port. --port is now also
  written into the service file.
- npm install runs with CODEMAN_NO_AUTOSTART=1: postinstall otherwise builds
  and starts a detached `codeman web` on 127.0.0.1:3000, which made the
  service crash-loop on EADDRINUSE while the done screen reported "running"
  off the orphan (fresh Ubuntu 24 sandbox).
- The LAN address comes from the default route, not the first interface.
- A foreign /Library/LaunchDaemons/com.codeman.web.plist is left alone
  instead of being replaced by a LaunchAgent.
- The cloudflared question leaves the main flow (`install.sh cloudflared`).
- "Continue WITHOUT a password?" defaults to yes (owner decision).

Tests: the invariants test pins no `serve reset`, no funnel, no Tailscale
Service, every serve mutation through ts_cmd_serve, rename before shape,
flag/header parity, the rename default and the NO_AUTOSTART opt-out; the CI
bash 3.2 step drives the question phase with stubbed tailscale state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:11 +02:00
timkjrandClaude Sonnet 5 46d8b92049 fix(split-pane): port Ctrl+Shift+C's never-falls-through guarantee to Pane B
The smart-copy gate only entered its selection-check block behind
hasSelection(), so a selection-less Ctrl+Shift+C skipped straight to
`return true` and ceded the keystroke to the browser's own handling
(e.g. Chrome's Inspect-Element binding) instead of matching Pane A's
"never falls through" contract for that chord.

Verified live in a real browser that this is a UX-parity fix, not an
interrupt-safety one: xterm's evaluateKeyboardEvent never emits PTY
data for a shifted ctrl-letter regardless of any gate (only "_" and
"@" get special-cased), so no accidental 0x03 was ever at risk. The
regression test added here asserts on the dispatched event's
defaultPrevented rather than the absence of a WS frame, since the
frame-count check passes vacuously for this exact key combo whether
or not the gate fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 0b3e086334 fix(split-pane): address Ark0N's fourth pass — PTY-less picker exclusion, hollow chord test, remaining key gates
- buildSplitPickerSessions() now excludes any session with pid === null
  (exited CLI, tripped PTY-exit breaker, a restore that never re-attached).
  Pane B has no equivalent of selectSession()'s auto re-attach POST, so a
  split opened onto one had nothing reading its tmux pane: no terminal
  events ever arrived and Session.write() silently dropped every keystroke
  with no ack either way, while the socket itself reported healthy.
- Fixed the hollow chord regression test: the synthetic keydowns carried no
  keyCode, which is what xterm's evaluateKeyboardEvent switches on to
  produce a data frame at all, so the assertion held regardless of whether
  the gate fired. Adding real keyCodes surfaced a second, real bug in the
  Alt+B case: the event bubbles to app.js's own document-level shortcut
  dispatcher, which really toggles the sidebar and resets the layout
  attribute the gate reads before Pane B's own (later, non-capture) handler
  ever sees it — fixed by driving the app's real settings cache instead of
  only the DOM attribute.
- Ported the two remaining primary-pane gates with real consequences:
  Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching
  terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no
  visible output otherwise), and Shift/Ctrl+Enter now POSTs to
  /api/sessions/:id/send-key for THIS pane's own session instead of
  letting xterm send a bare \r, which used to submit an incomplete prompt
  instead of inserting a newline. Smart-copy Ctrl+C is re-implemented
  against Pane B's own terminal (copying app.copyTerminalSelection() would
  have copied Pane A's selection instead).
- Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md
  to match, and added CLAUDE.md's missing .split-picker-menu z-index entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 fafef0aa00 fix(split-pane): gate app-level chords out of Pane B, address Ark0N's third pass
Pane B had no attachCustomKeyEventHandler of its own, so the document
capture-phase shortcut handler's preventDefault() (which does not stop
xterm) left Ctrl+K/Alt+1/Alt+B ALSO writing their raw byte/escape
sequence into Pane B's live PTY on top of whatever the app action did
to Pane A. Pane B now installs the same registry-aware gates the
primary pane's own attachCustomKeyEventHandler uses. Ctrl+V is left on
xterm's default paste — no image-paste trap to route it to.

Plus the rest of the review's smaller items:
- Narrowing the window past the desktop gate now closes an open split
  instead of leaving it stranded on screen.
- Split is refused while a web tab is active (activeWebviewId), which
  used to open Pane B's socket behind a hidden container.
- Pane B now handles the server's `{t:'r'}` refresh frame via a shared
  _loadBuffer() helper (also used by connect()), instead of ignoring it.
- The divider drag now uses pointer events + setPointerCapture (mirrors
  tab-rail-resize.js), a button!==0 guard, preventDefault, and a
  body.split-pane-resizing cursor/selection lock — a plain mousedown
  drag selected the text under the cursor as it crossed both terminals.
- Pane B's close control and the picker rows are real <button>s now
  (keyboard-reachable), with matching CSS chrome resets.
- Dropped the redundant CodemanBase.base prefix on the buffer fetch
  (the global fetch wrapper already applies it).
- data-preview-order for the Split settings chip moved from a collision
  with Ultracode Agents (both 15/12) to 11.5, matching its real
  position between Multi-monitor and Ultracode Agents in the header;
  widened test/app-settings-structure.test.ts's regex to allow the
  decimal (Number() already parses it fine for the preview sort).
- Added zh-CN i18n entries for the Split button and empty-picker text.
- Dropped the stray unused `vi` import Ark0N flagged as unrelated to
  this feature (vitest's `globals: true` makes it ambient anyway).
- Documented the fix and the deliberate no-cid/seq choice in the
  split-pane-sessions architecture-invariants entry.

Added a real-Chromium regression test asserting Ctrl+K/Alt+1/Alt+B
dispatched at Pane B's own textarea send no `{t:'i'}` frame over its
WebSocket. Full CI gate green (409 files, 7736 tests) plus all 8
split-pane browser tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 3152ec801d docs(split-pane): short CLAUDE.md rule, stale module count, wiki entries, shortcut-handler caveat
CLAUDE.md previously only mentioned split-pane in the load-order list,
with nothing in the Architecture/frontend prose the way every other
feature gets, and its own module count was one stale (34, should have
been bumped to 35 when terminal-split.js was added). Add a short
pointer-style paragraph next to the other terminal features, fix the
count.

docs/wiki/The-Dashboard.md's header button table and
docs/wiki/Settings-Reference.md's header chips list are the two
user-facing surfaces that never mention Split at all; added both, plus
a note that the feature is desktop-only regardless of the setting.

docs/split-pane-sessions-plan.md: recorded the one design note that
isn't a code change — the global capture-phase shortcut handler always
resolves against Pane A, so Ctrl+L/Ctrl+W typed into Pane B affects the
other session. Not fixed for v1, same reasoning as the rest of the
"deliberately plainer" section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 2e3e245cc6 fix(split-pane): throttle the drag, chunk the scrollback, and the rest of Ark0N's second pass
Two majors:
- The divider drag was unthrottled: every mousemove did a full xterm
  reflow on BOTH panes and sent Pane B a {t:'z'} resize frame with no
  unchanged-dimensions skip, fanning out into a `tmux resize-window`
  child plus a SIGWINCH per event — ~50 of each dragging across half a
  wide viewport. SplitTerminalPane.fit() is now split into localFit()
  (reflow only) and fit() (reflow + send); the drag coalesces moves
  into one localFit() per animation frame via requestAnimationFrame,
  and sends the real resize for both panes exactly once, at drag end,
  matching the primary pane's own throttledResize convention.
- Pane B pulled the FULL scrollback unchunked for every session mode,
  writing it in one terminal.write() call. Mirrors the primary pane's
  own mode check (app.js's selectSession): shell sessions get a
  bounded 1MiB ?tail= fetch instead of ?full=1, and the fetched buffer
  is written through a minimal chunked writer (32KB slices, yielding a
  frame between each) instead of one primary-pane chunkedTerminalWrite
  this simpler, independently created/destroyed pane has no equivalent
  of (no session-switch generation counters or live-output gate).

Smaller items from the same review:
- Pane B now follows live appearance changes (applyTerminalSkin,
  applyTerminalFontFamily, applyTerminalFontWeights, setFontSize all
  propagate to it, matching the teammateTerminals pattern) and reads
  the real codeman-font-size/terminalFontFamily/weights/DEFAULT_SCROLLBACK
  settings at construction instead of hardcoding fontSize 14 / scrollback 5000.
- The Pane-B-promotion path now skips selectSession() when
  _closingSessions already owns this delete (the user closing Pane A's
  own tab), matching _onSessionDeleted's own active-session-handoff guard.
- Detaching a session AFTER a split is already open now yields the PTY
  size in _sendResize() too (not just at picker-open time), mirroring
  sendResize's own detachedElsewhere guard.
- .btn-split joins the body.solo-mode hide list, next to .btn-multimonitor.
- The split row was 6px wider than its container (two flex-shrink:0
  50% panes plus a 6px divider): both panes are now flex-shrink 1.
- Pane B's header and the split-picker rows are marked so i18n.js's
  exact-string lookup skips them, matching .session-name elsewhere —
  a session literally named e.g. "Sessions" was translatable on zh-CN.
- The Split button now reflects open/closed state via a `.split-open`
  accent style, aria-pressed, and a title/aria-label that says which
  behaviour the next click gets.
- _splitPane.connect() is no longer an unawaited call with no .catch().
- terminal-split.js's fileoverview pointed at a doc path that was
  renamed away in the previous push; @dependency now credits
  constants.js for CodemanTerminalFont, not terminal-ui.js.
- index.html's Split settings chip no longer reuses data-preview-order
  "12" (already the Ultracode Agents chip's slot in the same "header"
  preview group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 165cfb52d6 fix(split-pane): stop breaking every settings save, finish the desktop gate
Blocker from Ark0N's second PR #453 pass: moving showSplitButton into
settings-ui.js's per-device displayKeys set was only half of making it
per-device. saveAppSettings() still put it in the object PUT to
/api/settings, 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. Strip it
out via the same destructure every other per-device key goes through
(`showSplitButton: _ssp,`), drop the stray mention from a schemas.ts
comment (a mention there reads as "this is a real field" to the next
grep), and add a static guard test mirroring
test/terminal-auto-copy.test.ts's three-way rule.

Also finishes the desktop gate the first pass only did in CSS at
599px: SPLIT_PANE_MIN_WIDTH (1180, matching HOME_SESSIONS_MIN_WIDTH)
now backs an actual JS width check in _applySplitButtonVisibility,
with a matchMedia listener so a live window resize hides/shows the
button without a reload — the CSS backstop in styles.css is the
reverse-direction guarantee for when JS hasn't run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 6c8bd6c606 fix(split-pane): gate the Split button to desktop, make it per-device
Ark0N's PR #453 review: nothing gated this feature to desktop even
though the design called for it (two 240px min-width panes plus the
divider need ~486px, and the divider has no touch handlers), and
showSplitButton was a SYNCED setting, so turning it on at a desk also
put the button in the phone header.

- Hard-hide .btn-split on phones in mobile.css regardless of the
  setting, matching the other desktop-oriented header buttons in the
  same @media (max-width: 599px) block.
- Move showSplitButton into settings-ui.js's per-device displayKeys
  set and drop it from SettingsUpdateSchema entirely, matching the
  showFileViewerButton/skin precedent (CLAUDE.md's "per-device keys
  ... must NOT be added to SettingsUpdateSchema" rule) — a desktop
  opt-in must never sync onto a phone that never asked for it. Removes
  the now-invalid server-round-trip test for the setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 8d3bde5469 fix(split-pane): address the rest of Ark0N's PR #453 review
- Exclude popped-out (detached) sessions from the split picker:
  SplitTerminalPane._sendResize() has no yield-to-detached-window check
  the way the primary pane's sendResize() does, so splitting against a
  detached session put its own window and Pane B in a fight over the
  same PTY's dimensions. Simplest fix per the review: keep them out of
  buildSplitPickerSessions() entirely.
- Show a visible dead state when Pane B's WebSocket drops. onData
  already silently discards keystrokes while the socket isn't OPEN
  (there is no reconnect for v1), so a dropped socket left the pane
  looking normal while it quietly ate everything typed into it.
- openSplitPane() returns early with no active session, so a split
  triggered from the home screen no longer creates and connects Pane B
  behind the opaque welcome overlay with nothing to show for it.
- onMove() during a divider drag now bails when the split has
  auto-collapsed mid-drag (the other pane's session ending) instead of
  throwing on `divider.parentElement` being null.
- Promote Pane B via `selectSession(id, { auto: true })` when Pane A's
  session ends — this is an app-driven selection, not the user clicking
  a tab, so it must not spend the promoted session's idle alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 78dcb0aa24 fix(split-pane): refit Pane B when the window/sidebar/tab-rail resizes
Ark0N's PR #453 review: fit() was only ever called from the divider
drag, and the trailing-edge ResizeObserver callback in terminal-ui.js
(throttledResize) only ever measured Pane A's own container. Split at
a wide viewport, shrink the window (or toggle the Alt+B sidebar, or
drag the tab rail), and Pane A's cols changed while Pane B silently
kept its stale PTY size in both xterm and the real pane.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 7fc66e8161 docs(split-pane): keep the design spec, drop the task-plan scaffolding
Per Ark0N's review on PR #453: rename the design spec to
docs/split-pane-sessions-plan.md, matching every other feature's
*-plan.md convention, and drop the 957-line implementation task plan
(docs/superpowers/plans/2026-09-15-split-pane-sessions.md) — workflow
scaffolding for the subagent-driven-development run, not repo
documentation. Fixes the now-dangling link in architecture-invariants.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 1678386f50 test(split-pane): cover the blank-Pane-B and stale-width-Pane-A fixes
Real-browser regression coverage for the previous commit:

- SplitTerminalPane connects onto an already-quiet session and shows its
  existing scrollback with no new output, proving the ?full=1 fetch (not
  a live echo) populated the pane.
- openSplitPane() force-resizes Pane A synchronously as part of opening
  a split.
- Dragging the divider force-resizes Pane A once, at drag end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 3859506f9b fix(split-pane): populate Pane B history and force-resize Pane A on split changes
Pane B's SplitTerminalPane.connect() only opened a WebSocket and waited for
live output — ws-routes.ts's terminal socket sends nothing on connect, only
future 'terminal' events — so it stayed blank until the target session
happened to produce new output. It looked intermittent rather than
always-broken because a resize sent by _sendResize() often nudges the
session's real tmux window to a new size, and tmux repaints its current
screen on resize; that incidental repaint was what usually populated the
pane. When Pane B's computed dimensions already matched the session's
last-known size, Session.resize() skipped the resize as a no-op and the
pane stayed empty. Fetch the existing scrollback (?full=1) before opening
the socket, same as the primary pane does.

Pane A never told its own session's PTY/tmux about a size change at all,
relying purely on the passive 300ms-debounced ResizeObserver in
terminal-ui.js. openSplitPane() now force-resizes Pane A immediately on
entering split (mirroring closeSplitPane()'s existing symmetric call), and
the divider-drag handler force-resizes it once at drag end (matching the
codebase's established trailing-edge debounce convention rather than
flooding a resize per mousemove).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 33b2605815 fix(split-pane): stop leaking document listeners on repeated split-picker toggles
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 d0a887d98a test(split-pane): add fast unit coverage for the _onSessionDeleted auto-collapse ordering
test/split-pane-auto-collapse.browser.test.ts covers "Pane B's session ends"
in a real Chromium, but that suite is excluded from the npm test CI gate.
The "Pane A's session ends, Pane B gets promoted" branch had no coverage
anywhere, and it is the one branch whose correctness depends on exact
ordering: _splitSessionId must be captured BEFORE closeSplitPane() runs
(which nulls it) or the promoted session id is lost. Loads terminal-split.js
via `vm` against a minimal fake CodemanApp (same technique as
test/session-close-fallback.test.ts), and pins all three branches (Pane A
ends, Pane B ends, unrelated session ends) plus that the original
_onSessionDeleted always still fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 24a92c8f3e fix(split-pane): refuse to split a session against itself
Nothing stopped a stale picker click (opened before switching tabs) or
clicking Pane B's own session tab while split from landing on
openSplitPane(sessionId) with sessionId === activeSessionId, or from
selectSession() rebinding the primary pane onto the session Pane B was
already showing — either way, two live WebSockets to one session, each
independently claiming PTY dimensions via its own {t:'z',...} resize frame.
openSplitPane() now refuses early when the target is already the active
session, and a new selectSession() prototype patch (same top-level pattern
as the existing _onSessionDeleted patch) closes an active split BEFORE the
primary pane rebinds to the session Pane B holds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f7852081b7 docs(split-pane): fix orphaned Session list layout section
The new "Split-pane sessions" section was inserted between the "Session
list layout (header strip vs. left sidebar)" heading and that section's own
body paragraphs, orphaning the heading from its content. Move "Split-pane
sessions" to after the Session list layout section's full body, before
"Gesture control: the setting" — no change to the Session list layout prose
itself, only where the new section sits relative to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f0e24d8ce2 fix(split-pane): hide the split container together with the rest of the terminal on a web tab
.main.webview-active hid .terminal-wrap when a web tab became active, but
.terminal-wrap is reparented INSIDE .terminal-split-container while a split
is open, so Pane B and the divider stayed stranded on screen over the
dashboard iframe. Hide the whole split container as one unit, mirroring the
existing .terminal-wrap rule; no state is destroyed, so returning to the
session tab shows the split intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 2724c922ce fix(split-pane): style and dismiss the split-picker menu
.split-picker-menu/-item/-empty (created in openSplitPicker()) had zero CSS
and could not be dismissed except by picking an item — a default-path defect
since the Split button ships enabled to anyone who flips showSplitButton on.
Add CSS matching the sibling .run-mode-menu popover's look (floating-bg
backdrop blur, border, shadow, z-index 1000 above the header's 100), and
dismiss on outside click or Escape via the same one-shot listener pattern
session-ui.js already uses for its other transient popovers
(toggleCaseSettings(), toggleRunModeMenu()). Picking an item now routes
through the same _dismissSplitPicker() method as the outside-click/Escape
handlers, so the listeners never outlive the menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 57406f6c14 fix(split-pane): stop clamping Pane B's resize dimensions to a 40x10 floor
_sendResize() clamped Pane B's proposed cols/rows to a 40/10 floor before
sending the {t:'z',...} resize frame, so the PTY was misinformed of Pane B's
real width at the divider's own reachable 20% position, causing real
output-wrapping bugs. The primary pane (terminal-ui.js's
getTerminalDimensions()) sends fitAddon.proposeDimensions() unclamped and
lets the server enforce its own valid range ([1,500]/[1,200] in
ws-routes.ts); Pane B now matches that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 8903a72662 fix(split-pane): hide the Split header button when showSplitButton is off
.btn-split--hidden had no matching CSS rule anywhere, so the opt-in Split
header button shipped visible to every user on every viewport regardless of
the setting. Add the `display: none !important` rule alongside its sibling
marker classes (.btn-multimonitor--hidden etc.), plus a static regression
guard (test/split-pane-hidden-button-css.test.ts) that fails if any future
"*--hidden" marker class in index.html is missing a matching CSS rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjr ba7b8b7bef docs: add split-pane sessions architecture-invariants entry 2026-09-20 13:10:26 -05:00
timkjr 8c73128cd6 feat(split-pane): auto-collapse split when either session ends 2026-09-20 13:10:26 -05:00
timkjrandClaude Sonnet 5 2abf328db8 feat(split-pane): add open/close orchestration, picker, and divider drag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 fa8bb13a27 fix(split-pane): reset _wsReady on WS close/error in SplitTerminalPane
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 6f64e557e5 docs(plan): fix session-creation test bug found by Task 4's implementer
Task 4's implementer found two real bugs in this plan's browser-test
helpers: POST /api/sessions nests the id at data.session.id (not
data.id), and mode:'shell' needs a follow-up POST .../shell to actually
spawn a PTY. Fixed in Task 4's own snippet (documentation accuracy —
already fixed in the real committed code) and pre-emptively in Tasks
5/6's createShellSession() helper before either was dispatched, so
neither implementer has to rediscover it independently. Also corrected
the <script> tag snippet to defer, matching the real file's convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 97a1238c85 feat(split-pane): add SplitTerminalPane class for Pane B
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:24 -05:00
timkjr aa0521602d feat(split-pane): add split container/divider/pane-b CSS 2026-09-20 13:10:24 -05:00
timkjr 2bc16d5fd9 feat(split-pane): add showSplitButton setting and header button 2026-09-20 13:10:23 -05:00
timkjr d60a164025 feat(split-pane): add pure divider-clamp and picker-list helpers 2026-09-20 13:10:23 -05:00
timkjrandClaude Sonnet 5 727817410c docs(plan): fix Task 6's SSE handler patch to target the prototype
Monkey-patching the instance's _onSessionDeleted inside a
DOMContentLoaded listener races connectSSE()'s handler-wrapper cache,
which captures the function reference by value on first connect and
never re-reads it. Patching CodemanApp.prototype at module-evaluation
time (synchronous script-tag order) is unraceable: it completes before
any instance exists or connectSSE() ever runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 28d3bd7da8 docs(plan): fix Task 2/4/5/6 tests against real test infrastructure
Preflight scan for SDD execution caught two classes of defect before
dispatch: Task 2's test invented a buildTestApp() helper and response
envelope that don't exist for /api/settings; Tasks 4-6 used
@playwright/test's runner against a test/browser/ directory that
doesn't exist in this codebase. Both corrected against real patterns
found in existing tests (system-routes-settings-partial-put.test.ts,
terminal-copy-shortcut.test.ts, tab-rail-resize.browser.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 d0f9bdd251 docs: fix plan wording and add execution-environment note
Global Constraints previously read as if local-echo/CJK/accessory-bar
were desktop features; they are mobile-only, and split-pane is the
desktop-only side of that equation. Also names the exact spec section
instead of a loose paraphrase, and adds a worktree/branch note so an
executing subagent knows where this plan runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 ff98006471 docs: add split-pane sessions implementation plan
7 tasks: pure divider/picker helpers, showSplitButton header wiring,
split-container CSS, SplitTerminalPane (Pane B's independent xterm+WS),
open/close orchestration with picker and divider drag, auto-collapse on
either session ending, and an architecture-invariants entry.

Also folds in the "detach session" prior art discovered mid-brainstorm
into the spec's architecture section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 5f1be90ae9 docs: fix tab/pane terminology in split-pane spec
The Problem paragraph and the architecture section used "tab" where
"pane" was meant, colliding with the browser's own tab concept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 3da8bb7046 docs: add split-pane sessions design spec
Scopes v1 of an in-app split view (two live session panes side-by-side,
draggable divider) after multi-monitor spanning turned out to solve a
different problem than showing multiple panes at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:20 -05:00
DodgyBadger ac574d6c64 fix(mobile): compact the compose key 2026-09-20 17:31:10 +00:00
DevvynandClaude Sonnet 5 d9e6ebb20a fix(cli-registry): address round-2 review on #458 — count-based allowlist, RUN_MODE_LAUNCH drift guard
Three of Ark0N's four "will take at merge" items, applied instead since
they were straightforward to do properly:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<expression> (fixed last round) closed the line-shift problem
   but opened a new one: every stock id was already allowlisted for
   session-ui.js in the `mode === '<id>'` form, so a BRAND NEW branch
   reusing that exact expression anywhere in the file passed unnoticed.
   Reproduced live (`if (this.mode === 'codex')` injected into
   runOpenCode()) — stayed green under the old version. Each allowlist
   entry now carries the exact count of approved call sites, and a new
   test asserts actual-vs-declared count for every key; a mismatch in
   either direction is real (higher = new unreviewed branch riding in on
   an existing approval, lower = a reviewed site was removed and the
   entry is now stale). Reproduced again against the fix: same injection
   now fails with an exact diagnostic (expected 2, found 3).

2. Added test/run-mode-launch-table-drift.test.ts. RUN_MODE_LAUNCH
   restates four things stock.ts already owns (label, install command,
   supportsCustomModel, the external-mode key set), and they agree today
   with nothing enforcing it. supportsCustomModel is the dangerous one:
   the Run-menu picker's rows come from the server-injected
   window.__codemanCustomModelClis (built from
   capabilities.customModelInjection.kind), so a CLI gaining a real
   injection recipe later would be OFFERED in the picker while
   _runCliMode silently 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 and compares RUN_MODE_LAUNCH
   against STOCK_CLIS on all four axes.

3. Inlined the "Open Question 7 in PR-B2.md" references in the allowlist
   reasons — PR-B2.md is a local planning doc, never part of the
   committed tree, so the reference was dead on arrival for anyone
   reading the repo. Points at the PR #458 review thread instead.

4. Added a sentence to docs/cli-registry.md naming the new frontend guard
   alongside the backend one it mirrors.

Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format/
check:frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 21:37:05 +08:00
DevvynandClaude Sonnet 5 88e5b7b200 fix(custom-model): address Ark0N's PR review — client-side probe timeout, defer "last used" past confirmation, docs, zh-CN
Four things from the maintainer's review on PR #459, all fixed:

1. Bound _getCustomModelCurrentlyLoaded's probe client-side (~800ms via
   Promise.race, on top of — never instead of — the route's own 5s
   server-side timeout). Without it, an asleep/firewalled endpoint behind
   a saved model list left the picker completely invisible for up to 5s
   after the Run menu had already closed, with no spinner or toast.
   `timeoutMs` is an optional param (default 800, real callers never pass
   it) so a test can drive it in milliseconds, same pattern as
   `_watchLlamaSwapLoading`'s own `pollIntervalMs` — this code runs in a
   JSDOM window's own realm, whose setTimeout vi.useFakeTimers() cannot
   patch.

2. "Last used" is now written only once a launch actually applies, never
   on the mere click. It moved out of runCustomModelEntry (unconditional)
   and into each path's own success point: _quickStartWithCustomModelConfirm
   after the final post succeeds, and _runCustomModelEntryViaRestart right
   after the apply's success check. A context-window-warning decline means
   this exact model cannot work with this CLI at all, so the old
   unconditional write would promote, next time the picker opened, the one
   model guaranteed to fail again.

3. Documented the promotion/tag precedence and the new
   codeman:customModelLastUsed:<mode>:<endpointId> localStorage key in both
   CLAUDE.md's Custom Model Endpoint Profiles section and
   docs/custom-model-endpoints.md's Run-menu picker section.

4. Added zh-CN entries for "Currently loaded" and "Last used" in i18n.js,
   next to this modal's existing "Choose a model"/"Custom Endpoints" pair.

New tests: the client-side timeout (endpoint that never answers, one that
answers within the bound, and a rejected-after-timeout probe settling
quietly), and "last used" recording on success vs. NOT recording on either
confirmation's decline, for both the restart and one-shot paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 21:19:42 +08:00
DevvynandClaude Sonnet 5 73607663fd fix(custom-model): guard the picker's async open against a slower, superseded probe
Code review (high effort) on the previous commit found a real race: making
_openCustomModelPickModal async (it now awaits the currently-loaded-model
probe before rendering) meant a second, faster call for a different
endpoint could render first, only for the first call's slower probe to
resolve afterwards and overwrite the modal with the wrong endpoint's model
list — while _pendingCustomModelPick (set synchronously, before either
await) still named the second, correct endpoint. Picking a model in that
state would launch/apply the wrong model on the wrong endpoint.

Fixed with the same mutable-generation-counter guard
_watchLlamaSwapLoading already uses for an identical async-superseded-by-
newer-call shape: every DOM write, including _pendingCustomModelPick
itself, is deferred until after the awaited probe, and a call that finds
its generation already superseded bails out untouched instead of clobbering
whatever a newer call already rendered.

Added a regression test driving two overlapping opens with a controlled
promise so the earlier, slower probe resolves after the later, faster one
renders, asserting the late response is a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:27:07 +08:00
DevvynandClaude Sonnet 5 458ca578e7 feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
Custom Model Endpoint Profiles' "which model" picker (session-ui.js's
_openCustomModelPickModal) always listed models in their raw discovery
order, so on a host with several downloaded GGUFs the user had to
remember (or eyeball the "Default" tag) which one llama-swap actually
had hot before picking — the whole point of the picker being fast is
undone if it makes you think first.

The picker now promotes exactly one model to the top of the list:

- If llama-swap reports a model from this host's own list `ready`
  right now (via the existing GET /api/model-endpoints/:id/running-status
  route), that model is promoted and tagged "Currently loaded" — it's
  what a launch attaches to with zero wait.
- Otherwise, the last model actually launched on this exact
  (harness, endpoint) pair is promoted and tagged "Last used", read
  from a new per-device localStorage key
  (codeman:customModelLastUsed:<mode>:<endpointId>), written by
  runCustomModelEntry on every launch attempt regardless of outcome.
- A plain (non-llama-swap) OpenAI-compatible server, an unreachable
  endpoint, or a loaded-but-not-yet-ready model never promotes
  anything — the rest of the list keeps its discovery order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:20:45 +08:00
DodgyBadger 884713cca5 fix(mobile): retain oversized composer drafts 2026-09-20 07:21:20 +00:00
DodgyBadger a220c28a14 fix(input): count code points when clearing prompts 2026-09-20 07:18:49 +00:00
DodgyBadger 0761de3dae fix(mobile): use raw fallback for composed prompts 2026-09-20 07:18:23 +00:00
DodgyBadger c2eaba990b fix(mobile): release echo passthrough after compose 2026-09-20 07:17:41 +00:00
DodgyBadger e214691429 fix(mobile): preserve composer delivery after replay 2026-09-20 07:16:59 +00:00
DodgyBadger 773b405429 feat(mobile): add manual prompt composer 2026-09-20 07:16:59 +00:00
DevvynandClaude Sonnet 5 2df9355367 fix(cli-registry): address PR B2 review — fix two test guards, drop unused catalogue
Two required fixes from Ark0N's review of #458:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<line>::<expression>. A single inserted line anywhere above an
   entry shifted every subsequent line number, so all 21 entries went stale
   simultaneously and the same 21 branches were reported as "new" — on a
   file six other open PRs also touch. Dropped the line number from the key
   (<file>::<expression>, matching the backend guard's own design), which
   collapses 21 line-keyed entries to 11 or-collapse where the same
   expression recurs at multiple call sites in the same file.

2. test/run-mode-ui.test.ts's terminal-ownership guard scanned method
   bodies via `^ {2}async (run[A-Za-z]*)\(\) \{$`, which matched the 8
   one-line run<Mode>() wrappers PR B2 introduced but not _runCliMode(mode),
   where the real logic (and the actual risk the guard exists to catch) now
   lives. Fixed the regex to `^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$` and
   added _runCliMode to the sanity list. Same-class fix in
   test/opencode-resize.test.ts, which had the identical blind spot via
   runOpenCode.toString().

Both reproduced live before fixing (inserted the same comment line; added
this.terminal.clear() to _runCliMode) to confirm the bug, then confirmed
the fix catches it and the suite stays green otherwise.

Also resolves Open Question 2 by dropping window.__codemanCliCatalog
entirely: nothing consumed it, and a registry DECLARED_FOR_LATER field
costs nothing until read while an unconsumed script tag on every page
render is a different trade. Reverts Phase 1 cleanly — server.ts's
injection, shortBadge back in types.ts's DECLARED_FOR_LATER list and the
pinned guard test, and the three associated render-index-html.test.ts /
server-index-title.test.ts assertions.

Full gate: 405 files / 7717 tests / 0 failures (net unchanged), typecheck/
lint/format clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 02:04:37 +08:00
DevvynandClaude Sonnet 5 cd64b0a3f7 feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
PR #380 (PR B) held back the frontend half of the CLI registry refactor,
explicitly deferring window.__codemanCliCatalog and making session-ui.js /
mobile-overview.js catalogue-driven as "PR B2".

- Inject window.__codemanCliCatalog in renderIndexHtml(), following the
  existing __codemanCustomModelClis pattern (escapeScriptJson-guarded,
  resolved per-request). Reading CliEntry.shortBadge here is what makes it
  genuinely read, so it drops out of types.ts's DECLARED_FOR_LATER list.
- Consolidate session-ui.js's 8 near-duplicate run<Mode>() launch functions
  (opencode/codex/gemini/antigravity/pi/omp/grok/deepseek) into one shared
  _runCliMode() plus a local RUN_MODE_LAUNCH config table. The 8 method
  names stay as thin wrappers (index.html calls them by name; tests assert
  on the name). Also collapses a duplicated 8-way isAltMode/isExternalCli
  OR-chain (same expression, copy-pasted twice in openSessionOptions) into
  one EXTERNAL_CLI_MODES check.
- Add test/frontend-cli-no-id-branching.test.ts, a guard scoped to
  session-ui.js/mobile-overview.js only (not the rest of src/web/public/,
  which stays explicitly out of scope per CLAUDE.md), mirroring the
  backend's own no-id-branching guard.

mobile-overview.js and the wiring of accent/echo/wheelForward/
keyboardAccessory were investigated and deliberately left alone: the first
is already a single, tested, gated table (not duplicated logic); the second
set belongs to terminal-ui.js/keyboard-accessory.js/styles.css, files
outside this PR's mandate.

Verified on a tmux-capable devbox (this sandbox has no tmux): full CI gate
at 405 files / 7717 tests / 0 failures, typecheck clean, 94 targeted tests
covering exact per-CLI wire-body shapes unmodified and passing, and a live
anti-vacuity check on the new guard (injected a real branch, confirmed it
fails, reverted, confirmed green).

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