Merge master into feat/story-mode

Picks up master's frontend dead-code cleanup. The one conflict was the
z-index list in src/web/CLAUDE.md: master dropped removed layers, the branch
added the Story Mode overlay (2400); both kept. The new
check:frontend-dead passes with Story Mode's scripts in it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-13 21:10:36 +01:00
commit 00ba3637d2
30 files changed
+330 -5531

No files matched your search

+1
View File
@@ -77,6 +77,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
| Dead-code report | `npm run knip` (`config/knip.json`; fetched with `npx`, so it needs the network and is not a CI step). It reports nothing on a clean tree: an export, file or dependency it lists is new dead code, or a manual script that needs an `entry`. Tests count as users, and `src/web/public/` is outside it (plain scripts with no imports). A deliberate keep carries `@knipignore` in its JSDoc |
| Frontend dead-code check | `npm run check:frontend-dead` (`scripts/check-frontend-dead-code.mjs`, a few seconds, not a CI step). `src/web/public/` is plain scripts that knip cannot follow, so this is its counterpart there: it fails on a function, method or top-level binding that nothing references (identifiers, strings, `index.html`, the gesture overlay's source, tests) and on a CSS class or id that no markup or script can produce. `--production` also lists what only tests reference. A deliberate keep goes in the script's `ALLOW` map with its reason |
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
| Production logs | `journalctl --user -u codeman-web -f` |
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
+4 -4
View File
@@ -1123,7 +1123,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
**Adding a skin means touching four places, and each one fails silently on its own**: (1) the `html[data-skin="…"]` token block in `styles.css`; (2) the xterm ANSI palette in `CODEMAN_XTERM_THEMES` (terminal-ui.js) — a missing entry falls back to `daylight-blue`, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in `index.html` — a skin missing there is coerced to `daylight-blue` on every reload, which reads as "my theme keeps resetting"; (4) the `<select>` in App Settings → Appearance → Theme. `test/skin-themes.test.ts` is the static four-way guard.
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** terminal-tile.js, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
@@ -1255,7 +1255,7 @@ The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTime
⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range).
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates).
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and every open tile and split pane** (`_forEachTile`: they read their options at construction, exactly like `applyTerminalSkin()` propagates).
⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
@@ -1321,7 +1321,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### PTY and browser terminal geometry
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px Line truncated
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and the tiles own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reaLine truncated
**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes.
@@ -1339,7 +1339,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### Z-index layers
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
## Security layers
+1
View File
@@ -29,6 +29,7 @@
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:frontend-dead": "node scripts/check-frontend-dead-code.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
-1
View File
@@ -157,7 +157,6 @@ console.log('\n[build] content-hash cache busting');
'settings-ui.js',
'panels-ui.js',
'session-ui.js',
'ralph-wizard.js',
'api-client.js',
'subagent-windows.js',
'image-input.js',
-482
View File
@@ -1,482 +0,0 @@
#!/usr/bin/env node
/**
* capture-readme-gifs.mjs
*
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
* server + page.route mocks), drives a scripted timeline in the page, records
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
*
* Scenes:
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
* windows open one by one and stream tool-call activity live (driven
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
*
* Usage: node scripts/capture-readme-gifs.mjs
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
* Requires: ffmpeg
*/
import { chromium } from 'playwright';
import { execSync } from 'child_process';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
PORT,
SESSION_IDS,
STANDARD_SESSIONS,
buildInitPayload,
startStaticServer,
setupRoutes,
injectState,
outPath,
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
} from './capture-readme-screenshots.mjs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const GIF_COLORS = 192;
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
// on flat dark UI, and error-diffusion dither shimmers between frames.
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
execSync(
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
{ stdio: 'inherit' }
);
}
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
// Terminal content visible before the agents spawn
const TERMINAL_PRESPAWN = [
'',
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
'',
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
'',
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
` ${GRY}░${RST} ./src/index.ts`,
` ${GRY}░${RST} ./src/session.ts`,
` ${GRY}░${RST} ./src/web/server.ts`,
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
'',
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
'',
].join('\r\n');
function makeAgent(agentId, description, startedOffsetMs) {
return {
agentId,
sessionId: 'claude-sess-w1-0001',
projectHash: 'abc123',
filePath: `/tmp/${agentId}.jsonl`,
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
lastActivityAt: Date.now(),
status: 'active',
toolCallCount: 0,
entryCount: 0,
fileSize: 4000,
description,
model: 'claude-haiku-4-5-20251001',
modelShort: 'haiku',
totalInputTokens: 0,
totalOutputTokens: 0,
parentSessionId: SESSION_IDS.w1,
};
}
// Timeline events: t (ms from scene start) + kind
// term — write raw data to the session terminal
// discover — register subagent + open + position its floating window
// tool — stream a tool call into an agent window
// msg — stream an assistant message into an agent window
// complete — flip an agent to completed
function buildSubagentTimeline() {
const T = (lines) => lines.join('\r\n') + '\r\n';
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
return [
{
t: 600,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
{
t: 1000,
kind: 'discover',
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
x: 440, y: 45,
},
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
{
t: 2000,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
{
t: 2500,
kind: 'discover',
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
x: 880, y: 45,
},
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
{
t: 3300,
kind: 'term',
data: T([
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
'',
]),
},
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
{
t: 3800,
kind: 'discover',
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
x: 660, y: 400,
},
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
{
t: 4500,
kind: 'term',
data: T([
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
'',
]),
},
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
{
t: 9300,
kind: 'term',
data: T([
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
'',
]),
},
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
{
t: 11600,
kind: 'term',
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
},
];
}
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
async function recordSubagentScene(browser, videoDir) {
console.log('\n1/2 Recording subagent-demo...');
const context = await browser.newContext({
viewport: SUBAGENT_VIEWPORT,
deviceScaleFactor: 1,
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
});
const recStart = Date.now();
const page = await context.newPage();
page.setDefaultTimeout(30000);
// Start with NO subagents — they appear during the recording
const initPayload = buildInitPayload(STANDARD_SESSIONS);
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
await page.evaluate(() => {
try { window.app?.fitAddon?.fit(); } catch {}
window.app?.terminal?.scrollToBottom();
});
await sleep(500);
const timeline = buildSubagentTimeline();
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
const sceneStart = Date.now();
// Run the whole timeline inside the page so events interleave naturally
await page.evaluate((events) => {
const app = window.app;
for (const ev of events) {
setTimeout(() => {
try {
if (ev.kind === 'term') {
app.terminal.write(ev.data);
app.terminal.scrollToBottom();
} else if (ev.kind === 'discover') {
app._onSubagentDiscovered(ev.agent);
app.openSubagentWindow(ev.agent.agentId);
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
setTimeout(() => {
const win = app.subagentWindows.get(ev.agent.agentId);
if (win?.element) {
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
win.element.style.left = `${ev.x}px`;
win.element.style.top = `${ev.y}px`;
}
}, 520);
setTimeout(() => {
const win = app.subagentWindows.get(ev.agent.agentId);
if (win?.element) win.element.style.transition = '';
app.updateConnectionLines();
}, 850);
} else if (ev.kind === 'tool') {
app._onSubagentToolCall({
agentId: ev.agentId,
tool: ev.tool,
input: ev.input,
timestamp: new Date().toISOString(),
});
} else if (ev.kind === 'msg') {
app._onSubagentMessage({
agentId: ev.agentId,
role: 'assistant',
text: ev.text,
timestamp: new Date().toISOString(),
});
} else if (ev.kind === 'complete') {
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
}
} catch (err) {
console.error('timeline event failed', ev, err);
}
}, ev.t);
}
}, timeline);
await sleep(totalMs + 500);
await page.close();
const videoPath = await page.video().path();
await context.close();
return {
videoPath,
ss: (sceneStart - recStart) / 1000 - 0.4,
duration: (totalMs + 400) / 1000,
};
}
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
const TYPE_INTERVAL_MS = 110;
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
const ZEROLAG_TAIL_HOLD = 1800;
const ZEROLAG_HTML = `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
<style>
* { margin: 0; box-sizing: border-box; }
body {
width: 1280px; height: 470px; background: #0a0a0c;
display: flex; align-items: center; justify-content: center; gap: 48px;
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.pane { width: 560px; }
.card {
background: #131316; border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
}
.card-head {
display: flex; align-items: baseline; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
}
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
.sub { font-size: 12.5px; color: #8b8b92; }
.term { padding: 16px 8px 12px 16px; height: 165px; }
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
.tag {
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
}
.tag b { color: #22c55e; font-weight: 600; }
.bad-tag b { color: #ef4444; }
</style>
</head>
<body>
<div class="pane">
<div class="card good">
<div class="card-head">
<span class="dot"></span>
<span class="title">With zerolag-input</span>
<span class="sub">instant local echo</span>
</div>
<div class="term" id="termLeft"></div>
</div>
<div class="tag">keystrokes echo in <b>0 ms</b></div>
</div>
<div class="pane">
<div class="card bad">
<div class="card-head">
<span class="dot"></span>
<span class="title">Without</span>
<span class="sub">server round-trip echo</span>
</div>
<div class="term" id="termRight"></div>
</div>
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
</div>
</body>
</html>`;
async function recordZerolagScene(browser, videoDir) {
console.log('\n2/2 Recording zerolag-demo...');
const context = await browser.newContext({
viewport: ZEROLAG_VIEWPORT,
deviceScaleFactor: 1,
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
});
const recStart = Date.now();
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
await page.waitForFunction(() => typeof Terminal !== 'undefined');
await page.evaluate(() => {
const theme = {
background: '#131316',
foreground: '#e8e8ea',
cursor: '#22c55e',
cursorAccent: '#131316',
};
const mk = (id) => {
const term = new Terminal({
cols: 44,
rows: 5,
fontSize: 20,
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
cursorBlink: true,
cursorStyle: 'block',
theme,
});
term.open(document.getElementById(id));
term.write('\x1b[32m❯\x1b[0m ');
return term;
};
window.termLeft = mk('termLeft');
window.termRight = mk('termRight');
});
await sleep(600);
const sceneStart = Date.now();
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
await page.evaluate(
({ text, interval, flushEvery }) => {
let i = 0;
const remoteQueue = [];
const typer = setInterval(() => {
if (i >= text.length) { clearInterval(typer); return; }
const ch = text[i++];
window.termLeft.write(ch); // local echo: instant
remoteQueue.push(ch); // server echo: waits for the round-trip
}, interval);
const flusher = setInterval(() => {
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
}, flushEvery);
},
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
);
await sleep(totalMs + 400);
await page.close();
const videoPath = await page.video().path();
await context.close();
return {
videoPath,
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
duration: (totalMs + 600) / 1000,
};
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log('='.repeat(60));
console.log('Codeman README GIF Capture');
console.log('='.repeat(60));
const server = await startStaticServer();
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
let browser;
try {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
const sub = await recordSubagentScene(browser, videoDir);
const subGif = outPath('images', 'subagent-demo.gif');
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
console.log(` Saved: ${subGif}`);
const zl = await recordZerolagScene(browser, videoDir);
const zlGif = outPath('images', 'zerolag-demo.gif');
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
console.log(` Saved: ${zlGif}`);
console.log('\nDone.');
} catch (err) {
console.error('\nFatal error:', err.message);
console.error(err.stack);
process.exitCode = 1;
} finally {
if (browser) await browser.close().catch(() => {});
server.close();
rmSync(videoDir, { recursive: true, force: true });
}
}
process.on('SIGINT', () => process.exit(1));
main();
-253
View File
@@ -1,253 +0,0 @@
#!/usr/bin/env node
/**
* capture-readme-real.mjs
*
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
* NOT prod itself (never touch prod's live sessions).
*
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
* - per-device localStorage seeding so the capture matches a real device.
*
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
*/
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
import { join } from 'path';
const BASE = process.env.BASE || 'http://localhost:5000';
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
const SKIN = process.env.SKIN || 'daylight-blue';
const SCENE = process.env.SCENE || 'all';
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
const DSF = Number(process.env.DSF || 2);
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const url = (extra = '') => {
const sep = BASE.includes('?') ? '&' : '?';
const params = [];
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
if (extra) params.push(extra);
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
};
async function newCtx(browser) {
const context = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: DSF,
ignoreHTTPSErrors: BASE.startsWith('https'),
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.addInitScript(
([skin, planUsage, font]) => {
try {
localStorage.setItem('codeman:skin', skin);
localStorage.setItem('codeman-font-size', String(font));
const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: false };
// Don't auto-hide subagent windows that belong to a non-active tab — the
// subagent scene re-homes agents and needs both windows visible at once.
blob.subagentActiveTabOnly = false;
if (planUsage) blob.showPlanUsageLimits = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
} catch {
/* ignore */
}
},
[SKIN, PLAN_USAGE, FONT]
);
return { context, page };
}
async function bootstrap(page) {
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
await sleep(1200);
}
async function listSessions(page) {
return page.evaluate(() =>
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
);
}
async function shoot(page, name) {
const out = join(OUT, name);
await page.evaluate((f) => {
try {
if (window.app.setFontSize) window.app.setFontSize(f);
} catch {}
try {
window.app.fitAddon && window.app.fitAddon.fit();
} catch {}
try {
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
} catch {}
}, FONT);
await sleep(1500);
await page.screenshot({ path: out, fullPage: false });
console.log(' Saved: ' + out);
}
async function sceneDashboard(browser) {
console.log('Scene: dashboard');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
const sessions = await listSessions(page);
// Select a claude session so the active terminal shows rich content; all tabs render.
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
await sleep(4000);
await shoot(page, 'multi-session-dashboard.png');
await context.close();
}
async function sceneMonitor(browser) {
console.log('Scene: monitor');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
const sessions = await listSessions(page);
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
await sleep(2500);
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
// mux sessions (/api/mux), starts stats, and renders the task panel.
await page.evaluate(async () => {
try {
await window.app.toggleMonitorPanel();
} catch {}
});
await sleep(3000);
await shoot(page, 'multi-session-monitor.png');
await context.close();
}
async function sceneSubagent(browser) {
console.log('Scene: subagent');
const { context, page } = await newCtx(browser);
await page.goto(url(), { waitUntil: 'domcontentloaded' });
await bootstrap(page);
// Select the session whose subagents we want (subagentActiveTabOnly means
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
const sessions = await listSessions(page);
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
let agents = [];
for (let i = 0; i < 45; i++) {
agents = await page.evaluate(() =>
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
);
if (agents.length >= 1) break;
await sleep(1000);
}
console.log(' live in-browser subagents:', JSON.stringify(agents));
if (agents.length === 0) {
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
await context.close();
return;
}
// The window body renders from app.subagentActivity, which fills ONLY from live
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
// So sit connected and wait for live activity to accumulate, then open the two
// agents that actually have content (otherwise the windows read "No activity yet").
let active = [];
for (let i = 0; i < 100; i++) {
active = await page.evaluate(() =>
Array.from(window.app.subagentActivity?.entries?.() || [])
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
.map(([id, arr]) => ({ id, n: arr.length }))
.sort((a, b) => b.n - a.n)
);
if (active.length >= 2) break;
// xhigh-effort agents churn in bursts between long thinking pauses, so be
// patient (~150s); accept a single populated window after ~45s if that's all.
if (i >= 30 && active.length >= 1) break;
await sleep(1500);
}
console.log(' agents with live activity:', JSON.stringify(active));
const openIds = (active.length ? active : agents).map((a) => a.id);
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
// Codeman id and never becomes the real Claude conversation UUID, so the window
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
// to the agents' (shared) sessionId so the windows open AND show their live activity.
await page.evaluate(
(ids) => {
const activeId = window.app.activeSessionId;
const tab = window.app.sessions.get(activeId);
ids.slice(0, 2).forEach((id) => {
const a = window.app.subagents.get(id);
if (!a) return;
a.parentSessionId = activeId;
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
});
},
openIds
);
await page.evaluate(
(ids) => {
ids.slice(0, 2).forEach((id) => {
try {
window.app.openSubagentWindow(id);
} catch {}
});
},
openIds
);
await sleep(2000);
await page.evaluate(() => {
// Viewport-relative tiling: center two subagent windows over the terminal so
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
// recipe) instead of overflowing at narrower widths.
const wins = Array.from(window.app.subagentWindows.values());
const W = window.innerWidth;
const H = window.innerHeight;
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
const winH = Math.min(360, Math.floor(H * 0.56));
const top = Math.floor(H * 0.16);
const gap = 16;
const totalW = winW * 2 + gap;
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
wins.slice(0, 2).forEach((win, i) => {
const el = win.element;
// Force visible: a freshly opened window may be hidden by the activeTabOnly
// rule before we override it (we also seed subagentActiveTabOnly:false).
win.hidden = false;
win.minimized = false;
el.style.display = 'flex';
el.style.left = startLeft + i * (winW + gap) + 'px';
el.style.top = top + 'px';
el.style.width = winW + 'px';
el.style.height = winH + 'px';
});
});
await sleep(1500);
await shoot(page, 'subagent-spawn.png');
await context.close();
}
async function main() {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
await browser.close();
}
main().catch((e) => {
console.error('FATAL', e.message);
process.exit(1);
});
File diff suppressed because it is too large. Load diff
-731
View File
@@ -1,731 +0,0 @@
#!/usr/bin/env node
/**
* capture-video-screenshots.mjs
*
* Captures real Codeman UI screenshots for the Remotion demo video.
* Uses Playwright + page.route() mock injection (same pattern as capture-readme-screenshots.mjs).
* No real Claude CLI or server needed — all API responses are mocked.
*
* Usage: node scripts/capture-video-screenshots.mjs
* Port: 3198 (static file server)
* Output: scripts/scripts/remotion/public/ (6 PNGs)
*/
import { chromium } from 'playwright';
import { createServer } from 'http';
import { readFileSync, existsSync } from 'fs';
import { join, extname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
const PORT = 3198;
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
const MOBILE_VIEWPORT = { width: 390, height: 844 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ─── MIME Types ──────────────────────────────────────────────────────────────
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
};
// ─── Static File Server ──────────────────────────────────────────────────────
function startStaticServer() {
return new Promise((resolve) => {
const server = createServer((req, res) => {
let urlPath = req.url.split('?')[0];
if (urlPath === '/') urlPath = '/index.html';
const filePath = join(PUBLIC_DIR, urlPath);
if (!existsSync(filePath)) {
res.writeHead(404);
res.end('Not Found');
return;
}
try {
const data = readFileSync(filePath);
const ext = extname(filePath);
res.writeHead(200, {
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache',
});
res.end(data);
} catch {
res.writeHead(500);
res.end('Internal Server Error');
}
});
server.listen(PORT, () => {
console.log(`Static server on http://localhost:${PORT}`);
resolve(server);
});
});
}
// ─── Mock Data ───────────────────────────────────────────────────────────────
function makeSession(id, name, mode, status, extra = {}) {
return {
id,
pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000),
status,
workingDir: '/home/user/my-project',
currentTaskId: null,
createdAt: Date.now() - 3600000,
lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000),
name,
mode,
autoClearEnabled: false,
autoClearThreshold: 140000,
autoCompactEnabled: false,
autoCompactThreshold: 110000,
autoCompactPrompt: '',
imageWatcherEnabled: false,
totalCost: mode === 'claude' ? 0.12 : 0,
inputTokens: mode === 'claude' ? 18000 : 0,
outputTokens: mode === 'claude' ? 11500 : 0,
ralphEnabled: false,
niceEnabled: false,
niceValue: 10,
color: 'default',
flickerFilterEnabled: false,
cliVersion: '2.1.61',
cliModel: mode === 'claude' ? 'Opus 4.6' : 'GPT-4o',
cliAccountType: mode === 'claude' ? 'Claude Max' : 'API Key',
cliLatestVersion: '2.1.61',
messageCount: 15,
isWorking: status === 'busy',
lastPromptTime: Date.now() - 30000,
bufferStats: {
terminalBufferSize: 4096,
textOutputSize: 2048,
messageCount: 15,
maxTerminalBuffer: 2097152,
maxTextOutput: 1048576,
maxMessages: 1000,
},
taskStats: { total: 0, running: 0, completed: 0, failed: 0 },
taskTree: [],
tokens: {
input: mode === 'claude' ? 18000 : 0,
output: mode === 'claude' ? 11500 : 0,
total: mode === 'claude' ? 29500 : 0,
},
autoClear: { enabled: false, threshold: 140000 },
nice: { enabled: false, niceValue: 10 },
ralphLoop: null,
ralphTodos: [],
ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 },
respawnEnabled: false,
respawnConfig: null,
respawn: null,
claudeSessionId: `claude-${id}`,
...extra,
};
}
function buildInitPayload(sessions) {
const respawnStatus = {};
for (const s of sessions) {
respawnStatus[s.id] = {
state: 'idle',
cycleCount: 0,
lastActivityTime: Date.now(),
timeSinceActivity: 0,
promptDetected: false,
workingDetected: false,
detection: {},
config: null,
};
}
return {
version: '0.1651',
sessions,
scheduledRuns: [],
respawnStatus,
globalStats: {
totalInputTokens: 145000,
totalOutputTokens: 87000,
totalCost: 1.82,
totalSessionsCreated: 12,
firstRecordedAt: Date.now() - 86400000,
lastUpdatedAt: Date.now(),
},
subagents: [],
timestamp: Date.now(),
};
}
// ─── Terminal Content (ANSI) ─────────────────────────────────────────────────
const RST = '\x1b[0m';
const RED = '\x1b[31m';
const GRN = '\x1b[32m';
const YEL = '\x1b[33m';
const MAG = '\x1b[35m';
const GRY = '\x1b[90m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
// Claude Code init banner
const TERMINAL_CLAUDE = [
'',
` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.61`,
` ${RED}████${RST} ${GRY}Opus 4.6${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`,
` ${RED}██${RST} ${RED}██${RST} ${GRY}~/my-project${RST}`,
'',
'',
`${GRY}❯ Try "how do I log an error?"${RST}`,
'',
'',
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
` ${BOLD}0 tokens${RST}`,
` ${GRY}current: 2.1.61${RST} ${DIM}·${RST} ${GRY}latest: 2.1.61${RST}`,
].join('\r\n');
// OpenCode init banner
const TERMINAL_OPENCODE = [
'',
` ${GRN}████${RST} ${BOLD}OpenCode${RST} v0.3.12`,
` ${GRN}████${RST} ${GRY}GPT-4o${RST} ${DIM}·${RST} ${GRY}API Key${RST}`,
` ${GRN}██${RST} ${GRN}██${RST} ${GRY}~/my-project${RST}`,
'',
'',
`${GRY}❯ Try "explain this codebase"${RST}`,
'',
'',
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST}`,
` ${BOLD}0 tokens${RST}`,
].join('\r\n');
// ─── Route Interceptors ──────────────────────────────────────────────────────
async function setupRoutes(page, initPayload, terminalContent) {
// Block SSE to prevent reconnection loops
await page.route('**/api/events', async (route) => {
await route.abort();
});
// Terminal buffer endpoint
await page.route('**/api/sessions/*/terminal**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
terminalBuffer: terminalContent,
status: 'busy',
fullSize: terminalContent.length,
truncated: false,
}),
});
});
// Mux sessions
await page.route('**/api/mux-sessions/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
await page.route('**/api/mux-sessions', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ sessions: [], muxAvailable: true }),
});
});
// Settings
await page.route('**/api/settings', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
showSubagents: false,
subagentTrackingEnabled: false,
subagentActiveTabOnly: false,
showMonitor: false,
}),
});
});
// Subagent window states
await page.route('**/api/subagent-window-states', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
// Subagent parents
await page.route('**/api/subagent-parents', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
// Session-specific subagents
await page.route('**/api/sessions/*/subagents', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, data: [] }),
});
});
// Interactive attach
await page.route('**/api/sessions/*/interactive', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
// Resize
await page.route('**/api/sessions/*/resize', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
// Catch-all API
await page.route('**/api/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
}
/**
* Inject mock state and render the app.
*/
async function injectState(page, initPayload, terminalContent, activeSessionId) {
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
await sleep(2000);
// Cancel SSE fallback timer and inject state
await page.evaluate((payload) => {
const app = window.app;
if (app._initFallbackTimer) {
clearTimeout(app._initFallbackTimer);
app._initFallbackTimer = null;
}
app.handleInit(payload);
}, initPayload);
await sleep(3000);
// Select the target session
if (activeSessionId) {
await page.evaluate((sid) => {
window.app.activeSessionId = null;
window.app.selectSession(sid);
}, activeSessionId);
await sleep(4000);
}
// Write terminal content directly as backup
if (terminalContent && activeSessionId) {
await page.evaluate((content) => {
const app = window.app;
if (app.terminal) {
const buf = app.terminal.buffer?.active;
const hasContent = buf && buf.length > 2 && buf.getLine(1)?.translateToString().trim();
if (!hasContent) {
app.terminal.clear();
app.terminal.reset();
app.terminal.write(content);
app.terminal.scrollToBottom();
}
}
}, terminalContent);
await sleep(3000);
}
// Clean up UI artifacts
await page.evaluate(() => {
const app = window.app;
if (app) {
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
app._connectionStatus = 'connected';
app._updateConnectionIndicator = () => {};
app.connectSSE = () => {};
app.setConnectionStatus = () => {};
}
// Remove connection indicator
const indicator = document.getElementById('connectionIndicator');
if (indicator) indicator.remove();
// Hide respawn banner
const respawnBanner = document.getElementById('respawnBanner');
if (respawnBanner) respawnBanner.style.display = 'none';
// Mock CPU/MEM stats
const statsEl = document.getElementById('headerSystemStats');
if (statsEl) {
statsEl.innerHTML = `
<span class="stat-label">CPU</span>
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
<span class="stat-value">18%</span>
<span class="stat-label">MEM</span>
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
<span class="stat-value">13.7G</span>
`;
}
// Hide subagents panel entirely
const subagentsPanel = document.getElementById('subagentsPanel');
if (subagentsPanel) subagentsPanel.style.display = 'none';
// Close and hide monitor panel
const monitorPanel = document.getElementById('monitorPanel');
if (monitorPanel) {
monitorPanel.classList.remove('open');
monitorPanel.style.display = 'none';
}
// Hide monitor toggle button in toolbar
const monitorBtn = document.getElementById('monitorToggle');
if (monitorBtn) monitorBtn.style.display = 'none';
// Update token display
if (app && app.activeSessionId) {
const session = app.sessions.get(app.activeSessionId);
if (session) {
const tokens = (session.tokens?.total || 0);
const tokenEl = document.getElementById('headerTokens');
if (tokenEl) {
const formatted = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
tokenEl.innerHTML = `<span class="token-icon">⊙</span> <span class="token-count">${formatted} tokens</span>`;
}
}
}
});
// Final settle — let xterm.js WebGL renderer, fonts, and layout fully stabilize
await sleep(4000);
}
// ─── Screenshot Scenarios ────────────────────────────────────────────────────
// 1. Desktop welcome — no sessions, welcome overlay visible
async function captureDesktopWelcome(browser) {
console.log('\n1/6 Capturing desktop-welcome.png...');
const context = await browser.newContext({
viewport: DESKTOP_VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
const initPayload = buildInitPayload([]);
await setupRoutes(page, initPayload, '');
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
await sleep(2000);
// Inject empty state — welcome overlay should show automatically
await page.evaluate((payload) => {
const app = window.app;
if (app._initFallbackTimer) {
clearTimeout(app._initFallbackTimer);
app._initFallbackTimer = null;
}
app.handleInit(payload);
}, initPayload);
await sleep(3000);
// Clean up UI
await page.evaluate(() => {
const app = window.app;
if (app) {
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
app._connectionStatus = 'connected';
app._updateConnectionIndicator = () => {};
app.connectSSE = () => {};
app.setConnectionStatus = () => {};
}
const indicator = document.getElementById('connectionIndicator');
if (indicator) indicator.remove();
const respawnBanner = document.getElementById('respawnBanner');
if (respawnBanner) respawnBanner.style.display = 'none';
// Mock stats
const statsEl = document.getElementById('headerSystemStats');
if (statsEl) {
statsEl.innerHTML = `
<span class="stat-label">CPU</span>
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
<span class="stat-value">18%</span>
<span class="stat-label">MEM</span>
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
<span class="stat-value">13.7G</span>
`;
}
// Hide monitor and subagent panels
const subagentsPanel = document.getElementById('subagentsPanel');
if (subagentsPanel) subagentsPanel.style.display = 'none';
const monitorPanel = document.getElementById('monitorPanel');
if (monitorPanel) {
monitorPanel.classList.remove('open');
monitorPanel.style.display = 'none';
}
const monitorBtn = document.getElementById('monitorToggle');
if (monitorBtn) monitorBtn.style.display = 'none';
});
await sleep(3000);
await page.screenshot({
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
} finally {
await context.close();
}
}
// 2. Desktop with single Claude tab active
async function captureDesktopClaude(browser) {
console.log('\n2/6 Capturing desktop-claude.png...');
const claudeSession = makeSession('sess-claude-1', 'w1-my-project', 'claude', 'busy');
const initPayload = buildInitPayload([claudeSession]);
const context = await browser.newContext({
viewport: DESKTOP_VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-1');
await page.screenshot({
path: join(OUTPUT_DIR, 'desktop-claude.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
} finally {
await context.close();
}
}
// 3. Desktop with 2 tabs (Claude + OpenCode), Claude active
async function captureDesktopBothClaude(browser) {
console.log('\n3/6 Capturing desktop-both-claude.png...');
const claudeSession = makeSession('sess-claude-2', 'w1-my-project', 'claude', 'busy');
const opencodeSession = makeSession('sess-opencode-2', 'w2-my-project', 'opencode', 'busy');
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
const context = await browser.newContext({
viewport: DESKTOP_VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-2');
await page.screenshot({
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
} finally {
await context.close();
}
}
// 4. Desktop with 2 tabs (Claude + OpenCode), OpenCode active
async function captureDesktopBothOpencode(browser) {
console.log('\n4/6 Capturing desktop-both-opencode.png...');
const claudeSession = makeSession('sess-claude-3', 'w1-my-project', 'claude', 'busy');
const opencodeSession = makeSession('sess-opencode-3', 'w2-my-project', 'opencode', 'busy');
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
const context = await browser.newContext({
viewport: DESKTOP_VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-3');
await page.screenshot({
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
} finally {
await context.close();
}
}
// 5. Mobile with Claude tab
async function captureMobileClaude(browser) {
console.log('\n5/6 Capturing mobile-claude.png...');
const claudeSession = makeSession('sess-claude-m1', 'w1-my-project', 'claude', 'busy');
const initPayload = buildInitPayload([claudeSession]);
const context = await browser.newContext({
viewport: MOBILE_VIEWPORT,
deviceScaleFactor: 2,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-m1');
await page.screenshot({
path: join(OUTPUT_DIR, 'mobile-claude.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
} finally {
await context.close();
}
}
// 6. Mobile with OpenCode tab
async function captureMobileOpencode(browser) {
console.log('\n6/6 Capturing mobile-opencode.png...');
const opencodeSession = makeSession('sess-opencode-m1', 'w1-my-project', 'opencode', 'busy');
const initPayload = buildInitPayload([opencodeSession]);
const context = await browser.newContext({
viewport: MOBILE_VIEWPORT,
deviceScaleFactor: 2,
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
try {
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-m1');
await page.screenshot({
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
fullPage: false,
});
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
} finally {
await context.close();
}
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log('='.repeat(60));
console.log('Codeman Video Screenshot Capture');
console.log('='.repeat(60));
console.log(`Port: ${PORT}`);
console.log(`Desktop: ${DESKTOP_VIEWPORT.width}x${DESKTOP_VIEWPORT.height}`);
console.log(`Mobile: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height} @2x`);
console.log(`Output: ${OUTPUT_DIR}`);
console.log('');
const server = await startStaticServer();
let browser;
try {
browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
await captureDesktopWelcome(browser);
await captureDesktopClaude(browser);
await captureDesktopBothClaude(browser);
await captureDesktopBothOpencode(browser);
await captureMobileClaude(browser);
await captureMobileOpencode(browser);
console.log('\n' + '='.repeat(60));
console.log('All 6 screenshots captured!');
console.log('='.repeat(60));
console.log('\nOutput files:');
console.log(' scripts/remotion/public/desktop-welcome.png');
console.log(' scripts/remotion/public/desktop-claude.png');
console.log(' scripts/remotion/public/desktop-both-claude.png');
console.log(' scripts/remotion/public/desktop-both-opencode.png');
console.log(' scripts/remotion/public/mobile-claude.png');
console.log(' scripts/remotion/public/mobile-opencode.png');
} catch (err) {
console.error('\nFatal error:', err.message);
console.error(err.stack);
process.exitCode = 1;
} finally {
if (browser) await browser.close().catch(() => {});
server.close();
console.log('\nDone.');
}
}
process.on('SIGINT', () => {
console.log('\nInterrupted.');
process.exit(1);
});
main();
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
/**
* Frontend dead-code check.
*
* `src/web/public/*.js` is loaded as plain <script> files: no imports, so knip cannot
* follow it, and it is outside ESLint. A method that lost its last caller therefore
* stays in the tree unnoticed (the November 2026 cleanup removed about 2,000 such
* lines of JS and 1,500 lines of CSS). This script finds them.
*
* JavaScript: every function, method and top-level binding the frontend files
* define, checked against every identifier and every string or template literal in
* those files, `index.html` (inline handlers), the gesture overlay's source and the
* tests. A name that appears nowhere but at its own definition is reported.
*
* CSS: every class and id selector in `styles.css` and `mobile.css`, checked against
* the same text. A class built at runtime (`tab-state-${state}`) is recognised by its
* prefix and skipped, so the report holds only names no code can produce.
*
* Text matching is deliberately conservative: a mention in a comment does not count,
* a mention in any string does. So a clean run does not prove everything is used,
* but a reported name really has no reference. To keep one on purpose, add it to
* ALLOW below with the reason.
*
* Usage: node scripts/check-frontend-dead-code.mjs [--production]
* --production also report what only tests reference (informational, exit 0)
*
* Parsed with the TypeScript compiler (already a dev dependency), so this needs no
* package of its own.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
const require = createRequire(join(ROOT, 'package.json'));
const ts = require('typescript');
const postcss = require('postcss');
/**
* Names kept on purpose although nothing references them. Key: the name as reported
* (a JS name, or a selector with its `.` or `#`). Value: why it stays.
*/
const ALLOW = new Map([
// ['exampleMethod', 'called from the browser console when debugging X'],
]);
/** Class prefixes that belong to vendored code, which the scan does not read. */
const EXTERNAL_CLASS_PREFIXES = ['xterm'];
const productionMode = process.argv.includes('--production');
function walk(dir, filter, skipDirs = []) {
const out = [];
if (!existsSync(dir)) return out;
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || skipDirs.includes(entry)) continue;
const path = join(dir, entry);
if (statSync(path).isDirectory()) out.push(...walk(path, filter, skipDirs));
else if (filter.test(entry)) out.push(path);
}
return out;
}
const rel = (path) => relative(ROOT, path);
const read = (path) => readFileSync(path, 'utf8');
// ── What counts as a reference ────────────────────────────────────────────────
// Shipped scripts. The vendored bundles and the generated gesture bundle are not ours
// to judge; the gesture overlay is read from its source instead.
const frontendFiles = walk(PUBLIC_DIR, /\.js$/, ['vendor', 'gesture', 'fonts']);
const htmlText = walk(PUBLIC_DIR, /\.html$/, ['vendor', 'gesture']).map(read).join('\n');
const gestureText = walk(join(ROOT, 'packages', 'gesture-control', 'src'), /\.(ts|js)$/)
.map(read)
.join('\n');
const serverText = walk(join(ROOT, 'src'), /\.ts$/, ['public']).map(read).join('\n');
const overlayText = walk(join(ROOT, 'packages', 'xterm-zerolag-input', 'src'), /\.(ts|js)$/)
.map(read)
.join('\n');
const testText = walk(join(ROOT, 'test'), /\.(ts|mjs|js)$/).map(read).join('\n');
const identifierCount = new Map();
const literalParts = [];
const definitions = [];
function bump(name) {
identifierCount.set(name, (identifierCount.get(name) ?? 0) + 1);
}
function isFunctionLike(node) {
return !!node && (ts.isFunctionExpression(node) || ts.isArrowFunction(node));
}
function propertyName(name) {
if (!name) return null;
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
return null;
}
/** Top-level statements, looking one level into an IIFE wrapper. */
function topLevelStatements(sourceFile) {
const out = [...sourceFile.statements];
for (const statement of sourceFile.statements) {
if (!ts.isExpressionStatement(statement)) continue;
let expr = statement.expression;
while (ts.isParenthesizedExpression(expr) || ts.isPrefixUnaryExpression(expr)) {
expr = ts.isParenthesizedExpression(expr) ? expr.expression : expr.operand;
}
if (!ts.isCallExpression(expr)) continue;
let callee = expr.expression;
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
if (ts.isPropertyAccessExpression(callee)) callee = callee.expression;
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
if ((ts.isFunctionExpression(callee) || ts.isArrowFunction(callee)) && ts.isBlock(callee.body)) {
out.push(...callee.body.statements);
}
}
return out;
}
for (const file of frontendFiles) {
const text = read(file);
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
const define = (name, node, kind) => {
if (name) definitions.push({ name, file: rel(file), line: lineOf(node), kind });
};
for (const statement of topLevelStatements(sourceFile)) {
if (ts.isVariableStatement(statement)) {
for (const decl of statement.declarationList.declarations) {
if (ts.isIdentifier(decl.name)) define(decl.name.text, decl, 'binding');
}
} else if (ts.isClassDeclaration(statement) && statement.name) {
define(statement.name.text, statement, 'binding');
}
}
const visit = (node) => {
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) bump(node.text);
else if (
ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
node.kind === ts.SyntaxKind.TemplateHead ||
node.kind === ts.SyntaxKind.TemplateMiddle ||
node.kind === ts.SyntaxKind.TemplateTail
) {
literalParts.push(node.text);
}
if (ts.isFunctionDeclaration(node) && node.name) define(node.name.text, node, 'function');
else if (ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
define(propertyName(node.name), node, 'method');
} else if (ts.isPropertyAssignment(node) && isFunctionLike(node.initializer)) {
define(propertyName(node.name), node, 'method');
} else if (ts.isPropertyDeclaration(node) && isFunctionLike(node.initializer)) {
define(propertyName(node.name), node, 'method');
} else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isFunctionLike(node.initializer)) {
define(node.name.text, node, 'function');
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
const literalText = literalParts.join('\n');
const wordPattern = (name) => new RegExp(`(?<![\\w$])${name.replace(/[$]/g, '\\$&')}(?![\\w$])`, 'g');
const count = (text, pattern) => (text.match(pattern) ?? []).length;
// ── JavaScript ────────────────────────────────────────────────────────────────
const byName = new Map();
for (const def of definitions) {
// A binding and a function for the same declaration are one definition.
const list = byName.get(def.name) ?? [];
if (!list.some((d) => d.file === def.file && d.line === def.line)) list.push(def);
byName.set(def.name, list);
}
const deadJs = [];
const testOnlyJs = [];
for (const [name, defs] of byName) {
if (name.length < 3 || name === 'constructor' || ALLOW.has(name)) continue;
const pattern = wordPattern(name);
const shipped =
(identifierCount.get(name) ?? 0) -
defs.length +
count(literalText, pattern) +
count(htmlText, pattern) +
count(gestureText, pattern);
if (shipped > 0) continue;
const inTests = count(testText, pattern);
(inTests > 0 ? testOnlyJs : deadJs).push({ name, defs, inTests });
}
// ── CSS ───────────────────────────────────────────────────────────────────────
const cssCorpus = [
...frontendFiles.map(read),
htmlText,
gestureText,
overlayText,
serverText,
...walk(PUBLIC_DIR, /\.json$/, ['vendor', 'gesture']).map(read),
].join('\n');
const corpusWords = new Set(cssCorpus.match(/[A-Za-z_][\w-]*/g) ?? []);
// `foo-${x}` and 'foo-' + x build a class at runtime: everything with that prefix may exist.
const dynamicPrefixes = new Set();
for (const m of cssCorpus.matchAll(/([A-Za-z_][\w-]*-)(?:\$\{|['"`]\s*\+)/g)) dynamicPrefixes.add(m[1]);
// `${x}-suffix` and x + '-suffix' likewise, when the stem itself is a known word.
const dynamicSuffixes = new Set();
for (const m of cssCorpus.matchAll(/(?:\}|\+\s*['"`])(-[\w-]+)/g)) dynamicSuffixes.add(m[1]);
const deadCss = [];
for (const cssFile of [join(PUBLIC_DIR, 'styles.css'), join(PUBLIC_DIR, 'mobile.css')]) {
const seen = new Map();
postcss.parse(read(cssFile), { from: cssFile }).walkRules((rule) => {
const selector = rule.selector.replace(/\[[^\]]*\]/g, ' ').replace(/"[^"]*"|'[^']*'/g, ' ');
for (const m of selector.matchAll(/([.#])(-?[A-Za-z_][\w-]*)/g)) {
const key = m[1] + m[2];
if (!seen.has(key)) seen.set(key, rule.source.start.line);
}
});
for (const [key, line] of seen) {
const name = key.slice(1);
if (ALLOW.has(key) || corpusWords.has(name)) continue;
if (EXTERNAL_CLASS_PREFIXES.some((prefix) => name === prefix || name.startsWith(prefix + '-'))) continue;
if ([...dynamicPrefixes].some((prefix) => name.startsWith(prefix))) continue;
if ([...dynamicSuffixes].some((suffix) => name.endsWith(suffix) && corpusWords.has(name.slice(0, -suffix.length)))) {
continue;
}
if (deadCss.some((d) => d.key === key)) continue;
deadCss.push({ key, file: rel(cssFile), line });
}
}
// ── Report ────────────────────────────────────────────────────────────────────
const where = (defs) => defs.map((d) => `${d.file}:${d.line}`).join(', ');
const sortByPlace = (a, b) => where(a.defs).localeCompare(where(b.defs), undefined, { numeric: true });
if (productionMode && testOnlyJs.length > 0) {
console.log(`Referenced by tests only (${testOnlyJs.length}):`);
for (const item of testOnlyJs.sort(sortByPlace)) {
console.log(` ${item.name} ${where(item.defs)} (${item.inTests} mention${item.inTests === 1 ? '' : 's'} in test/)`);
}
console.log('');
}
if (deadJs.length === 0 && deadCss.length === 0) {
console.log(
`✓ no unreferenced frontend code: ${byName.size} names in ${frontendFiles.length} scripts and the selectors of 2 stylesheets checked`
);
process.exit(0);
}
if (deadJs.length > 0) {
console.error(`Unreferenced frontend functions and bindings (${deadJs.length}):`);
for (const item of deadJs.sort(sortByPlace)) console.error(` ${item.name} ${where(item.defs)}`);
}
if (deadCss.length > 0) {
console.error(`${deadJs.length > 0 ? '\n' : ''}CSS selectors nothing can produce (${deadCss.length}):`);
for (const item of deadCss) console.error(` ${item.key} ${item.file}:${item.line}`);
}
console.error(
'\nRemove each one, or add it to ALLOW in scripts/check-frontend-dead-code.mjs with the reason it stays.'
);
process.exit(1);
-179
View File
@@ -1,179 +0,0 @@
/**
* Manual verification harness for the session-sidebar feature.
*
* Renders the real UI in headless Chromium against a testMode WebServer,
* injects a synthetic 25-session fleet, and screenshots every layout state.
* Not part of the automated suite — run it by hand:
*
* npx tsx scripts/verify-session-sidebar.mts
*
* SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
* dedicated port. It never touches a real Codeman instance or tmux socket.
*/
import { chromium } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { mkdirSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Mirror test/setup.ts: isolate HOME before the app modules touch state.
process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
process.env.VITEST = 'true';
const PORT = 3299;
const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
mkdirSync(OUT, { recursive: true });
// Generic on purpose: these names end up in the harness screenshots, so they
// should not carry one contributor's project list into everyone else's review.
// The mix of CLI modes matters (each renders a different badge); the names do not.
const PROJECTS = [
['api-server', 'claude'],
['web-client', 'claude'],
['mobile-app', 'codex'],
['data-pipeline', 'claude'],
['shared-lib', 'gemini'],
['codeman', 'claude'],
['docs-site', 'claude'],
['batch-jobs', 'opencode'],
['search-index', 'claude'],
];
const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
function fleet(n: number) {
const out: any[] = [];
for (let i = 0; i < n; i++) {
const [proj, mode] = PROJECTS[i % PROJECTS.length];
const status = STATUSES[i % STATUSES.length];
out.push({
id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
pid: 10000 + i,
status,
workingDir: `${tmpdir()}/projects/${proj}`,
name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
mode,
currentTaskId: null,
createdAt: Date.now() - i * 60000,
lastActivityAt: Date.now() - i * 1000,
isWorking: status === 'busy',
messageCount: i * 3,
totalCost: 0,
inputTokens: 0,
outputTokens: 0,
color: 'default',
taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
taskTree: [],
tokens: { input: 0, output: 0, total: 0 },
bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
});
}
return out;
}
const SESSIONS = fleet(25);
async function main() {
const server = new WebServer(PORT, false, true);
await server.start();
const browser = await chromium.launch({ headless: true });
const results: string[] = [];
async function shot(
name: string,
opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
) {
const ctx = await browser.newContext({
viewport: { width: opts.width, height: opts.height },
hasTouch: !!opts.touch,
isMobile: !!opts.touch,
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
const settings = JSON.stringify({ sessionListLayout: opts.layout });
const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
await page.addInitScript(
([s, c]) => {
localStorage.setItem('codeman-app-settings', s as string);
localStorage.setItem('codeman-app-settings-mobile', s as string);
if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
else localStorage.removeItem('codeman-sidebar-collapsed');
},
[settings, collapsed]
);
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await page.evaluate((list) => {
const app = (window as any).app;
if (!app) throw new Error('no window.app');
app.sessions.clear();
for (const s of list as any[]) app.sessions.set(s.id, s);
// The renderer iterates sessionOrder, not the map.
app.sessionOrder = (list as any[]).map((s) => s.id);
app.activeSessionId = (list as any[])[3].id;
// renderSessionTabs() is debounced; drive the immediate path directly.
(app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
app.applySessionListLayout?.();
}, SESSIONS as any);
await page.waitForTimeout(600);
const info = await page.evaluate(() => {
const root = document.documentElement;
const aside = document.getElementById('sessionSidebar');
const tabsEl = document.getElementById('sessionTabs');
const asideBox = aside?.getBoundingClientRect();
const cs = aside ? getComputedStyle(aside) : null;
return {
dataSessionList: root.dataset.sessionList ?? null,
dataSidebar: root.dataset.sidebar ?? null,
rows: document.querySelectorAll('.session-tab').length,
tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
asideWidth: asideBox ? Math.round(asideBox.width) : null,
asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
asideInert: aside?.hasAttribute('inert') ?? null,
ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
firstRowText:
(document.querySelector('.session-tab') as HTMLElement | null)?.innerText
?.trim()
.replace(/\s+/g, ' ')
.slice(0, 40) ?? null,
listScrollable: (() => {
const el = document.getElementById('sessionTabs');
return el ? el.scrollHeight > el.clientHeight + 2 : null;
})(),
};
});
await page.waitForTimeout(400);
const file = join(OUT, `${name}.png`);
await page.screenshot({ path: file });
results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
await ctx.close();
return info;
}
await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
console.log('\n=== RESULTS ===');
for (const r of results) console.log(r);
console.log(`\nScreenshots in ${OUT}`);
await browser.close();
await server.stop();
}
main().then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
}
);
+2 -2
View File
@@ -64,7 +64,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. The one opt-in exception is File Viewer (#759): `mobile.css` no longer hides it at phone width, so it stays off a phone only through the handheld default in `getDefaultSettings()` and the `html.mobile-init` startup rule (both pinned by that test), and shows once the setting is switched on on that phone. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and every open tile and split pane (`_forEachTile`). ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
@@ -96,6 +96,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. ⚠️ The watchdog cannot see an attempt that never OPENS (no response headers: no `open`, no `error`, never `connected`), so each attempt is bounded by `SSE_CONNECT_TIMEOUT_MS` (15s, `CodemanSseStale.CONNECT_TIMEOUT_MS`) and then retried through `_handleSseConnectionFailure()`, the same backoff `onerror` uses. Its timer is cleared at the top of `connectSSE()` BEFORE the offline early return, on `open`, and in `_handleSseConnectionFailure()`; the timer and the handler both ignore an `EventSource` that has been replaced. The window `offline` handler leaves it running on purpose (`navigator.onLine` can report offline on a working network). Tests: `test/sse-connect-timeout.test.ts`. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), Story Mode overlay (2400, below the connection-loss overlay so a lost server still shows), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), Story Mode overlay (2400, below the connection-loss overlay so a lost server still shows), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
+1 -1
View File
@@ -7,7 +7,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class must be defined)
* @loadorder 14 of 15 — loaded after ralph-wizard.js
* @loadorder 14 of 15 — loaded after session-ui.js
*/
// Codeman — Centralized API fetch helpers for CodemanApp
-49
View File
@@ -11,7 +11,6 @@
* panels-ui.js — Subagent panel, agent teams, project insights, file browser, log viewer,
* image popups, monitor, token stats, toast, system stats
* session-ui.js — Quick start, session options modal, case settings, mobile case picker
* ralph-wizard.js — Ralph Loop wizard modal
* api-client.js — API helper methods (fetch wrappers)
* subagent-windows.js — Floating subagent terminal windows
*
@@ -299,13 +298,6 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.TUNNEL_QR_REGENERATED, '_onTunnelQrRegenerated'],
[SSE_EVENTS.TUNNEL_QR_AUTH_USED, '_onTunnelQrAuthUsed'],
// Plan orchestration
[SSE_EVENTS.PLAN_SUBAGENT, '_onPlanSubagent'],
[SSE_EVENTS.PLAN_PROGRESS, '_onPlanProgress'],
[SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'],
[SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'],
[SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'],
// Orchestrator loop
[SSE_EVENTS.ORCHESTRATOR_STATE_CHANGED, '_onOrchestratorStateChanged'],
[SSE_EVENTS.ORCHESTRATOR_PLAN_PROGRESS, '_onOrchestratorPlanProgress'],
@@ -871,26 +863,11 @@ class CodemanApp {
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Teammate tmux pane terminals (Agent Teams feature)
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.terminalBufferCache = new Map(); // Map<sessionId, string> — client-side cache for instant tab re-visits (max 20)
this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
// Plan subagent windows (visible agents during plan generation)
this.planSubagents = new Map(); // Map<agentId, { type, model, status, startTime, element, relativePos }>
this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE;
this.planGenerationStopped = false; // Flag to ignore SSE events after Stop
this.planAgentsMinimized = false; // Whether agent windows are minimized to tab
// Wizard dragging state
this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging }
this.wizardDragListeners = null; // { move, up } for cleanup
this.wizardPosition = null; // { left, top } - null means centered
// Project Insights tracking (active Bash tools with clickable file paths)
this.projectInsights = new Map(); // Map<sessionId, ActiveBashTool[]>
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
@@ -5078,10 +5055,6 @@ class CodemanApp {
// wrapping) clipped xterm's bottom rows behind the toolbar until a tab
// switch or a window resize.
// Clear any other orphaned timers
if (this.planLoadingTimer) {
clearInterval(this.planLoadingTimer);
this.planLoadingTimer = null;
}
if (this.timerCountdownInterval) {
clearInterval(this.timerCountdownInterval);
this.timerCountdownInterval = null;
@@ -11160,28 +11133,6 @@ class CodemanApp {
this.renderRalphStatePanel();
}
// ═══════════════════════════════════════════════════════════════
// Ralph Loop Wizard (methods in ralph-wizard.js)
// ═══════════════════════════════════════════════════════════════
// Wizard state (initialized here, methods loaded from ralph-wizard.js)
ralphWizardStep = 1;
ralphWizardConfig = {
taskDescription: '',
completionPhrase: 'COMPLETE',
maxIterations: 10,
caseName: 'testcase',
enableRespawn: false,
generatedPlan: null,
planGenerated: false,
skipPlanGeneration: false,
planDetailLevel: 'detailed',
existingPlan: null,
useExistingPlan: false,
};
planLoadingTimer = null;
planLoadingStartTime = null;
// ═══════════════════════════════════════════════════════════════
// Kill Sessions
// ═══════════════════════════════════════════════════════════════
+2 -3
View File
@@ -16,7 +16,7 @@
* @dependency None (first in load order)
* @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
* → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js
* → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js
* → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js
* → api-client.js → subagent-windows.js
*/
@@ -135,7 +135,6 @@ const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a
// Z-index base values for layered floating windows
const ZINDEX_SUBAGENT_BASE = 1000;
const ZINDEX_PLAN_SUBAGENT_BASE = 1100;
const ZINDEX_LOG_VIEWER_BASE = 2000;
const ZINDEX_IMAGE_POPUP_BASE = 3000;
@@ -1964,7 +1963,7 @@ function getEventCoords(e) {
return { clientX: e.clientX, clientY: e.clientY };
}
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
// HTML escape utility (shared by NotificationManager and the CodemanApp mixins)
const _htmlEscapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const _htmlEscapePattern = /[&<>"']/g;
function escapeHtml(text) {
+1 -1
View File
@@ -58,7 +58,7 @@
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
* @loadorder 12.6 of 16, after webview-tabs.js, before api-client.js
*/
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
-194
View File
@@ -1282,16 +1282,6 @@
<div class="monitor-empty">No mux sessions</div>
</div>
</div>
<div class="monitor-section" id="monitorPlanAgentsSection" style="display: none;">
<div class="monitor-section-header">
<span>Plan Wizard Agents</span>
<span class="task-stats" id="monitorPlanAgentStats">0 active</span>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.cancelPlanFromMonitor()" title="Cancel plan generation">Cancel</button>
</div>
<div class="monitor-section-body" id="monitorPlanAgentsBody">
<div class="monitor-empty">No plan agents</div>
</div>
</div>
<div class="monitor-section" id="monitorSubagentsSection">
<div class="monitor-section-header">
<span>Background Agents</span>
@@ -4221,189 +4211,6 @@
</div>
</div>
<!-- Ralph Loop Wizard Modal -->
<div class="modal" id="ralphWizardModal">
<div class="modal-backdrop"></div>
<div class="modal-content modal-wizard">
<div class="modal-header">
<h3>Start Ralph Loop</h3>
<div class="modal-header-actions">
<button class="modal-close" onclick="app.closeRalphWizard()" aria-label="Close Ralph wizard">&times;</button>
</div>
</div>
<!-- Wizard Progress Steps -->
<div class="wizard-progress">
<div class="wizard-step active" data-step="1">
<span class="wizard-step-number">1</span>
<span class="wizard-step-label">Describe</span>
</div>
<div class="wizard-step" data-step="2">
<span class="wizard-step-number">2</span>
<span class="wizard-step-label">Plan</span>
</div>
<div class="wizard-step" data-step="3">
<span class="wizard-step-number">3</span>
<span class="wizard-step-label">Launch</span>
</div>
</div>
<div class="modal-body">
<!-- Step 1: Task & Completion Setup -->
<div class="wizard-page wizard-page-compact" id="ralphWizardStep1">
<div class="wizard-step-intro">
<p>Describe your task below. Claude will generate an implementation plan with testing steps.</p>
</div>
<div class="form-row">
<label>What do you want to build?</label>
<div class="textarea-with-assist">
<textarea
id="ralphTaskDescription"
rows="3"
placeholder="Build a REST API for user authentication with JWT tokens, login/logout endpoints, and password reset flow."></textarea>
</div>
</div>
<div class="wizard-inline-row">
<div class="wizard-inline-field">
<label>Working Directory <span id="existingPlanBadge" class="existing-plan-badge" style="display:none"></span></label>
<div class="case-select-inline">
<select id="ralphCaseSelect" class="form-select" onchange="app.onRalphCaseChange()"></select>
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+</button>
</div>
</div>
<div class="wizard-inline-field wizard-inline-field-small">
<label>Iterations</label>
<div class="iteration-presets-compact">
<button type="button" class="iteration-preset-btn active" data-iterations="10" onclick="app.selectIterationPreset(10)">10</button>
<button type="button" class="iteration-preset-btn" data-iterations="25" onclick="app.selectIterationPreset(25)">25</button>
<button type="button" class="iteration-preset-btn" data-iterations="50" onclick="app.selectIterationPreset(50)">50</button>
<button type="button" class="iteration-preset-btn" data-iterations="0" onclick="app.selectIterationPreset(0)">&infin;</button>
</div>
</div>
</div>
<!-- Hidden completion phrase with default -->
<input type="hidden" id="ralphCompletionPhrase" value="COMPLETE">
</div>
<!-- Step 2: Plan Generation -->
<div class="wizard-page wizard-page-compact hidden" id="ralphWizardStep2">
<!-- Existing plan detected (shown when @fix_plan.md exists) -->
<div id="existingPlanSection" class="plan-section hidden">
<!-- Content populated by app.updateExistingPlanUI() -->
</div>
<!-- Loading state (shown during generation) -->
<div id="planGenerationLoading" class="plan-loading-state hidden">
<div class="plan-loading-content">
<div class="plan-spinner"></div>
<div class="plan-loading-text">
<span class="plan-loading-title" id="planLoadingTitle">Starting Opus 4.5...</span>
<span class="plan-loading-time" id="planLoadingTime">0s</span>
</div>
</div>
<p class="plan-loading-hint" id="planLoadingHint">Initializing deep reasoning model</p>
<div class="plan-loading-actions">
<button class="btn-toolbar btn-sm plan-cancel-btn" onclick="app.cancelPlanGeneration()">Stop</button>
<button class="btn-toolbar btn-sm" onclick="app.regeneratePlan()">Regenerate</button>
</div>
<!-- Auto-start toggle visible during generation -->
<div class="plan-autostart-toggle">
<label class="plan-autostart-label">
<input type="checkbox" id="ralphAutoStartStep2" onchange="app.ralphWizardConfig.autoStart = this.checked">
<span class="plan-autostart-text">Auto-launch when plan completes</span>
</label>
</div>
</div>
<!-- Stopped/Error state -->
<div id="planGenerationError" class="plan-section hidden">
<div class="plan-stopped-indicator" id="planStoppedIndicator">
<span class="plan-stopped-icon">&#x25A0;</span>
<span class="plan-stopped-text">Stopped</span>
</div>
<p class="plan-error-msg" id="planErrorMsg"></p>
<div class="plan-actions">
<button class="btn-toolbar btn-primary" onclick="app.generatePlan()">Regenerate Plan</button>
</div>
<p class="plan-skip-hint">Use <strong>Back</strong> to edit your task, or <strong>Next</strong> to continue without a plan</p>
</div>
<!-- Editor state -->
<div id="planEditor" class="plan-section hidden">
<div class="plan-editor-header">
<span>Implementation Plan <span class="plan-stats" id="planStats"></span></span>
<div class="plan-editor-actions">
<div class="plan-option-group">
<label>Detail:</label>
<div class="plan-detail-btns">
<button type="button" class="plan-detail-btn" data-detail="standard" onclick="app.setPlanDetail('standard')" title="Single-pass generation with Opus 4.5">Standard</button>
<button type="button" class="plan-detail-btn active" data-detail="detailed" onclick="app.setPlanDetail('detailed')" title="Enhanced: parallel subagents + verification (slower but more thorough)">Enhanced</button>
</div>
</div>
<button class="btn-toolbar btn-sm" onclick="app.regeneratePlan()">Regenerate</button>
</div>
</div>
<div id="planItemsList" class="plan-items-list"></div>
</div>
</div>
<!-- Step 3: Preview & Launch -->
<div class="wizard-page hidden" id="ralphWizardStep3">
<div class="ralph-preview-section">
<label>Prompt Preview</label>
<div class="ralph-prompt-preview" id="ralphPromptPreview"></div>
</div>
<div class="ralph-config-summary">
<div class="config-summary-row">
<span class="config-label">Completion Phrase:</span>
<code id="summaryPhrase">COMPLETE</code>
</div>
<div class="config-summary-row">
<span class="config-label">Max Iterations:</span>
<span id="summaryIterations">10</span>
</div>
<div class="config-summary-row">
<span class="config-label">Case:</span>
<span id="summaryCase">testcase</span>
</div>
<div class="config-summary-row" style="display: none;">
<span class="config-label">Plan:</span>
<span id="summaryPlan">-</span>
</div>
</div>
<!-- Advanced Options (Collapsible) -->
<details class="advanced-options">
<summary>Advanced Options</summary>
<div class="advanced-options-content">
<div class="form-row form-row-switch">
<label>Enable Respawn</label>
<label class="switch">
<input type="checkbox" id="ralphEnableRespawn">
<span class="slider"></span>
</label>
<span class="form-hint">Auto-restart sessions when context fills up (usually not needed)</span>
</div>
</div>
</details>
</div>
</div>
<!-- Wizard Footer -->
<div class="wizard-footer">
<button class="btn-toolbar" id="ralphBackBtn" onclick="app.ralphWizardBack()" style="display: none;">Back</button>
<div class="wizard-footer-right">
<button class="btn-toolbar" onclick="app.closeRalphWizard()">Cancel</button>
<button class="btn-toolbar btn-primary" id="ralphNextBtn" onclick="app.ralphWizardNext()">Next</button>
<button class="btn-toolbar btn-success" id="ralphStartBtn" onclick="app.startRalphLoop()" style="display: none;">Start Loop</button>
</div>
</div>
</div>
</div>
<!-- Kill All Confirmation Modal -->
<div class="modal" id="killAllModal">
<div class="modal-backdrop" onclick="app.closeKillAllModal()"></div>
@@ -4769,7 +4576,6 @@
<script defer src="local-model-ui.js"></script>
<script defer src="story-mode.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
<script defer src="ultracode-windows.js"></script>
+1 -2
View File
@@ -26,8 +26,7 @@
* by Link Existing and the extended mobile keyboard bar.
*
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
* and other modal dialogs.
* Saves and restores previously focused element on deactivate. Used by the modal dialogs.
*
* @globals {object} KeyboardAccessoryBar
* @globals {object} PathPicker
-24
View File
@@ -1930,30 +1930,6 @@ html.mobile-init .file-browser-panel {
justify-content: flex-start;
}
/* Ralph wizard responsive */
.modal-wizard {
display: flex;
flex-direction: column;
}
.wizard-progress {
padding: 0.5rem;
}
.wizard-step {
padding: 0.3rem;
}
.wizard-step-number {
width: 24px;
height: 24px;
font-size: 0.7rem;
}
.wizard-step-label {
display: none;
}
/* Timer banner - includes safe area padding */
.timer-banner {
padding-left: calc(0.5rem + var(--safe-area-left));
+3 -161
View File
@@ -1,6 +1,6 @@
/**
* @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking,
* agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking),
* agent teams (teammate badges), project insights (bash tool tracking),
* file browser (directory tree, preview), log viewer (floating file streamers),
* image popups (auto-popup for screenshots), mux sessions, monitor panel,
* token statistics, toast notifications, and system stats.
@@ -177,7 +177,7 @@ Object.assign(CodemanApp.prototype, {
this.findParentSessionForSubagent(data.agentId);
// Subagent windows are no longer auto-opened on discovery.
// Users can open them manually from the monitor panel or via openAllActiveSubagentWindows().
// Users open them from the subagents panel.
// Ensure connection lines are updated after window is created and DOM settles
requestAnimationFrame(() => {
@@ -2135,16 +2135,10 @@ Object.assign(CodemanApp.prototype, {
// Show window (unless it was minimized by user)
if (!windowInfo.minimized) {
windowInfo.element.style.display = 'flex';
// Lazily re-create teammate terminal if it was disposed when hidden
if (windowInfo._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
}
windowInfo.hidden = false;
} else {
// Hide window (but don't close it)
// Dispose teammate terminal to free memory while hidden on inactive tab
this._disposeTeammateTerminalForMinimize(agentId);
windowInfo.element.style.display = 'none';
windowInfo.hidden = true;
}
@@ -2210,23 +2204,12 @@ Object.assign(CodemanApp.prototype, {
windowData.element.remove();
this.subagentWindows.delete(agentId);
}
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
},
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
// message) into a single DOM update per 100ms per agent window.
scheduleSubagentWindowRender(agentId) {
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
// Skip DOM updates for minimized windows
const windowData = this.subagentWindows.get(agentId);
if (windowData?.minimized) return;
@@ -2241,12 +2224,6 @@ Object.assign(CodemanApp.prototype, {
},
renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
const windowData = this.subagentWindows.get(agentId);
if (windowData?._lazyTerminal) return;
const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return;
@@ -2399,141 +2376,6 @@ Object.assign(CodemanApp.prototype, {
// Agent Teams
// ═══════════════════════════════════════════════════════════════
/** Initialize an xterm.js terminal for a teammate's tmux pane */
initTeammateTerminal(agentId, paneInfo, windowElement) {
const body = windowElement.querySelector('.subagent-window-body');
if (!body) return;
// Clear the activity log content
body.innerHTML = '';
body.classList.add('teammate-terminal-body');
windowElement.classList.add('has-terminal');
const sessionId = paneInfo.sessionId;
// Buffer incoming terminal data until xterm is ready
const pendingData = [];
this.teammateTerminals.set(agentId, {
terminal: null,
fitAddon: null,
paneTarget: paneInfo.paneTarget,
sessionId,
resizeObserver: null,
pendingData,
});
// Defer terminal creation to next frame so the body element has computed dimensions
requestAnimationFrame(() => {
// Safety: if window was closed before we got here, bail out
if (!document.contains(body)) {
this.teammateTerminals.delete(agentId);
return;
}
const fontSettings = this.loadAppSettingsFromStorage?.() || {};
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily),
// A pane opened after a weight change must match the main terminal;
// one open across the change is repainted by applyTerminalFontWeights().
...window.CodemanTerminalFont.resolveWeights(fontSettings),
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
if (typeof Unicode11Addon !== 'undefined') {
try {
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = '11';
} catch (_e) { /* Unicode11 addon failed */ }
}
try {
terminal.open(body);
} catch (err) {
console.warn('[TeammateTerminal] Failed to open terminal:', err);
this.teammateTerminals.delete(agentId);
return;
}
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
// running an agent CLI (Task-tool dispatched, never a plain shell), so
// unlike the main terminal's mode-gated trap this one is unconditional.
// Mirrors the main terminal's guard in terminal-ui.js's
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
terminal.attachCustomKeyEventHandler((ev) => {
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
ev.preventDefault();
return false;
}
return true;
});
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
try { fitAddon.fit(); } catch {}
// Fetch initial pane buffer
fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`)
.then(r => r.json())
.then(resp => {
if (resp.success && resp.data?.buffer) {
try { terminal.write(resp.data.buffer); } catch {}
}
})
.catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err));
// Flush any data that arrived while terminal was initializing
for (const chunk of pendingData) {
try { terminal.write(chunk); } catch {}
}
pendingData.length = 0;
}, 100);
// Forward keyboard input to the teammate's pane
terminal.onData((data) => {
fetch(`/api/sessions/${sessionId}/teammate-pane-input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }),
}).catch(err => console.error('[TeammateTerminal] Failed to send input:', err));
});
// Resize observer to refit terminal when window is resized
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} });
});
resizeObserver.observe(body);
// Update the stored entry with the real terminal
const entry = this.teammateTerminals.get(agentId);
if (entry) {
entry.terminal = terminal;
entry.fitAddon = fitAddon;
entry.resizeObserver = resizeObserver;
}
});
},
/** Check if a subagent is a teammate and return its info */
getTeammateInfo(agent) {
if (!agent?.description) return null;
+1 -147
View File
@@ -1,7 +1,6 @@
/**
* @fileoverview Ralph state panel (progress ring, status, task cards, fix_plan.md integration),
* plan versioning (checkpoint, rollback, diff), and plan wizard agents in monitor.
* Includes 11 SSE handlers for Ralph and plan events.
* and plan versioning (checkpoint, rollback, diff). Includes the SSE handlers for Ralph events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.ralphStates, this.activeSessionId)
@@ -68,64 +67,6 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
},
// Bash tools
// Plan orchestration
_onPlanSubagent(data) {
console.log('[Plan Subagent]', data);
this.handlePlanSubagentEvent(data);
},
_onPlanProgress(data) {
console.log('[Plan Progress]', data);
// Update UI if we have a progress handler registered
if (this._planProgressHandler) {
this._planProgressHandler({ type: 'plan:progress', data });
}
// Also update the loading display directly for better feedback
const titleEl = document.getElementById('planLoadingTitle');
const hintEl = document.getElementById('planLoadingHint');
if (titleEl && data.phase) {
const phaseLabels = {
'parallel-analysis': 'Running parallel analysis...',
'subagent': data.detail || 'Subagent working...',
'synthesis': 'Synthesizing results...',
'verification': 'Running verification...',
};
titleEl.textContent = phaseLabels[data.phase] || data.phase;
}
if (hintEl && data.detail) {
hintEl.textContent = data.detail;
}
},
_onPlanStarted(data) {
console.log('[Plan Started]', data);
this.activePlanOrchestratorId = data.orchestratorId;
this.planGenerationStopped = false; // Reset flag for new generation
this.renderMonitorPlanAgents();
},
_onPlanCancelled(data) {
console.log('[Plan Cancelled]', data);
if (this.activePlanOrchestratorId === data.orchestratorId) {
this.activePlanOrchestratorId = null;
}
this.renderMonitorPlanAgents();
},
_onPlanCompleted(data) {
console.log('[Plan Completed]', data);
if (this.activePlanOrchestratorId === data.orchestratorId) {
this.activePlanOrchestratorId = null;
}
this.renderMonitorPlanAgents();
},
// ═══════════════════════════════════════════════════════════════
// Enhanced Ralph Wiggum Loop Panel
// ═══════════════════════════════════════════════════════════════
@@ -1095,91 +1036,4 @@ Object.assign(CodemanApp.prototype, {
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
},
// ═══════════════════════════════════════════════════════════════
// Plan Wizard Agents in Monitor
// ═══════════════════════════════════════════════════════════════
renderMonitorPlanAgents() {
const section = document.getElementById('monitorPlanAgentsSection');
const body = document.getElementById('monitorPlanAgentsBody');
const stats = document.getElementById('monitorPlanAgentStats');
if (!section || !body) return;
const planAgents = Array.from(this.planSubagents?.values() || []);
const hasActiveOrchestrator = !!this.activePlanOrchestratorId;
// Show section only if there are plan agents or active orchestrator
if (planAgents.length === 0 && !hasActiveOrchestrator) {
section.style.display = 'none';
return;
}
section.style.display = '';
const activeCount = planAgents.filter(a => a.status === 'running').length;
const completedCount = planAgents.filter(a => a.status === 'completed' || a.status === 'failed').length;
if (stats) {
if (hasActiveOrchestrator) {
stats.textContent = `${activeCount} running, ${completedCount} done`;
} else {
stats.textContent = `${planAgents.length} total`;
}
}
if (planAgents.length === 0) {
body.innerHTML = `<div class="monitor-empty">${hasActiveOrchestrator ? 'Plan generation starting...' : 'No plan agents'}</div>`;
return;
}
let html = '';
for (const agent of planAgents) {
const statusClass = agent.status === 'running' ? 'active' : agent.status === 'completed' ? 'completed' : 'error';
const agentLabel = agent.agentType || agent.agentId;
const modelBadge = agent.model ? `<span class="model-badge opus">opus</span>` : '';
const detail = agent.detail ? escapeHtml(agent.detail.substring(0, 50)) : '';
const duration = agent.durationMs ? `${(agent.durationMs / 1000).toFixed(1)}s` : '';
const itemCount = agent.itemCount ? `${agent.itemCount} items` : '';
html += `
<div class="process-item">
<span class="process-mode ${statusClass}">${agent.status || 'pending'}</span>
<div class="process-info">
<div class="process-name">${modelBadge} ${escapeHtml(agentLabel)}</div>
<div class="process-meta">
${detail ? `<span>${detail}</span>` : ''}
${itemCount ? `<span>${itemCount}</span>` : ''}
${duration ? `<span>${duration}</span>` : ''}
</div>
</div>
</div>
`;
}
body.innerHTML = html;
},
async cancelPlanFromMonitor() {
if (!this.activePlanOrchestratorId && this.planSubagents?.size === 0) {
this.showToast('No active plan generation', 'info');
return;
}
if (!confirm('Cancel plan generation and close all plan agent windows?')) return;
// Cancel the plan generation (reuse existing method)
await this.cancelPlanGeneration();
// Also force close the wizard if it's open
const wizardModal = document.getElementById('ralphWizardModal');
if (wizardModal?.classList.contains('active')) {
this.closeRalphWizard();
}
// Update monitor display
this.renderMonitorPlanAgents();
this.showToast('Plan generation cancelled', 'success');
},
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -8,7 +8,7 @@
* @dependency app.js (CodemanApp class, this.sessions, this.cases, this.activeSessionId)
* @dependency constants.js (escapeHtml)
* @dependency mobile-handlers.js (MobileDetection)
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
* @loadorder 12 of 15 — loaded after panels-ui.js, before api-client.js
*/
/**
-723
View File
@@ -11335,47 +11335,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
drop-shadow(0 0 12px rgba(59, 130, 246, 0.7));
}
/* Wizard connection lines - different color to show plan generation link */
.connection-line.wizard-connection {
stroke: #60a5fa;
stroke-width: 3;
/* Dark outline for contrast against blurred backgrounds */
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 5px #60a5fa)
drop-shadow(0 0 10px #60a5fa);
stroke-dasharray: 8 4;
animation: wizard-pulse 1.5s ease-in-out infinite;
}
@keyframes wizard-pulse {
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
/* Plan subagent to regular subagent connection lines (Opus → Haiku) */
.connection-line.plan-to-subagent-line {
stroke: #3b82f6;
stroke-width: 3;
/* Dark outline for contrast, vibrant blue glow */
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
stroke-dasharray: 5 3;
animation: plan-subagent-pulse 1.2s ease-in-out infinite;
}
.connection-line.plan-to-subagent-line:hover {
stroke-width: 3.5;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9))
drop-shadow(0 0 6px rgba(59, 130, 246, 1))
drop-shadow(0 0 12px rgba(59, 130, 246, 0.7));
}
@keyframes plan-subagent-pulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
One quiet orthogonal tree per spawning tab, every family always drawn and
routed through the gaps between tab rows (computeLineageTree in constants.js).
@@ -12623,565 +12582,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
cursor: pointer;
}
/* ========== Ralph Loop Wizard ========== */
/* Wizard Progress Indicator */
.wizard-progress {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-dark);
border-bottom: 1px solid var(--border);
}
.wizard-step {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.8rem;
transition: color 0.2s;
}
.wizard-step.active {
color: var(--accent);
}
.wizard-step.completed {
color: var(--green);
}
.wizard-step-number {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 600;
}
.wizard-step.active .wizard-step-number {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.wizard-step.completed .wizard-step-number {
background: var(--green);
border-color: var(--green);
color: #fff;
}
.wizard-step-label {
font-weight: 500;
}
/* Wizard Pages */
.wizard-page {
min-height: 180px;
}
.wizard-page.hidden {
display: none;
}
/* Wizard Footer */
.wizard-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.wizard-footer-right {
display: flex;
gap: 0.5rem;
}
/* Wizard Modal - compact layout */
.modal-wizard {
width: 480px;
max-width: 95vw;
max-height: 70vh;
display: flex;
flex-direction: column;
}
/* Draggable wizard state */
.modal-wizard.draggable {
position: absolute;
transform: none;
cursor: default;
}
.modal-wizard .modal-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
.modal-wizard .wizard-footer {
flex-shrink: 0;
}
.wizard-page-compact {
min-height: auto;
padding: 0.5rem 0;
}
.wizard-step-intro {
text-align: center;
margin-bottom: 0.75rem;
color: var(--text-dim);
font-size: 0.9rem;
}
.wizard-step-intro p {
margin: 0;
}
/* Wizard inline row for compact layout */
.wizard-inline-row {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.wizard-inline-field {
flex: 1;
}
.wizard-inline-field-small {
flex: 0 0 auto;
}
.wizard-inline-field label {
display: block;
font-size: 0.8rem;
color: var(--text-dim);
margin-bottom: 0.25rem;
}
.iteration-presets-compact {
display: flex;
gap: 0.25rem;
}
.iteration-presets-compact .iteration-preset-btn {
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
min-width: 36px;
}
/* Plan Generation Step */
.plan-section {
padding: 1rem;
text-align: center;
}
.plan-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
/* Existing Plan Badge (in case selector) */
.existing-plan-badge {
font-size: 0.7rem;
background: var(--accent);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-left: 0.5rem;
font-weight: normal;
}
/* Existing Plan Card (Step 2) */
.existing-plan-card {
background: var(--bg-input);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 1rem;
text-align: left;
}
.existing-plan-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.75rem;
}
.existing-plan-icon {
font-size: 1.2rem;
}
.existing-plan-stats {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.existing-plan-stats .stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.existing-plan-stats .stat.pending {
color: var(--yellow);
}
.existing-plan-stats .stat.completed {
color: var(--green);
}
.existing-plan-stats .stat.total {
color: var(--text-muted);
}
.existing-plan-actions {
display: flex;
gap: 0.5rem;
}
/* Plan Loading State */
.plan-loading-state {
padding: 1.5rem 1rem;
text-align: center;
}
.plan-loading-content {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.plan-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.plan-loading-text {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.plan-loading-title {
font-weight: 500;
color: var(--text);
}
.plan-loading-time {
font-size: 0.8rem;
color: var(--text-muted);
font-family: 'SF Mono', Monaco, monospace;
}
.plan-loading-hint {
color: var(--text-dim);
font-size: 0.85rem;
margin: 0 0 1rem 0;
}
.plan-error-msg {
color: var(--red);
margin-bottom: 1rem;
}
.plan-option-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.plan-option-group label {
font-size: 0.8rem;
color: var(--text-dim);
}
.plan-option-group select {
padding: 0.3rem 0.5rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.8rem;
}
.plan-detail-btns {
display: flex;
gap: 0.25rem;
}
.plan-detail-btn {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
}
.plan-detail-btn:hover {
border-color: var(--border-light);
}
.plan-detail-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Plan Editor */
.plan-editor-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-weight: 500;
font-size: 0.9rem;
}
.plan-stats {
font-weight: normal;
font-size: 0.8rem;
color: var(--text-muted);
margin-left: 0.5rem;
}
.plan-editor-actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: center;
}
.plan-items-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 280px;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.plan-loading-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.plan-autostart-toggle {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.plan-autostart-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
justify-content: center;
}
.plan-autostart-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.plan-autostart-text {
font-size: 0.85rem;
color: var(--text);
}
.plan-autostart-label:hover .plan-autostart-text {
color: var(--accent);
}
/* Existing wizard files section */
.plan-cancel-btn {
color: var(--red);
}
/* Plan Stopped State */
.plan-stopped-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--surface);
border-radius: 6px;
border: 1px solid var(--border);
}
.plan-stopped-icon {
color: var(--red);
font-size: 1rem;
}
.plan-stopped-text {
font-weight: 500;
color: var(--text);
}
.plan-skip-hint {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
margin-top: 0.75rem;
}
.plan-skip-hint strong {
color: var(--text-dim);
}
/* ========== Plan Subagent Windows (Visible Opus Agents) ========== */
.plan-subagent-window {
position: fixed;
width: 280px;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 20px rgba(96, 165, 250, 0.2);
overflow: hidden;
animation: planSubagentSpawn 0.3s ease-out;
}
@keyframes planSubagentSpawn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.plan-subagent-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(96, 165, 250, 0.1));
border-bottom: 1px solid var(--border);
cursor: grab;
user-select: none;
}
.plan-subagent-header:active {
cursor: grabbing;
}
.plan-subagent-icon {
font-size: 1rem;
}
.plan-subagent-title {
flex: 1;
font-weight: 500;
font-size: 0.8rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plan-subagent-model {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
background: #7c3aed;
color: white;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
}
.plan-subagent-body {
padding: 0.75rem;
}
.plan-subagent-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.plan-subagent-status.running .plan-subagent-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.plan-subagent-status.completed .plan-subagent-status-text {
color: var(--green);
}
.plan-subagent-status.failed .plan-subagent-status-text {
color: var(--red);
}
.plan-subagent-status-text {
font-size: 0.8rem;
font-weight: 500;
}
.plan-subagent-detail {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Clickable status text */
.plan-subagent-status-text.clickable {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.plan-subagent-status-text.clickable:hover {
text-decoration-style: solid;
color: var(--accent);
}
/* Ralph panel version info */
.ralph-version-info {
display: flex;
@@ -13212,129 +12612,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
text-decoration: underline;
}
/* Textarea with AI Assist button */
.textarea-with-assist {
position: relative;
}
.textarea-with-assist textarea {
width: 100%;
padding: 0.6rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.85rem;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
resize: vertical;
min-height: 80px;
}
.textarea-with-assist textarea:focus {
outline: none;
border-color: var(--accent);
}
/* Iteration Presets */
.iteration-preset-btn {
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: 500;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
min-width: 50px;
}
.iteration-preset-btn:hover {
border-color: var(--border-light);
color: var(--text);
}
.iteration-preset-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Case Select Inline */
.case-select-inline {
display: flex;
gap: 0.5rem;
}
.case-select-inline select {
flex: 1;
}
/* Ralph Prompt Preview */
.ralph-preview-section {
margin-bottom: 1rem;
}
.ralph-preview-section > label {
display: block;
font-size: 0.7rem;
color: var(--text-dim);
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ralph-prompt-preview {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
font-size: 0.75rem;
color: var(--text-dim);
max-height: 120px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.ralph-prompt-preview .preview-highlight {
color: var(--accent);
font-weight: 500;
}
/* Config Summary */
.ralph-config-summary {
background: var(--bg-input);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.config-summary-row {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
font-size: 0.8rem;
}
.config-summary-row:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.config-label {
color: var(--text-dim);
}
.config-summary-row code {
background: var(--bg-dark);
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
color: var(--text);
}
/* Advanced Options */
.advanced-options {
margin-top: 1rem;
+35 -367
View File
@@ -243,11 +243,6 @@ Object.assign(CodemanApp.prototype, {
const svg = document.getElementById('connectionLines');
if (!svg) return;
// Check if Ralph wizard modal is open
const wizardModal = document.getElementById('ralphWizardModal');
const wizardOpen = wizardModal?.classList.contains('active');
const wizardContent = wizardOpen ? wizardModal.querySelector('.modal-content') : null;
// Collect visible regular subagent windows
const visibleSubagentWindows = [];
for (const [agentId, windowInfo] of this.subagentWindows) {
@@ -257,11 +252,6 @@ Object.assign(CodemanApp.prototype, {
visibleSubagentWindows.push({ agentId, windowInfo, win });
}
// Get plan subagent windows as array for distribution
const planSubagentArray = Array.from(this.planSubagents.entries())
.filter(([, data]) => data.element)
.map(([id, data]) => ({ id, ...data }));
// === PHASE 1: Batch all layout reads (getBoundingClientRect) ===
// Reading layout properties forces the browser to calculate layout.
// By batching all reads before any writes, we avoid repeated forced reflows.
@@ -272,36 +262,14 @@ Object.assign(CodemanApp.prototype, {
rects.set('sub:' + agentId, win.getBoundingClientRect());
}
// Read all plan subagent rects
for (const planAgent of planSubagentArray) {
rects.set('plan:' + planAgent.id, planAgent.element.getBoundingClientRect());
}
// Read wizard rect (if open)
let wizardRect = null;
if (wizardOpen && wizardContent) {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed).
// Read tab rects (only tabs that are actually needed).
// Only a painted row is cached: a row a search or filter hid has no line
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
// Read plan window rects for wizard-to-plan lines
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
for (const [agentId, windowData] of this.planSubagents) {
if (!windowData.element) continue;
const key = 'planwin:' + agentId;
if (!rects.has(key)) rects.set(key, windowData.element.getBoundingClientRect());
}
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
// === PHASE 2: DOM writes using cached rects (no more layout reads) ===
@@ -310,149 +278,34 @@ Object.assign(CodemanApp.prototype, {
for (const { agentId } of visibleSubagentWindows) {
const winRect = rects.get('sub:' + agentId);
// If wizard is open with plan subagents, connect regular subagents to plan subagent windows
if (wizardOpen && wizardContent && planSubagentArray.length > 0) {
// Find the nearest plan subagent window to connect to
let nearestPlanAgent = null;
let nearestDistance = Infinity;
// Connect the agent window to its parent TAB.
// Use the PERSISTENT subagentParentMap as the ONLY source of truth
const parentSessionId = this.subagentParentMap.get(agentId);
for (const planAgent of planSubagentArray) {
const planRect = rects.get('plan:' + planAgent.id);
const planCenterX = planRect.left + planRect.width / 2;
const planCenterY = planRect.top + planRect.height / 2;
const winCenterX = winRect.left + winRect.width / 2;
const winCenterY = winRect.top + winRect.height / 2;
const distance = Math.hypot(planCenterX - winCenterX, planCenterY - winCenterY);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestPlanAgent = planAgent;
}
}
if (nearestPlanAgent) {
const planRect = rects.get('plan:' + nearestPlanAgent.id);
// Draw line from plan subagent window to regular subagent window
let x1, y1, x2, y2;
const planCenterX = planRect.left + planRect.width / 2;
const winCenterX = winRect.left + winRect.width / 2;
if (winCenterX < planCenterX) {
x1 = planRect.left;
y1 = planRect.top + planRect.height / 2;
x2 = winRect.right;
y2 = winRect.top + winRect.height / 2;
} else {
x1 = planRect.right;
y1 = planRect.top + planRect.height / 2;
x2 = winRect.left;
y2 = winRect.top + winRect.height / 2;
}
const midX = (x1 + x2) / 2;
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line plan-to-subagent-line');
line.setAttribute('data-agent-id', agentId);
line.setAttribute('data-plan-agent-id', nearestPlanAgent.id);
svg.appendChild(line);
}
} else if (wizardOpen && wizardContent) {
// Wizard open but no plan subagents - connect directly to wizard
const winCenterX = winRect.left + winRect.width / 2;
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
let x1, y1, x2, y2;
if (winCenterX < wizardCenterX) {
x1 = wizardRect.left;
y1 = wizardRect.top + wizardRect.height / 2;
x2 = winRect.right;
y2 = winRect.top + winRect.height / 2;
} else {
x1 = wizardRect.right;
y1 = wizardRect.top + wizardRect.height / 2;
x2 = winRect.left;
y2 = winRect.top + winRect.height / 2;
}
const midX = (x1 + x2) / 2;
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line wizard-connection');
line.setAttribute('data-agent-id', agentId);
svg.appendChild(line);
} else {
// NORMAL MODE: Connect agent window to its parent TAB
// Use the PERSISTENT subagentParentMap as the ONLY source of truth
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId) {
// No parent known yet - skip this agent
continue;
}
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not painted (session closed, collapsed group, or hidden by a search)
continue;
}
// Draw a curved line from the tab to the window. Header strip: tab
// bottom-center → window top-center (vertical). Sidebar: tab right-edge →
// window left-edge (horizontal), otherwise the curve loops backwards
// underneath the sidebar. _tabAnchor/_tabConnectorPath live in app.js.
const anchor = this._tabAnchor(tabRect);
const path = this._tabConnectorPath(anchor, winRect);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line');
line.setAttribute('data-agent-id', agentId);
line.setAttribute('data-parent-tab', parentSessionId);
svg.appendChild(line);
if (!parentSessionId) {
// No parent known yet - skip this agent
continue;
}
}
// Draw lines from wizard to plan subagent windows (Opus agents during plan generation)
// Skip if agents are minimized to tab
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
for (const [agentId] of this.planSubagents) {
const winRect = rects.get('planwin:' + agentId);
if (!winRect) continue;
// Determine which side of wizard the window is on
const winCenterX = winRect.left + winRect.width / 2;
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
let x1, y1, x2, y2;
if (winCenterX < wizardCenterX) {
x1 = wizardRect.left;
y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50);
x2 = winRect.right;
y2 = winRect.top + winRect.height / 2;
} else {
x1 = wizardRect.right;
y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50);
x2 = winRect.left;
y2 = winRect.top + winRect.height / 2;
}
const midX = (x1 + x2) / 2;
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line wizard-connection plan-subagent-line');
line.setAttribute('data-plan-agent-id', agentId);
svg.appendChild(line);
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not painted (session closed, collapsed group, or hidden by a search)
continue;
}
// Draw a curved line from the tab to the window. Header strip: tab
// bottom-center → window top-center (vertical). Sidebar: tab right-edge →
// window left-edge (horizontal), otherwise the curve loops backwards
// underneath the sidebar. _tabAnchor/_tabConnectorPath live in app.js.
const anchor = this._tabAnchor(tabRect);
const path = this._tabConnectorPath(anchor, winRect);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line');
line.setAttribute('data-agent-id', agentId);
line.setAttribute('data-parent-tab', parentSessionId);
svg.appendChild(line);
}
// Ultracode floating run windows → parent tab (additional layer, ultracode-windows.js).
@@ -475,83 +328,6 @@ Object.assign(CodemanApp.prototype, {
this._applyLineEntrances?.(svg);
},
// ═══════════════════════════════════════════════════════════════
// Lazy Terminal Lifecycle
// ═══════════════════════════════════════════════════════════════
//
// Teammate terminal windows use xterm.js Terminal instances that consume
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
// invisible terminals. To avoid this, we dispose the Terminal when a
// window is minimized and lazily re-create it when restored.
//
// Flow:
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
//
// The tmux pane buffer is re-fetched from the API on restore. Regular
// (non-teammate) subagent windows use activity HTML and are unaffected
// by this optimization.
/**
* Dispose a teammate terminal when its window is minimized.
* Saves pane metadata so the terminal can be re-created on restore.
* No-op if the window has no teammate terminal.
*/
_disposeTeammateTerminalForMinimize(agentId) {
const termData = this.teammateTerminals.get(agentId);
if (!termData) return; // Not a teammate terminal window
const windowData = this.subagentWindows.get(agentId);
// Save pane metadata needed to re-create the terminal on restore
if (windowData) {
windowData._lazyTerminal = true;
windowData._lazyPaneTarget = termData.paneTarget;
windowData._lazySessionId = termData.sessionId;
}
// Dispose the resize observer
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
// (the activity HTML can serve as a lightweight placeholder while minimized)
this.teammateTerminals.delete(agentId);
},
/**
* Re-create a teammate terminal when its window is restored from minimized state.
* Fetches the current pane buffer from the API (tmux is the source of truth).
* No-op if the window doesn't have the _lazyTerminal flag.
*/
_restoreTeammateTerminalFromLazy(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData || !windowData._lazyTerminal) return;
const paneTarget = windowData._lazyPaneTarget;
const sessionId = windowData._lazySessionId;
// Clear lazy state
windowData._lazyTerminal = false;
windowData._lazyPaneTarget = null;
windowData._lazySessionId = null;
if (!paneTarget || !sessionId) return;
// Re-create the terminal using the same initTeammateTerminal flow
const paneInfo = { paneTarget, sessionId };
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
},
// ═══════════════════════════════════════════════════════════════
// Subagent Floating Windows
// ═══════════════════════════════════════════════════════════════
@@ -627,43 +403,9 @@ Object.assign(CodemanApp.prototype, {
finalY = topStart + visibleCount * (mobileCardHeight + mobileCardGap);
}
} else {
// Check if Ralph wizard modal is open - if so, position windows on the sides
const wizardModal = document.getElementById('ralphWizardModal');
const wizardOpen = wizardModal?.classList.contains('active');
let startX, startY, maxCols;
if (wizardOpen) {
// Wizard is ~720px wide, centered. Position windows on left/right sides
const wizardWidth = 720;
const centerX = viewportWidth / 2;
const wizardLeft = centerX - wizardWidth / 2;
const wizardRight = centerX + wizardWidth / 2;
// Alternate between left and right sides of the wizard
const leftSideSpace = wizardLeft - 20;
const rightSideSpace = viewportWidth - wizardRight - 20;
if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) {
// Even windows go to the right
startX = wizardRight + 20;
maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1;
} else if (leftSideSpace >= windowWidth) {
// Odd windows go to the left
startX = Math.max(10, wizardLeft - windowWidth - 20);
maxCols = 1; // Usually only room for 1 column on left
} else {
// Not enough side space, use right side
startX = wizardRight + 20;
maxCols = 1;
}
startY = 80; // Start higher when wizard is open
} else {
// Normal positioning
startX = 50;
startY = WINDOW_INITIAL_TOP_PX;
maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
}
const startX = 50;
const startY = WINDOW_INITIAL_TOP_PX;
const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1;
const col = windowCount % maxCols;
@@ -793,24 +535,7 @@ Object.assign(CodemanApp.prototype, {
win.style.display = 'none';
}
// Render content — check if this teammate has a tmux pane
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
if (paneInfo) {
if (shouldHide) {
// Window starts hidden — defer terminal creation until visible (lazy init).
// Saves ~75KB of DOM memory per hidden teammate terminal window.
const windowEntry = this.subagentWindows.get(agentId);
if (windowEntry) {
windowEntry._lazyTerminal = true;
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
windowEntry._lazySessionId = paneInfo.sessionId;
}
} else {
this.initTeammateTerminal(agentId, paneInfo, win);
}
} else {
this.renderSubagentWindowContent(agentId);
}
this.renderSubagentWindowContent(agentId);
// Focus on click
win.addEventListener('mousedown', () => {
@@ -882,10 +607,6 @@ Object.assign(CodemanApp.prototype, {
this.setAgentParentSessionId(agentId, parentSessionId);
}
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
// The terminal will be lazily re-created on restore via initTeammateTerminal().
this._disposeTeammateTerminalForMinimize(agentId);
// Always minimize to tab
windowData.element.style.display = 'none';
windowData.minimized = true;
@@ -956,18 +677,6 @@ Object.assign(CodemanApp.prototype, {
}
this.subagentWindows.clear();
// Clean up all teammate terminals
for (const [, termData] of this.teammateTerminals) {
if (termData.resizeObserver) termData.resizeObserver.disconnect();
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
}
this.teammateTerminals.clear();
this.teammatePanesByName.clear();
// Clean up all log viewer windows with their EventSources and drag listeners
for (const data of this.logViewerWindows.values()) {
if (data.eventSource) {
@@ -986,20 +695,6 @@ Object.assign(CodemanApp.prototype, {
}
this.logViewerWindows.clear();
// Clean up plan subagent windows (wizard agents)
if (this.planSubagents) {
for (const windowData of this.planSubagents.values()) {
if (windowData.dragListeners) {
document.removeEventListener('mousemove', windowData.dragListeners.move);
document.removeEventListener('mouseup', windowData.dragListeners.up);
}
if (windowData.element) {
windowData.element.remove();
}
}
this.planSubagents.clear();
}
// Clean up all image popup windows with their drag listeners
for (const popupData of this.imagePopups.values()) {
if (popupData.dragListeners) {
@@ -1018,25 +713,7 @@ Object.assign(CodemanApp.prototype, {
// Clean up ultracode floating run windows (re-seeded from data.workflowRuns on reconnect)
if (typeof this.removeAllUltracodeWindows === 'function') this.removeAllUltracodeWindows();
// Clear orphaned plan generation state
this.activePlanOrchestratorId = null;
this._planProgressHandler = null;
this.planGenerationStopped = true;
if (this.planGenerationAbortController) {
this.planGenerationAbortController.abort();
this.planGenerationAbortController = null;
}
// Clean up wizard-specific timers (leak fix: not cleared on SSE reconnect)
if (this.wizardMinimizedTimer) {
clearInterval(this.wizardMinimizedTimer);
this.wizardMinimizedTimer = null;
}
// Clean up wizard drag listeners (leak fix: document-level handlers)
if (typeof this.cleanupWizardDragging === 'function') this.cleanupWizardDragging();
// Deactivate focus trap if wizard was open (leak fix: keydown listener)
// Deactivate a focus trap a modal left active (leak fix: keydown listener)
if (this.activeFocusTrap) {
this.activeFocusTrap.deactivate();
this.activeFocusTrap = null;
@@ -1045,9 +722,6 @@ Object.assign(CodemanApp.prototype, {
// Clear minimized agents tracking
this.minimizedSubagents.clear();
// Update monitor panel
this.renderMonitorPlanAgents();
// Update connection lines (should be empty now)
this.updateConnectionLines();
},
@@ -1084,12 +758,6 @@ Object.assign(CodemanApp.prototype, {
}
windowData.minimized = false;
// Lazily re-create teammate terminal if it was disposed on minimize.
// Only re-create when the window is actually becoming visible.
if (shouldShow && windowData._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
this.updateConnectionLines();
// Restack all visible mobile windows so restored ones don't overlap
this.relayoutMobileSubagentWindows();
+3 -24
View File
@@ -428,8 +428,8 @@
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
// global so the main terminal and every other module that builds a terminal (tiles,
// the split pane) can read the current skin's palette.
//
// ⚠️ The selection key is `selectionBackground`, xterm's name for it since v5 (#360).
// An ITheme is a plain object handed straight to xterm, so an unknown key is not an
@@ -6585,16 +6585,6 @@ Object.assign(CodemanApp.prototype, {
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
for (const [, entry] of this.teammateTerminals || []) {
if (!entry?.terminal) continue;
entry.terminal.options.fontWeight = fontWeight;
entry.terminal.options.fontWeightBold = fontWeightBold;
try {
entry.fitAddon?.fit();
} catch {
/* pane not laid out yet — its own resize observer refits it */
}
}
this._forEachTile?.((tile) => {
tile.terminal.options.fontWeight = fontWeight;
tile.terminal.options.fontWeightBold = fontWeightBold;
@@ -7158,7 +7148,7 @@ Object.assign(CodemanApp.prototype, {
}, 100);
},
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Re-theme all live xterm terminals (main pane and tiles) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
@@ -7176,17 +7166,6 @@ Object.assign(CodemanApp.prototype, {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
this._forEachTile?.((tile) => {
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
tile.terminal.options.theme = { ...theme };
-1
View File
@@ -7,7 +7,6 @@
* only session:terminal is filtered by subscription)
* - Tab switching: terminal buffer loading, session creation + switch
* - Terminal data cap / backpressure recovery
* - Lazy teammate terminal lifecycle
* - SSE padding strategy (only latency-sensitive events)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
-4
View File
@@ -138,23 +138,19 @@ describe('Codeman light skins', () => {
it('switches live terminals between light and dark contrast policies', () => {
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };
const refreshFont = vi.fn();
const app = {
terminal: main,
teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
_localEchoOverlay: { refreshFont },
};
terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
expect(main.options.minimumContrastRatio).toBe(4.5);
expect(teammate.options.minimumContrastRatio).toBe(4.5);
expect((main.options.theme as Record<string, string>).background).toBe('#f6f8fa');
expect(refreshFont).toHaveBeenCalledTimes(1);
terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
expect(main.options.minimumContrastRatio).toBe(1);
expect(teammate.options.minimumContrastRatio).toBe(1);
expect((main.options.theme as Record<string, string>).background).toBe('#161b23');
expect(refreshFont).toHaveBeenCalledTimes(2);
});
-1
View File
@@ -277,7 +277,6 @@ describe('vertical rail session search in Chromium', () => {
['ag1', { element: document.getElementById('subWin'), minimized: false, hidden: false }],
]);
app.subagentParentMap = new Map([['ag1', 'alpha']]);
app.planSubagents = new Map();
});
try {
const anchor = await rowAnchor();
-1
View File
@@ -801,7 +801,6 @@ describe('floating windows whose parent row the search hid', () => {
['ag1', { element: document.getElementById('subWin'), minimized: false, hidden: false }],
]);
app.subagentParentMap = new Map([['ag1', 'review']]);
app.planSubagents = new Map();
app.ultracodeWindows = new Map([
['run1', { element: document.getElementById('runWin'), parentSessionId: 'review' }],
]);
+4 -30
View File
@@ -11,11 +11,9 @@
*
* Three things are pinned here because each fails silently:
*
* - A live save reaches the echo overlays and the Agent Teams panes. Both
* cache the weight (the overlays paint it into their spans, the panes read
* their options at construction), so without the propagation the characters
* being typed, or a pane left open across the save, keep the old weight
* beside a repainted terminal.
* - A live save reaches the echo overlays. They cache the weight and paint
* it into their spans, so without the propagation the characters being
* typed keep the old weight beside a repainted terminal.
* - An unchanged save is a no-op, so opening and closing App Settings does not
* churn the terminal.
* - The bundled face is declared over its full axis. The `@font-face`
@@ -65,19 +63,11 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
function makeApp(
opts: {
teammates?: number;
terminal?: ReturnType<typeof fakeTerminal> | null;
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
} = {}
) {
const fit = vi.fn();
const teammateFits: ReturnType<typeof vi.fn>[] = [];
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
for (let i = 0; i < (opts.teammates ?? 0); i++) {
const teammateFit = vi.fn();
teammateFits.push(teammateFit);
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
}
const app = {
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
// Secondary panes (the split pane's second terminal) are reached through
@@ -99,12 +89,11 @@ function makeApp(
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
fitAddon: { fit },
teammateTerminals,
_localEchoOverlay: { refreshFont: vi.fn() },
_predictiveEcho: { refreshFont: vi.fn() },
_terminalFontReady: null as unknown,
};
return { app, fit, teammateFits, teammateTerminals };
return { app, fit };
}
describe('applyTerminalFontWeights', () => {
@@ -152,21 +141,6 @@ describe('applyTerminalFontWeights', () => {
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
});
it('reaches Agent Teams panes, which read their options at construction', () => {
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
terminalFontWeight: '300',
terminalFontWeightBold: '800',
});
for (const [, entry] of teammateTerminals) {
expect(entry.terminal.options.fontWeight).toBe(300);
expect(entry.terminal.options.fontWeightBold).toBe(800);
}
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
});
it('restores xterm’s own defaults when the setting is cleared', () => {
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });