mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b667264b4 | ||
|
|
e3d5fd90cd | ||
|
|
713f632a64 | ||
|
|
92b5dfacb0 | ||
|
|
890a1b0902 | ||
|
|
a360763890 | ||
|
|
57899f879e | ||
|
|
d4fe3afc9d | ||
|
|
77fcd65b4a | ||
|
|
2b57c595df | ||
|
|
070e8da81b | ||
|
|
0e82443222 | ||
|
|
323730a29d | ||
|
|
5ac516dd3b | ||
|
|
5130ca6633 | ||
|
|
b87bc6871b | ||
|
|
c367b12f77 |
@@ -1,5 +1,38 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.26.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal rendering fixes, a Ctrl+V paste fix, an iOS Safari toolbar fix, a 2GB download cap, and a Blur entrance animation.
|
||||
|
||||
### Terminal rendering
|
||||
|
||||
Three independent causes behind #398, where opening a session rendered a frame with characters spliced into each other and left the caret on the composer's border instead of its input line, until the CLI next wrote anything:
|
||||
- **The full-history replay now keeps row alignment** (#395). The linear capture path never restored the cursor, so every cursor-relative update the CLI sent afterwards was measured from the status line instead of the pane's real position, and four transforms that each can delete a line (trailing-blank stripping, redraw-bloat stripping, the pre-banner trim, leading-whitespace removal) shifted the frame out from under it. The full-history path now appends the pane's own cursor position and keeps every row, so row N of the reply is row N of the pane. The visible-frame and tail paths are untouched.
|
||||
- **The first fit waits for the terminal font** (#396). A cell measured against a fallback font gives the wrong column and row count, so the pane was sized twice and the CLI repainted for a shape that no longer matched the frame on screen. `selectSession` now holds for the font before measuring, bounded at 2s so a font that never arrives cannot strand a session, and it ends by re-measuring explicitly — `FitAddon.proposeDimensions()` divides by a cached cell size and nothing in it listens for font loading, so waiting alone would still divide by the fallback cell.
|
||||
- **A detached session's own window owns its pane size** (#397). Popping a session out left both windows sizing one PTY, and the dashboard's terminal is narrower than the popup because the session rail takes width the popup does not have, so the CLI drew frames that fit neither. The dashboard now withholds the resize send (never the local reflow) for a session showing in its own window, and takes sizing back on redock.
|
||||
|
||||
### Other fixes
|
||||
- **Ctrl+V no longer pastes twice** (#394). One keypress delivered two paste events to the clipboard trap: Firefox dispatches a trusted event for `document.execCommand('paste')` and then returns `false`, and the key's own default action fires another, because xterm's custom key handler returns false without cancelling the keydown. Right-click → Paste has no keydown, which is why only the keyboard duplicated. The trap now consumes exactly one event per keypress.
|
||||
- **iOS Safari: the phone toolbar sits on Safari's bottom bar** (#391, #392). The toolbar was lifted by `100vh - --app-height`, which on iPhone Safari measures the bar's collapsible height rather than an overlap — fixed elements there already stop above the bar — leaving an empty ~40px band and padding the terminal by the same amount. The lift is now `--chrome-overlap` (`innerHeight` minus the visual viewport height), which is 0 on iPhone Safari and equals the real overlap anywhere fixed elements do land behind the chrome.
|
||||
|
||||
### Downloads
|
||||
|
||||
`file-raw`, the attachment `/raw` route and `GET /api/download` now cap at **2GB** instead of 50MB, configurable via `CODEMAN_MAX_DOWNLOAD_BYTES` (`0` = unlimited). The old cap was memory protection for a `readFile()` that no longer exists: those bodies stream and answer `Range` requests, so size costs a read stream rather than RSS (measured: a 600MB download moved peak RSS by ~37MB), and all the cap still did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file; it now streams, advertises `Accept-Ranges` and is resumable. Refusals move from `400` to `413`, the correct status for the case.
|
||||
|
||||
### Blur entrance animation
|
||||
|
||||
A new opt-in `Blur` style on all four entrance surfaces (tabs, agent windows, the terminal pane, connection lines), plus a `Soft focus` theme that sets all four: an iOS-style focus pull where the thing arrives out of focus and the blur fades off it as the opacity comes up. App Settings → Appearance → Entrance Animations, or mix per surface at `?animlab=1`. Entrance animations stay off by default, so an untouched install is unchanged.
|
||||
|
||||
### Maintainer tooling
|
||||
|
||||
The PR bot now fails fast when the review model's budget is spent, instead of hanging a review for the full 40-minute timeout and burning its retry cap.
|
||||
|
||||
### Thanks
|
||||
- @irisitymichaelgrundberg for #394, #395, #396 and #397, and for the #398 investigation that separated three causes behind one symptom
|
||||
- @JDProfresh for reporting #391 and fixing it in #392
|
||||
|
||||
## 1.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -75,7 +75,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.26.1 (must match `package.json`)
|
||||
**Version**: 1.26.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -247,12 +247,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`.
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which appends a hidden `contenteditable` trap, focuses it, and reads the clipboard out of the paste event that lands there. Images upload and their saved paths are typed into the session; text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event.** Two routes deliver one for a single keypress and Firefox fires both: `document.execCommand('paste')` dispatches a trusted event and still returns `false`, because the trap cancels it, while Chromium refuses that command and dispatches nothing; separately, the keydown's own default action delivers a paste to the now-focused trap, because returning `false` from the custom key handler never cancels the DOM event (see smart copy above). Measured on a live install: Firefox two events per keypress, Chromium and WebKit one. Handling both wrote the clipboard to the PTY twice, and right-click → Paste stayed correct because it carries no keydown. ⚠️ Removing the `execCommand('paste')` call would also end the doubling, and all three engines still deliver one event without it, but it stays for the mobile engines a desktop measurement cannot reach: where a browser aims the key's default action at the element focused when the keydown began, the command is the only route into the trap, and the trap is the only place image blobs are read. The one-shot flag lives on the trap rather than on a browser check, so any count produces one insert. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
@@ -270,7 +272,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree. ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. A query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw` and the attachments `/raw` route always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ **The size cap on all three is a sanity bound, not memory protection** (`MAX_FILE_DOWNLOAD_BYTES` in `config/buffer-limits.ts`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the `readFile()` it described was replaced by `sendFileBody()`, so all it did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file, and now shares `sendFileBody()` with the other two. ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
@@ -292,7 +294,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the ≤430px block, because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
|
||||
|
||||
|
||||
@@ -691,6 +691,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+C` | Copy selection, or interrupt when nothing is selected |
|
||||
| `Ctrl+Shift+C` | Copy selection (never interrupts) |
|
||||
| `Ctrl/Cmd+V` | Paste, or upload a clipboard image and paste its path |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -90,7 +90,7 @@ has to be copied; set them here to use a different bot.
|
||||
| `PR_BOT_POLL_INTERVAL` | `600` | Seconds between GitHub polls (minimum 60). |
|
||||
| `PR_BOT_MAIN_CHECKOUT` | the repo this script is in | The repository the clones share objects with and fetch from. |
|
||||
| `PR_BOT_DATA_DIR` | `~/.codeman/pr-bot` | State, briefs, reports, clones. |
|
||||
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `claude-fable-5-1`. |
|
||||
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `opus[1m]`. ⚠️ Pick a model whose budget can absorb a re-review of every open PR on every head commit: when it runs out, Claude Code answers the limit **inside the turn** and the reviewer has nothing to write. The bot now names that failure in seconds (`findModelLimitNotice`) instead of burning the whole `PR_BOT_REVIEW_TIMEOUT`, and a limit does not spend the per-head retry budget, so the queue resumes by itself once the budget does. |
|
||||
| `PR_BOT_EFFORT` | unset | Codeman `effort` for the review sessions. |
|
||||
| `PR_BOT_REVIEW_TIMEOUT` | `40` | Minutes before a review is abandoned. |
|
||||
| `PR_BOT_FOLLOWUP_TIMEOUT` | `20` | Minutes before a follow-up is abandoned. |
|
||||
|
||||
@@ -312,8 +312,8 @@ TOCTOU window.
|
||||
| Route | Cap | Notes |
|
||||
|-------|-----|-------|
|
||||
| `file-content` | 10 MB | text preview |
|
||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
||||
| `file-raw` | 2 GB (`CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||
| `GET /api/download` | same cap | forced `attachment`; sensitive‑path blocklist; streamed, `Range`-aware |
|
||||
|
||||
### SVG / content‑type XSS
|
||||
|
||||
@@ -340,7 +340,7 @@ the attachment guard below.
|
||||
|
||||
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
|
||||
for a host file so browser requests carry the id, never an absolute path. Serving
|
||||
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
|
||||
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download cap,
|
||||
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
|
||||
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
|
||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||
|
||||
@@ -14,6 +14,7 @@ works, slash commands included.
|
||||
| `Shift+Enter` / `Ctrl+Enter` | Newline without sending. |
|
||||
| `Ctrl+C` | Copy if text is selected, otherwise interrupt. |
|
||||
| `Ctrl+Shift+C` | Copy, never interrupts. |
|
||||
| `Ctrl+V` | Paste. A clipboard image uploads instead. |
|
||||
| `Ctrl+L` | Clear the terminal. |
|
||||
|
||||
### Exactly-once delivery
|
||||
|
||||
@@ -25,6 +25,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| `Ctrl+Enter` | Same. |
|
||||
| `Ctrl+C` | Copy the selection, or interrupt when nothing is selected. |
|
||||
| `Ctrl+Shift+C` | Copy the selection. Never interrupts. |
|
||||
| `Ctrl+V` | Paste. An image on the clipboard uploads and pastes its file path instead. |
|
||||
| `Ctrl+L` | Clear the terminal. |
|
||||
| `Ctrl+Shift+R` | Restore terminal size. |
|
||||
| `Ctrl` `+` / `Ctrl` `-` | Font size. |
|
||||
|
||||
@@ -19,7 +19,9 @@ It renders what it can:
|
||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||
| Anything else | Download. |
|
||||
|
||||
Caps: 10 MB for text preview, 50 MB for raw and download. Sensitive paths (`.env`, anything
|
||||
Caps: 10 MB for text preview, 2 GB for raw and download (set `CODEMAN_MAX_DOWNLOAD_BYTES`
|
||||
to change it, `0` for no limit — these bodies are streamed, so a large file costs a read
|
||||
stream rather than server memory). Sensitive paths (`.env`, anything
|
||||
matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML
|
||||
are served as downloads rather than rendered, so they cannot execute in the page.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.1",
|
||||
"version": "1.26.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.1",
|
||||
"version": "1.26.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.1",
|
||||
"version": "1.26.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+19
-5
@@ -19,7 +19,7 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { CodemanClient, stripAnsi, type TurnOutcome } from './codeman-client.js';
|
||||
import { CodemanClient, ModelLimitError, stripAnsi, type TurnOutcome } from './codeman-client.js';
|
||||
import type { PrBotConfig } from './config.js';
|
||||
import {
|
||||
approveWorkflowRun,
|
||||
@@ -434,7 +434,10 @@ export class PrBot {
|
||||
if (report && !existsSync(reportMdPath)) writeFileSync(reportMdPath, last);
|
||||
}
|
||||
await this.recordClaudeSessionId(sessionId, rec);
|
||||
if (!report) throw new Error(await this.describeFailure(sessionId, outcome, started, last));
|
||||
if (!report) {
|
||||
const why = await this.describeFailure(sessionId, outcome, started, last);
|
||||
throw outcome.kind === 'limit' ? new ModelLimitError(why) : new Error(why);
|
||||
}
|
||||
|
||||
const durationMin = Math.max(1, Math.round((Date.now() - started) / 60_000));
|
||||
Object.assign(rec, {
|
||||
@@ -460,9 +463,15 @@ export class PrBot {
|
||||
if (rec) {
|
||||
rec.status = 'failed';
|
||||
rec.lastError = reason;
|
||||
rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1;
|
||||
rec.failedSha = rec.headSha;
|
||||
const givingUp = rec.failedAttempts >= MAX_AUTO_RETRIES;
|
||||
// A spent model budget is an account condition, not a bad PR, so it must not
|
||||
// spend the per-head retry budget: otherwise one exhausted afternoon marks every
|
||||
// open PR "not retrying on my own" and none of them come back when credits do.
|
||||
const accountCondition = err instanceof ModelLimitError;
|
||||
if (!accountCondition) {
|
||||
rec.failedAttempts = rec.failedSha === rec.headSha ? (rec.failedAttempts ?? 0) + 1 : 1;
|
||||
rec.failedSha = rec.headSha;
|
||||
}
|
||||
const givingUp = !accountCondition && (rec.failedAttempts ?? 0) >= MAX_AUTO_RETRIES;
|
||||
await this.telegram
|
||||
.sendMessage(
|
||||
formatReviewFailure(rec, reason) +
|
||||
@@ -523,6 +532,11 @@ export class PrBot {
|
||||
}
|
||||
case 'exit':
|
||||
return 'the session exited before writing a report';
|
||||
case 'limit':
|
||||
return (
|
||||
`the model budget for these review sessions is spent, so the reviewer never started:\n${outcome.message}\n` +
|
||||
'Point PR_BOT_MODEL in ~/.codeman/pr-bot.env at a model with headroom and restart codeman-pr-bot.'
|
||||
);
|
||||
case 'timeout':
|
||||
return `timed out after ${minutes} min without a report`;
|
||||
default:
|
||||
|
||||
@@ -50,7 +50,42 @@ export interface SessionRecord {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export type TurnOutcome = { kind: 'stop' } | { kind: 'blocked' } | { kind: 'exit' } | { kind: 'timeout' };
|
||||
export type TurnOutcome =
|
||||
| { kind: 'stop' }
|
||||
| { kind: 'blocked' }
|
||||
| { kind: 'exit' }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'limit'; message: string };
|
||||
|
||||
/**
|
||||
* Claude Code answers a spent model budget INSIDE the turn ("You've reached your Fable
|
||||
* limit. Run /usage-credits to continue or switch models with /model.") and then simply
|
||||
* sits there with nothing to write. Measured 2026-09-08: four reviews each burned their
|
||||
* whole 40-minute deadline and reported a bare "timed out without a report", which reads
|
||||
* as a hung reviewer rather than an account that needs attention, and the retries spent
|
||||
* the per-head budget so the PRs would not have been picked up again once credits
|
||||
* returned. Matching the notice turns 40 silent minutes into a named failure in seconds.
|
||||
*
|
||||
* Deliberately model-agnostic: the same sentence is printed for every model, and the
|
||||
* apostrophe is typographic on the pane, so neither the model name nor `'` is matched.
|
||||
*/
|
||||
const MODEL_LIMIT_PATTERN = /reached your [^\n]{0,40}?\blimit\b|\/usage-credits/i;
|
||||
|
||||
/**
|
||||
* Thrown instead of a plain Error when a review died on a spent model budget, so the
|
||||
* caller can tell an account condition apart from a review that genuinely failed.
|
||||
*/
|
||||
export class ModelLimitError extends Error {
|
||||
override readonly name = 'ModelLimitError';
|
||||
}
|
||||
|
||||
/** The limit notice as one clean line, or undefined if the screen does not carry it. */
|
||||
export function findModelLimitNotice(screen: string): string | undefined {
|
||||
const line = stripAnsi(screen)
|
||||
.split('\n')
|
||||
.find((l) => MODEL_LIMIT_PATTERN.test(l));
|
||||
return line?.replace(/^[\s>|]*(?:\u23bf|\u2514|\u256d|\u2570|\u23a2|\u2502|\u23bd)?\s*/u, '').trim() || undefined;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -241,9 +276,22 @@ export class CodemanClient {
|
||||
if (!r.delivered) throw new Error('the prompt was not delivered (pane dead?)');
|
||||
let wait = r.wait;
|
||||
let nudged = false;
|
||||
// Only consulted when the turn produced nothing, so a review that merely QUOTES the
|
||||
// notice in its report cannot be mistaken for one that hit it.
|
||||
const limitNotice = async (): Promise<string | undefined> =>
|
||||
findModelLimitNotice(await this.terminalText(id).catch(() => ''));
|
||||
while (true) {
|
||||
if (wait && !wait.timedOut) return toOutcome(wait);
|
||||
if (wait && !wait.timedOut) {
|
||||
const outcome = toOutcome(wait);
|
||||
if (outcome.kind === 'stop' && !opts.isDone?.()) {
|
||||
const limit = await limitNotice();
|
||||
if (limit) return { kind: 'limit', message: limit };
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
if (opts.isDone?.()) return { kind: 'stop' };
|
||||
const limit = await limitNotice();
|
||||
if (limit) return { kind: 'limit', message: limit };
|
||||
const remaining = opts.deadlineMs - (Date.now() - started);
|
||||
if (remaining <= 0) return { kind: 'timeout' };
|
||||
if (!nudged) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* worktree's project settings through the git common dir, i.e. the MAIN checkout's
|
||||
* `.claude/settings.local.json`, whose model pin then silently overrides anything
|
||||
* written into the worktree (measured 2026-09-05: a worktree pinned to
|
||||
* `claude-fable-5-1` reported `claude-opus-5[1m]`). A shared clone has its own
|
||||
* `claude-fable-5-1` reported `claude-opus-5[1m]`, the main checkout's pin). A shared clone has its own
|
||||
* project root, so Codeman's `modelOverride` and hooks land where the CLI reads them,
|
||||
* while `objects/info/alternates` keeps the object store shared (no duplication).
|
||||
*
|
||||
|
||||
@@ -112,3 +112,50 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse a byte-limit env var, where `0` explicitly means "no limit".
|
||||
*
|
||||
* The `parseInt(...) || default` idiom used elsewhere in this file cannot
|
||||
* express that: it treats 0 as falsy and silently restores the default.
|
||||
*/
|
||||
function parseByteLimitEnv(raw: string | undefined, fallback: number): number {
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a file served by the raw/download file routes:
|
||||
* `GET /api/sessions/:id/file-raw` (the Files panel's download link and the
|
||||
* file-preview overlay), the attachment `/raw` route, and `GET /api/download`.
|
||||
*
|
||||
* ⚠️ This is a sanity bound, NOT memory protection. All three bodies are
|
||||
* STREAMED and `Range`-aware (`sendFileBody` in file-routes.ts), so a large
|
||||
* file costs one read stream rather than its size in RSS. The historical 50MB
|
||||
* cap predates that streaming rewrite and its "prevent memory exhaustion"
|
||||
* comment described a `readFile()` that no longer exists — all it did was
|
||||
* refuse legitimate downloads of build artifacts, videos and archives.
|
||||
*
|
||||
* Set `CODEMAN_MAX_DOWNLOAD_BYTES=0` to remove the cap entirely.
|
||||
* Override: CODEMAN_MAX_DOWNLOAD_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_FILE_DOWNLOAD_BYTES = parseByteLimitEnv(
|
||||
process.env.CODEMAN_MAX_DOWNLOAD_BYTES,
|
||||
2 * 1024 * 1024 * 1024 // 2GB
|
||||
);
|
||||
|
||||
/** True when `size` exceeds the download cap (a cap of 0 means unlimited). */
|
||||
export function exceedsDownloadLimit(size: number): boolean {
|
||||
return MAX_FILE_DOWNLOAD_BYTES > 0 && size > MAX_FILE_DOWNLOAD_BYTES;
|
||||
}
|
||||
|
||||
/** Human-readable "File too large (…)" message for a refused download. */
|
||||
export function downloadTooLargeMessage(size: number): string {
|
||||
return `File too large (${Math.round(size / 1024 / 1024)}MB > ${Math.round(MAX_FILE_DOWNLOAD_BYTES / 1024 / 1024)}MB limit). Raise or remove it with CODEMAN_MAX_DOWNLOAD_BYTES (0 = unlimited).`;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,12 @@ export interface RespawnPaneOptions {
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
export interface PaneCaptureOptions {
|
||||
/** Capture the entire tmux scrollback instead of just the visible frame. */
|
||||
/**
|
||||
* Capture the entire scrollback instead of just the visible frame, as linear
|
||||
* text ending with a cursor move back to the pane's caret position. An
|
||||
* implementation returns '' when the pane holds nothing visible, which the
|
||||
* caller reads as "nothing to replay" and keeps its existing history.
|
||||
*/
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
|
||||
+108
-39
@@ -528,6 +528,73 @@ export function normalizeScrollbackEol(buffer: string): string {
|
||||
return buffer.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/** Pane geometry and caret position, as `display-message` reports them. */
|
||||
interface PaneCursorGeometry {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pane's cursor and size, or null when tmux cannot say.
|
||||
*
|
||||
* Every field is validated together: a caller that gets a value back can place
|
||||
* a caret with it, and one that gets null must not try.
|
||||
*/
|
||||
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = run().trim();
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
return null;
|
||||
}
|
||||
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
!Number.isFinite(cursorX) ||
|
||||
!Number.isFinite(cursorY) ||
|
||||
!Number.isFinite(cols) ||
|
||||
!Number.isFinite(rows) ||
|
||||
cursorX < 0 ||
|
||||
cursorY < 0 ||
|
||||
cols <= 0 ||
|
||||
rows <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { cols, rows, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/** SGR attributes, which is all `capture-pane -e` emits. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g;
|
||||
|
||||
/** Whether a capture holds anything a reader would see, styles discounted. */
|
||||
export function hasVisibleContent(capture: string): boolean {
|
||||
return /\S/.test(capture.replace(CAPTURE_STYLE_SEQUENCE, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the caret back where the pane has it, counting UP from the bottom of what
|
||||
* was just replayed.
|
||||
*
|
||||
* Relative rather than absolute (`CUP`) on purpose. `\x1b[<row>;<col>H` numbers
|
||||
* rows from the top of the browser's screen, so it only lands correctly while
|
||||
* the browser's row count equals the pane's — and it need not, because
|
||||
* `resizeWindow` fires its tmux resize without waiting, so a capture can be
|
||||
* taken before a requested resize has been applied. Counting up from the last
|
||||
* replayed row is anchored to the content instead, which is the thing both ends
|
||||
* genuinely share.
|
||||
*/
|
||||
export function formatCursorRestore(geometry: PaneCursorGeometry): string {
|
||||
const up = Math.max(0, geometry.rows - 1 - geometry.cursorY);
|
||||
const right = Math.max(0, geometry.cursorX);
|
||||
// `\r` first so the column is known: the replay leaves the caret wherever the
|
||||
// last row's text ended.
|
||||
return `${up > 0 ? `\x1b[${up}A` : ''}\r${right > 0 ? `\x1b[${right}C` : ''}`;
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
@@ -3199,11 +3266,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* the browser xterm reproduces the live frame. Used for fast tab switches.
|
||||
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
|
||||
* the tmux scrollback (COD-47, bounded to the configured history limit),
|
||||
* returned as linear scrollback text with SGR codes preserved (NOT
|
||||
* repositioned — a multi-screen history can't be painted into a single
|
||||
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
|
||||
* hard-wrapped at the pane width so they reflow in the browser xterm.
|
||||
* Used for full page reloads so the user gets back their scroll history.
|
||||
* returned as linear scrollback text with SGR codes preserved. Rows are not
|
||||
* repainted at absolute positions — a multi-screen history can't be painted
|
||||
* into a single visible frame — but the capture DOES end with a cursor move
|
||||
* putting the caret back where the pane has it, counted up from the last
|
||||
* replayed row. `-J` re-joins lines hard-wrapped at the pane width so they
|
||||
* reflow in the browser xterm. Used for full page reloads so the user gets
|
||||
* back their scroll history. Returns '' for a pane holding nothing visible,
|
||||
* so the caller keeps whatever history it already had.
|
||||
* Caveat: lines tmux has already evicted past its history-limit are gone.
|
||||
*/
|
||||
/**
|
||||
@@ -3262,42 +3332,41 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
execOpts.maxBuffer =
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
|
||||
/\n+$/g,
|
||||
''
|
||||
);
|
||||
// Full-history spans many screens — return it as raw linear scrollback
|
||||
// rather than repainting rows at single-screen absolute positions. tmux
|
||||
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
|
||||
// xterm (convertEol:false) starts each replayed line at column 0 instead
|
||||
// of staircasing diagonally (COD-138).
|
||||
if (fullHistory) {
|
||||
return normalizeScrollbackEol(buffer);
|
||||
}
|
||||
try {
|
||||
const cursor = execSync(
|
||||
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
|
||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
||||
// it settles both how the capture is trimmed and whether a cursor move is
|
||||
// appended, and those two have to agree: trailing blank rows are only safe
|
||||
// to keep when a move follows to put the caret back above them.
|
||||
const geometry = queryPaneCursor(() =>
|
||||
execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
Number.isFinite(cursorX) &&
|
||||
Number.isFinite(cursorY) &&
|
||||
Number.isFinite(cols) &&
|
||||
Number.isFinite(rows) &&
|
||||
cursorX >= 0 &&
|
||||
cursorY >= 0 &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
|
||||
}
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
|
||||
if (fullHistory) {
|
||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||
// Keeping the blank rows here would park the caret at the bottom of the
|
||||
// pane with nothing to correct it — worse than not trying at all.
|
||||
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
|
||||
// Take the line terminator off and nothing else. The trailing blank rows
|
||||
// that remain are the real bottom of the screen, and the cursor move
|
||||
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
|
||||
// `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at
|
||||
// column 0 instead of staircasing diagonally (COD-138).
|
||||
const trimmed = rawCapture.replace(/\n$/, '');
|
||||
// An all-blank pane has to keep reading as "nothing to replay". The caller
|
||||
// treats an empty string as "capture unavailable" and keeps the byte
|
||||
// history; blank rows plus a cursor move are not empty, so without this a
|
||||
// blank pane REPLACES that history with a blank screen — the downgrade
|
||||
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
|
||||
// side where that guard cannot see it.
|
||||
if (!hasVisibleContent(trimmed)) return '';
|
||||
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
|
||||
}
|
||||
|
||||
const buffer = rawCapture.replace(/\n+$/g, '');
|
||||
if (geometry) return formatPaneSnapshot(buffer.split('\n'), geometry);
|
||||
// Cursor query failed or geometry was invalid, so we skip the absolute-
|
||||
// positioned snapshot repaint and fall back to the raw capture. Normalize
|
||||
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
|
||||
|
||||
+37
-1
@@ -1332,7 +1332,11 @@ class CodemanApp {
|
||||
this._redock(id);
|
||||
}
|
||||
|
||||
/** Clear all dashboard-side detached state/timers for a session. */
|
||||
/** Clear all dashboard-side detached state/timers for a session, and take its
|
||||
* sizing back: the popup owned the pane while it was open, so the dashboard's
|
||||
* record of it is stale and the session it is showing needs re-measuring.
|
||||
* ⚠️ Not idempotent — each call re-asserts, so a path that redocks twice for
|
||||
* one close sends two SIGWINCHs. */
|
||||
_redock(id) {
|
||||
const t = this._detachWatchTimers.get(id);
|
||||
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
|
||||
@@ -1340,6 +1344,21 @@ class CodemanApp {
|
||||
this._detachOrphanStrikes.delete(id);
|
||||
this.detachedWindows.delete(id);
|
||||
this._markDetached(id, false);
|
||||
// While the popup owned this session the dashboard sent no resizes, so
|
||||
// `_lastResizeDims` — one value for the whole window — no longer describes
|
||||
// the PTY, which the popup has been sizing. Clearing it makes the next
|
||||
// sendResize report truthfully, on every redock path rather than only the
|
||||
// active one: `selectSession` reads that answer to decide whether to wait
|
||||
// for the TUI's redraw, and a false "unchanged" makes it fetch the frame
|
||||
// before the redraw lands.
|
||||
this._lastResizeDims = null;
|
||||
// Sizing comes back with the session. `force` buys a guaranteed repaint for
|
||||
// the case where popup and dashboard happened to agree on a size; the server
|
||||
// already resizes on its own comparison against the real pane whenever the
|
||||
// two differ.
|
||||
if (this.sessions.has(id) && id === this.activeSessionId) {
|
||||
this.sendResize(id, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
|
||||
@@ -5906,6 +5925,23 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Hold for the terminal font before measuring anything. A cell measured
|
||||
// against a fallback font gives the wrong column and row count, and the
|
||||
// correction would land after the replay, leaving the CLI drawing against a
|
||||
// frame the terminal no longer shows. Resolves immediately once the font is
|
||||
// in, so this costs a tab switch nothing after the first load, and it is
|
||||
// bounded, so a font that never arrives cannot strand the session.
|
||||
// ⚠️ BEFORE `_beginBufferLoad` on purpose: inside it, every live SSE event
|
||||
// for this session queues instead of painting, so a slow font would hold
|
||||
// output back rather than merely mis-measuring the grid.
|
||||
if (this._terminalFontReady) {
|
||||
await this._terminalFontReady;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Load terminal buffer for this session
|
||||
// Show cached content instantly while fetching fresh data in background.
|
||||
// Use tail mode for faster initial load (128KB is enough for recent visible content).
|
||||
|
||||
@@ -659,6 +659,22 @@ function sortSessionsByActivity(rows) {
|
||||
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||||
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||||
// symbols — while all readable text keeps coming from the text fonts.
|
||||
/**
|
||||
* How long a terminal fit will wait for the terminal font, in ms.
|
||||
*
|
||||
* `FontFaceSet.ready` has no deadline of its own and the wait sits in front of
|
||||
* the buffer replay, so a font request that never settles would leave the
|
||||
* session unpainted. Past this we measure whatever is painted.
|
||||
*/
|
||||
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Families in the stack that cannot move the measured cell, so nothing waits on
|
||||
* them: the generics match no `FontFace`, and the bundled symbols face carries
|
||||
* private-use-area glyphs only (xterm measures `W`) while weighing ~1.2MB.
|
||||
*/
|
||||
const TERMINAL_FONT_UNMEASURED = new Set(['monospace', 'serif', 'sans-serif', 'system-ui', 'symbols nerd font mono']);
|
||||
|
||||
const TERMINAL_FONT_DEFAULT_STACK =
|
||||
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
* 3. The terminal pane is ONE shared element, so its entrance is marked at
|
||||
* session creation but played at selection: a session created in the
|
||||
* background must not animate the pane the user is currently looking at. Its
|
||||
* styles are also restricted to transform/opacity/clip-path (see below).
|
||||
* styles are also restricted to transform/opacity/clip-path (see below), with
|
||||
* `blur` the one documented exception - a filter is the only thing that
|
||||
* actually blurs a live xterm; styles.css carries the measurement and the
|
||||
* three alternatives that do not work.
|
||||
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
|
||||
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
@@ -50,6 +53,7 @@ const TAB_ANIM_STYLES = [
|
||||
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
|
||||
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 440 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -65,6 +69,7 @@ const WIN_ANIM_STYLES = [
|
||||
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 560 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -73,6 +78,7 @@ const LINE_ANIM_STYLES = [
|
||||
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
|
||||
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 380 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -92,6 +98,7 @@ const TERM_ANIM_STYLES = [
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur lifts off the pane.', duration: 520 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
@@ -102,6 +109,7 @@ const BEAM_HOLD_MS = 360;
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
|
||||
@@ -60,9 +60,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.appendChild(trap);
|
||||
trap.focus();
|
||||
|
||||
// One Ctrl+V can deliver TWO paste events to this trap. The
|
||||
// execCommand('paste') below fires one wherever the browser honours that
|
||||
// command, and the key's own default action fires another, because xterm's
|
||||
// custom key handler returns false without cancelling the keydown. Handling
|
||||
// both sends the clipboard text to the PTY twice, which is the "Ctrl+V
|
||||
// pastes twice, right-click Paste does not" report: the context-menu paste
|
||||
// has no keydown, so it only ever produces one event. The trap therefore
|
||||
// accepts the first paste and drops every later one.
|
||||
var pasteConsumed = false;
|
||||
|
||||
// Listen for the paste event on our trap
|
||||
trap.addEventListener('paste', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
var imageFiles = [];
|
||||
@@ -84,7 +97,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
@@ -94,7 +106,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
e.preventDefault();
|
||||
if (text && self.terminal) self.terminal.paste(text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
|
||||
@@ -148,6 +148,13 @@ const MobileDetection = {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
const vh = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${vh}px`);
|
||||
// How far the layout viewport (which anchors position: fixed) extends below
|
||||
// the visual viewport, i.e. behind the browser's bottom bar. 0 on iPhone
|
||||
// Safari, where fixed elements already stop above the bar; the overlap
|
||||
// where they do not. mobile.css lifts the toolbar by this rather than by
|
||||
// (100vh - --app-height), which on iPhone measures the collapsible chrome
|
||||
// instead and left an empty band between the toolbar and the bar.
|
||||
document.documentElement.style.setProperty('--chrome-overlap', `${Math.max(0, window.innerHeight - vh)}px`);
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
|
||||
@@ -471,11 +471,11 @@ html.mobile-init .file-browser-panel {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
||||
browser's bottom bar. Match that offset in main's padding so the terminal
|
||||
doesn't extend behind the toolbar. */
|
||||
/* iOS Safari: the toolbar is lifted by --chrome-overlap where the browser's
|
||||
bottom bar would otherwise cover it. Match that offset in main's padding so
|
||||
the terminal doesn't extend behind the toolbar. */
|
||||
.ios-device.safari-browser .main {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom) + var(--chrome-overlap, 0px));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -780,11 +780,14 @@ html.mobile-init .file-browser-panel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
|
||||
extends behind the browser chrome. Offset the toolbar upward by the delta
|
||||
between 100vh (layout) and --app-height (visual). */
|
||||
/* iOS Safari: where position: fixed anchors to a layout viewport that
|
||||
extends behind the browser's bottom bar, lift the toolbar by that overlap.
|
||||
--chrome-overlap is innerHeight minus the visual viewport height, set in
|
||||
mobile-handlers.js. On iPhone Safari it is 0 because fixed elements already
|
||||
stop above the bar; the previous (100vh - --app-height) lift measured the
|
||||
collapsible chrome instead and left an empty band above the bar. */
|
||||
.ios-device.safari-browser .toolbar {
|
||||
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
bottom: calc(var(--safe-area-bottom) + var(--chrome-overlap, 0px));
|
||||
}
|
||||
|
||||
/* When keyboard is visible the JS translateY already accounts for the full
|
||||
|
||||
+116
-5
@@ -905,6 +905,28 @@ html[data-tab-anim="flip"] .session-tab.tab-enter {
|
||||
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull: the tab arrives out of focus and the blur fades
|
||||
OFF it as the opacity comes up, so it reads as resolving rather than moving.
|
||||
Opacity leads the blur (full opacity around 45%, blur still lifting) - that
|
||||
offset is what separates it from a plain cross-fade.
|
||||
|
||||
`filter` here, not on ::before: the tab's own box-shadow and border have to
|
||||
blur with it or the shape stays sharp inside a blurred fill, and unlike
|
||||
background/box-shadow (which .session-tab.active sets !important) nothing
|
||||
overrides filter. */
|
||||
html[data-tab-anim="blur"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-blur;
|
||||
animation-duration: calc(440ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-blur {
|
||||
0% { opacity: 0; filter: blur(10px); transform: scale(0.94); }
|
||||
45% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
|
||||
|
||||
.anim-lab {
|
||||
@@ -1186,6 +1208,25 @@ html[data-win-anim="pop"] .ultracode-window.win-enter {
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull. `materialize` is the noisy cousin: it glitches the
|
||||
opacity and rides a brightness boost. This one only defocuses, so it stays
|
||||
readable next to a terminal. The scale is deliberately small (0.96): the
|
||||
connection line is aimed at getBoundingClientRect(), which reports the
|
||||
TRANSFORMED box, so a big scale would swing the line's target while it draws.
|
||||
applyWindowEntrance() redraws the lines once the animation ends. */
|
||||
html[data-win-anim="blur"] .subagent-window.win-enter,
|
||||
html[data-win-anim="blur"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-blur;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes win-enter-blur {
|
||||
0% { opacity: 0; filter: blur(18px); transform: scale(0.96); }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Main terminal pane entrance animations ────────────────────────────────
|
||||
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
|
||||
from getComputedStyle(parent).width/height, the untransformed layout box -
|
||||
@@ -1327,6 +1368,47 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Blur, iOS-style focus pull.
|
||||
|
||||
⚠ THE ONE PLACE A `filter` GOES ON THE TERMINAL CONTAINER, and it is a
|
||||
deliberate exception to the rule above, not an oversight. Every other way of
|
||||
blurring this pane was tried against a real xterm and does not work:
|
||||
|
||||
- `backdrop-filter` on ::before blurs perfectly while it is STATIC, and
|
||||
Chrome silently drops the backdrop the moment ANY animation runs on that
|
||||
pseudo-element (measured: the veil computes `blur(15.3px)` and the text
|
||||
behind it stays razor sharp). Animating the container instead keeps the
|
||||
backdrop, so the veil would have to hold one fixed radius, which is a
|
||||
frosted pane that snaps off rather than a focus pull.
|
||||
- Driving the radius from rAF avoids the compositor promotion, at the cost
|
||||
of the same full-screen blur per frame plus main-thread work.
|
||||
|
||||
So the cost the rule exists to avoid is inherent to blurring a terminal at
|
||||
all, and this style buys it knowingly: it is opt-in, OFF by default, bounded
|
||||
to one ~520ms run when a session is opened (or switched to, with `Also on
|
||||
every tab switch`), and the class comes straight back off. `will-change` is
|
||||
still deliberately unset, per the base rule. Measured price on a headless
|
||||
SwiftShader rasterizer with no GPU at all, i.e. the worst case: frame deltas
|
||||
go 16.7ms -> 33.3ms for the length of the run, against 16.7ms flat for `fade`.
|
||||
|
||||
The blur must not change layout, or FitAddon would feed wrong dimensions into
|
||||
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
|
||||
before, during and after a run), and the property allowlist for every one of
|
||||
these keyframes is pinned by test/entrance-animations.test.ts. */
|
||||
html[data-term-anim="blur"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-blur;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* Opacity leads the blur - full opacity around 45%, blur still lifting - which
|
||||
is what separates the effect from a plain cross-fade. */
|
||||
@keyframes term-enter-blur {
|
||||
0% { opacity: 0; filter: blur(14px); transform: scale(1.008); }
|
||||
45% { opacity: 1; }
|
||||
100% { opacity: 1; filter: blur(0px); transform: none; }
|
||||
}
|
||||
|
||||
/* ── Connection-line entrance animations ───────────────────────────────────
|
||||
`--line-len` is the measured path length, stamped inline by
|
||||
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
|
||||
@@ -1393,6 +1475,26 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Blur, the line focuses in alongside a blurred tab and window. `filter` on an
|
||||
SVG path takes CSS filter functions, so the blur simply rides in front of the
|
||||
line's own glow (see --line-glow on .connection-line).
|
||||
|
||||
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
|
||||
the endpoint from the element's own computed value: a subagent line rests at
|
||||
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
|
||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
||||
wrong opacity and snaps it when the class comes off. */
|
||||
html[data-line-anim="blur"] .connection-line.line-enter {
|
||||
animation-name: line-enter-blur;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@keyframes line-enter-blur {
|
||||
0% { opacity: 0; filter: blur(5px) var(--line-glow); }
|
||||
100% { filter: blur(0px) var(--line-glow); }
|
||||
}
|
||||
|
||||
.anim-lab-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9833,10 +9935,17 @@ kbd {
|
||||
stroke-dasharray: 5 3;
|
||||
fill: none;
|
||||
opacity: 0.9;
|
||||
/* 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));
|
||||
/* Dark outline for contrast, vibrant blue glow. Held in a variable because the
|
||||
`blur` line entrance animates `filter`: a keyframe listing only the blur would
|
||||
drop the glow for the length of the run and pop it back at the end, and the
|
||||
lineage lines below - whose glow is a different colour entirely, set per
|
||||
element - make that obvious. Both of its keyframes say
|
||||
`blur(N) var(--line-glow)`, so the function lists match and interpolate while
|
||||
each kind of line keeps its own glow. */
|
||||
--line-glow: 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));
|
||||
filter: var(--line-glow);
|
||||
transition: opacity 0.2s, stroke-width 0.2s, filter 0.2s;
|
||||
}
|
||||
|
||||
@@ -9930,8 +10039,10 @@ kbd {
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
filter: var(--line-glow);
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
|
||||
@@ -514,6 +514,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Whenever that first fit runs — on this line, or a frame or two later on
|
||||
// the mobile-Safari branch above — it measures whatever font the browser has
|
||||
// painted with so far, which is not necessarily the terminal font. Start the
|
||||
// wait now so the buffer load can hold for it.
|
||||
this._terminalFontReady = this._awaitTerminalFont();
|
||||
|
||||
// Register link provider for clickable file paths in Bash tool output
|
||||
this.registerFilePathLinkProvider();
|
||||
@@ -935,7 +940,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
if (this.activeSessionId && !keyboardUp) {
|
||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
// Enforce minimum dimensions to prevent layout issues
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
@@ -3843,6 +3851,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// The pane belongs to the popup showing it, so this window has nothing to
|
||||
// restore. Say so rather than reporting a size that was never sent — the
|
||||
// same button in that window does the job.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId)) {
|
||||
this.showToast('This session is sized by its own window', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) {
|
||||
this.showToast('Could not determine terminal size', 'error');
|
||||
@@ -4785,6 +4801,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
const resolved = window.CodemanTerminalFont.resolve(custom);
|
||||
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
|
||||
this.terminal.options.fontFamily = resolved;
|
||||
// Changing the family at runtime is the same race as the boot-time one: the
|
||||
// option write makes xterm re-measure immediately, against a family the
|
||||
// browser may not have loaded. Re-arm the wait for the new stack and fit
|
||||
// again once it settles, so the setting takes effect at the right size
|
||||
// without needing a tab switch. The fit below still runs, so the terminal
|
||||
// is never left unfitted if the wait is slow.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
@@ -4801,6 +4826,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for the terminal's own font, then make xterm re-measure against it.
|
||||
*
|
||||
* A character cell measured against a fallback font has a different width and
|
||||
* height from one measured against the terminal font, so a fit taken too early
|
||||
* produces the wrong column and row count. The correction then arrives after
|
||||
* the buffer has been replayed, and the CLI redraws a frame that no longer
|
||||
* matches what the terminal is showing.
|
||||
*
|
||||
* ⚠️ Waiting is not sufficient on its own, which is what the re-measure at the
|
||||
* end is for. `FitAddon.proposeDimensions()` divides the container by a CACHED
|
||||
* cell size, and xterm refreshes that cache only from `open()`, from a resize
|
||||
* that actually changed the grid, and on a device-pixel-ratio change — nothing
|
||||
* in it listens for font loading. So a fit that runs after the font arrives can
|
||||
* still divide by the fallback cell, propose the grid it already has, and
|
||||
* short-circuit before anything re-measures.
|
||||
*
|
||||
* `document.fonts.load` for each family is what actually REQUESTS the faces:
|
||||
* the WebGL renderer rasterises glyphs through a canvas texture atlas, and
|
||||
* canvas text never triggers a CSS font fetch, so `document.fonts.ready` can
|
||||
* resolve with a face never having been asked for at all.
|
||||
*
|
||||
* Every step is best-effort and the whole thing is bounded, because a font
|
||||
* request that never settles must not hold up the terminal: `FontFaceSet.ready`
|
||||
* has no deadline of its own, and the caller awaits this in front of the buffer
|
||||
* replay. Past the deadline we fit against whatever is painted, which is the
|
||||
* old behaviour rather than a new failure.
|
||||
*/
|
||||
async _awaitTerminalFont() {
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.fonts?.load) return;
|
||||
const size = this.terminal?.options?.fontSize || 14;
|
||||
const families = String(this.terminal?.options?.fontFamily || '')
|
||||
.split(',')
|
||||
.map((family) => family.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean)
|
||||
// Only the faces that can supply the measured glyph are worth waiting on.
|
||||
// The bundled symbols font is ~1.2MB and carries private-use-area glyphs
|
||||
// only — xterm measures `W`, which it does not contain — so awaiting it
|
||||
// puts a megabyte between the user and their first frame for nothing.
|
||||
// Generic families match no FontFace at all.
|
||||
.filter((family) => !TERMINAL_FONT_UNMEASURED.has(family.toLowerCase()));
|
||||
const loaded = Promise.all(
|
||||
families.map((family) => document.fonts.load(`${size}px "${family}"`).catch(() => {}))
|
||||
).then(() => document.fonts.ready);
|
||||
await Promise.race([loaded, new Promise((resolve) => setTimeout(resolve, TERMINAL_FONT_WAIT_MS))]);
|
||||
} catch {
|
||||
/* font loading is unavailable or failed — fit against whatever is painted */
|
||||
}
|
||||
// Force the cache refresh xterm will not do for us. Without this the wait
|
||||
// buys nothing on the common path (see the warning above). Private API, as
|
||||
// FitAddon itself is; guarded because a terminal can be disposed mid-wait.
|
||||
try {
|
||||
this.terminal?._core?._charSizeService?.measure();
|
||||
} catch {
|
||||
/* renderer not ready or internals moved — the next real resize re-measures */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get terminal dimensions with minimum enforcement.
|
||||
* Prevents extremely narrow terminals that cause vertical text wrapping.
|
||||
@@ -4827,6 +4911,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// One PTY cannot hold two sizes. A detached session is owned by its own
|
||||
// window, and the dashboard's terminal is narrower than that window because
|
||||
// the session rail takes width the popup does not have — so both sizing it
|
||||
// makes the CLI draw frames that fit neither, which garbles the popup. The
|
||||
// dashboard yields; the solo window sizes what it alone displays.
|
||||
// (_maybeRefetchFullHistory already stands aside for the same reason.)
|
||||
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
|
||||
// xterm right, and only the SERVER write is the dashboard's to withhold —
|
||||
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
|
||||
// performs its one settle-time refit through this call and has no fallback.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return false;
|
||||
// Did the dimensions actually change since the last resize we sent? Callers
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -183,16 +184,8 @@ async function serveRawFile(
|
||||
rangeHeader?: string | string[]
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
|
||||
reply
|
||||
.code(413)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
// Markup is download-only: served with a renderable type on our own origin it
|
||||
@@ -1562,18 +1555,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
try {
|
||||
// Validate file size before reading (DoS protection - prevent memory exhaustion)
|
||||
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
|
||||
// Sanity bound only: the body below is streamed and Range-aware, so size
|
||||
// does not translate into resident memory. Configurable, 0 = unlimited.
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (stat.size > MAX_RAW_FILE_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1954,17 +1940,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
// 50MB size limit
|
||||
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
||||
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
|
||||
)
|
||||
);
|
||||
if (exceedsDownloadLimit(stat.size)) {
|
||||
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1988,15 +1965,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
|
||||
const filename = pathBasename(resolvedPath);
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
// Bypass Fastify compression — write directly to raw response
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
// Streamed rather than read into memory, and Range-aware, so a multi-GB
|
||||
// artifact costs one read stream and can be resumed. sendFileBody()
|
||||
// hijacks the reply, which also keeps Fastify's compression out of it.
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', filename));
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
return;
|
||||
} catch (err) {
|
||||
reply
|
||||
|
||||
@@ -2603,6 +2603,12 @@ export function registerSessionRoutes(
|
||||
? 'mux-full-history'
|
||||
: 'mux-visible'
|
||||
: 'history';
|
||||
// What the three row-preserving skips below must key on. `isFullReload` is
|
||||
// only what the CLIENT ASKED FOR: when the capture comes back null — ENOBUFS,
|
||||
// a timeout, a vanished pane, or a session with no mux at all — rawBuffer
|
||||
// falls back to the byte history, which is a stream of successive frames with
|
||||
// no row alignment to protect and every reason to be stripped.
|
||||
const isFullCapture = isFullReload && hasLiveMuxBuffer;
|
||||
let rawBuffer: string;
|
||||
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||||
// Full-history capture is the RENDERED form of everything already in the
|
||||
@@ -2649,8 +2655,16 @@ export function registerSessionRoutes(
|
||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||
// the terminal appears empty when switching tabs.
|
||||
// A full reload's buffer IS the rendered pane, one line per screen row, and
|
||||
// it ends with an absolute cursor move back to the pane's own position.
|
||||
// Every transform below that can DELETE A LINE would shift the rows out from
|
||||
// under that position, leaving the caret a row off — on the composer's
|
||||
// border rather than its input line. Redraw-bloat stripping exists for a
|
||||
// byte stream of successive frames; a capture holds no successive frames.
|
||||
let strippedBuffer =
|
||||
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||||
isFullCapture || getCli(session.mode)?.capabilities.stripInkBloat === false
|
||||
? rawBuffer
|
||||
: stripInkRedrawBloat(rawBuffer);
|
||||
|
||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||||
@@ -2688,7 +2702,10 @@ export function registerSessionRoutes(
|
||||
cleanBuffer = strippedBuffer;
|
||||
|
||||
// Find where Claude banner starts (has color codes before "Claude")
|
||||
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||
// Skipped for a full reload: the banner sits at whatever row the pane has
|
||||
// it, and cutting to it would drop the blank rows above and move every
|
||||
// row up by that many.
|
||||
const claudeMatch = isFullCapture ? null : cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||||
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
||||
let lineStart = claudeMatch.index;
|
||||
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
||||
@@ -2699,7 +2716,11 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
// Leading whitespace goes too, except on a full reload where a leading
|
||||
// blank line is the pane's own first row and dropping it shifts every row
|
||||
// up by one.
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '');
|
||||
if (!isFullCapture) cleanBuffer = cleanBuffer.replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
const finishedAt = performance.now();
|
||||
reply.header(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @fileoverview A detached session's pane is sized by its own window, not by
|
||||
* the dashboard.
|
||||
*
|
||||
* One PTY holds one size. When a session is popped out, the dashboard keeps it
|
||||
* active and keeps measuring it, but the dashboard's terminal is narrower than
|
||||
* the popup because the session rail takes width the popup does not have. Both
|
||||
* windows sizing the same pane makes the CLI draw frames that fit neither, and
|
||||
* the popup shows the result as a garbled frame.
|
||||
*
|
||||
* `sendResize` therefore returns early for a session this window has marked
|
||||
* detached, and the debounced window-resize handler skips it for the same
|
||||
* reason. A solo window is exempt: it IS the owner. `_maybeRefetchFullHistory`
|
||||
* already stood aside on the same condition, so this follows a rule the code
|
||||
* had already established.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
|
||||
* extracts the real mixin methods from terminal-ui.js.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The mixin runs inside the vm context, so its `fetch` must live there too. */
|
||||
let currentFetch: ReturnType<typeof vi.fn> = vi.fn();
|
||||
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), innerWidth: 1600 },
|
||||
document: { addEventListener: vi.fn() },
|
||||
fetch: (...args: unknown[]) => currentFetch(...args),
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
const SESSION = 'session-A';
|
||||
|
||||
function makeApp(overrides: Record<string, unknown> = {}) {
|
||||
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: { changed: true } }) }));
|
||||
currentFetch = fetchMock;
|
||||
const app = {
|
||||
sendResize: mixin.sendResize,
|
||||
getTerminalDimensions: () => ({ cols: 120, rows: 40 }),
|
||||
fitAddon: { fit: vi.fn() },
|
||||
detachedSessions: new Set<string>(),
|
||||
isSoloWindow: false,
|
||||
_lastResizeDims: null as { cols: number; rows: number } | null,
|
||||
_wsReady: false,
|
||||
_wsSessionId: null as string | null,
|
||||
...overrides,
|
||||
} as Record<string, unknown> & { sendResize: (id: string, o?: object) => Promise<boolean> };
|
||||
return { app, fetchMock };
|
||||
}
|
||||
|
||||
describe('detached sessions own their pane size', () => {
|
||||
it('the dashboard does not resize a session showing in its own window', async () => {
|
||||
const { app, fetchMock } = makeApp();
|
||||
(app.detachedSessions as Set<string>).add(SESSION);
|
||||
const changed = await app.sendResize(SESSION);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
// No request: the popup's size stands on the server.
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
// The LOCAL fit still runs, so the dashboard's own xterm stays correct and
|
||||
// tab-rail-resize's single settle-time refit is not swallowed. Same line the
|
||||
// mobile-keyboard guard draws: withhold the send, never the reflow.
|
||||
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the solo window still sizes the session it displays', async () => {
|
||||
const { app, fetchMock } = makeApp({ isSoloWindow: true });
|
||||
(app.detachedSessions as Set<string>).add(SESSION);
|
||||
await app.sendResize(SESSION);
|
||||
|
||||
// The popup is the owner, so being marked detached must not stop it.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the dashboard resizes a session that is not detached', async () => {
|
||||
const { app, fetchMock } = makeApp();
|
||||
await app.sendResize(SESSION);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `_redock` lives on the CodemanApp class rather than the terminal mixin, so it
|
||||
* needs app.js loaded. Same `vm` approach as terminal-flush-budget.test.ts.
|
||||
*/
|
||||
function loadAppClass() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance: { now: () => 0 },
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
});
|
||||
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
|
||||
const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
|
||||
}
|
||||
|
||||
describe('redock takes the sizing back', () => {
|
||||
const CodemanApp = loadAppClass();
|
||||
|
||||
function makeDashboard(activeSessionId: string | null) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.detachedSessions = new Set([SESSION]);
|
||||
app.detachedWindows = new Map();
|
||||
app._detachWatchTimers = new Map();
|
||||
app._redockGrace = new Map();
|
||||
app._detachOrphanStrikes = new Map();
|
||||
app.sessions = new Map([[SESSION, { id: SESSION }]]);
|
||||
app.activeSessionId = activeSessionId;
|
||||
app._lastResizeDims = { cols: 120, rows: 40 };
|
||||
app.$ = () => null;
|
||||
app.sendResize = vi.fn(() => Promise.resolve(true));
|
||||
return app;
|
||||
}
|
||||
|
||||
it('clears the stale dimensions so the next send reports truthfully', () => {
|
||||
// The popup sized the pane while it owned the session, so this window's one
|
||||
// global record of "what the PTY holds" is wrong. Left in place, the next
|
||||
// sendResize returns "unchanged" and selectSession skips its redraw wait.
|
||||
const app = makeDashboard(SESSION);
|
||||
app._redock(SESSION);
|
||||
expect(app._lastResizeDims).toBeNull();
|
||||
});
|
||||
|
||||
it('clears them even when the redocked session is not the active one', () => {
|
||||
// Pop out A, switch to B, close the popup: no resize is due, but the stale
|
||||
// record still has to go or selecting A later lies about it.
|
||||
const app = makeDashboard('some-other-session');
|
||||
app._redock(SESSION);
|
||||
expect(app._lastResizeDims).toBeNull();
|
||||
expect(app.sendResize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-asserts this window size for the session it is showing', () => {
|
||||
const app = makeDashboard(SESSION);
|
||||
app._redock(SESSION);
|
||||
expect(app.sendResize).toHaveBeenCalledWith(SESSION, { force: true });
|
||||
// Un-marked first, or the yield in sendResize would swallow the re-assert.
|
||||
expect(app.detachedSessions.has(SESSION)).toBe(false);
|
||||
});
|
||||
|
||||
it('sends nothing for a session that is gone', () => {
|
||||
// _onSessionDeleted redocks before cleanup, so the id can already be dead;
|
||||
// the resize would be a guaranteed 404.
|
||||
const app = makeDashboard(SESSION);
|
||||
app.sessions.delete(SESSION);
|
||||
app._redock(SESSION);
|
||||
expect(app.sendResize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @fileoverview Static guards for the entrance-animation styles (App Settings →
|
||||
* Appearance → Entrance Animations, plus the `?animlab=1` picker).
|
||||
*
|
||||
* A style is FOUR things that have to line up, and any one of them missing fails
|
||||
* silently rather than loudly: the entry in the style array in
|
||||
* entrance-animations.js (which is what the lab lists and what `_styleDuration`
|
||||
* reads), the `html[data-*-anim="<key>"]` rule in styles.css, the @keyframes
|
||||
* block that rule names, and — for a style that belongs to a theme — the theme's
|
||||
* `<option>` in index.html. A style with no CSS behind it renders as "the
|
||||
* animation silently does nothing"; a rule naming a keyframe block that does not
|
||||
* exist behaves the same way.
|
||||
*
|
||||
* The terminal pane carries an extra rule of its own, and it is the one with
|
||||
* teeth: xterm's FitAddon derives rows+cols from getComputedStyle(parent)
|
||||
* .width/height, so a terminal keyframe that animates a box-model property would
|
||||
* resize the PTY mid-animation. Only paint-level properties are allowed there.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const animSource = readFileSync(resolve('src/web/public/entrance-animations.js'), 'utf8');
|
||||
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
|
||||
/** Surfaces, keyed by the `data-*-anim` attribute their styles are selected by. */
|
||||
const SURFACES = [
|
||||
{ attr: 'tab', array: 'TAB_ANIM_STYLES', selector: '.session-tab.tab-enter' },
|
||||
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
||||
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
||||
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Styles with no CSS of their own, by design: `off` means "do nothing" and `fly`
|
||||
* is the pre-existing JS transition in subagent-windows.js, which deliberately
|
||||
* skips the `win-enter` class entirely.
|
||||
*/
|
||||
const CSS_LESS_STYLES = new Set(['off', 'fly']);
|
||||
|
||||
function styleKeys(arrayName: string): string[] {
|
||||
const start = animSource.indexOf(`const ${arrayName} = [`);
|
||||
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
|
||||
const body = animSource.slice(start, animSource.indexOf('];', start));
|
||||
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
|
||||
const start = animSource.indexOf('const ANIM_THEMES = [');
|
||||
const body = animSource.slice(start, animSource.indexOf('];', start));
|
||||
return [
|
||||
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `animation-name:` a `html[data-<attr>-anim="<key>"]` block asks for,
|
||||
* tagged with whether it runs on the element itself or on its ::before overlay.
|
||||
* The distinction matters for the terminal: the FitAddon rule below binds to the
|
||||
* container, while ::before is a throwaway wash that may animate anything.
|
||||
*/
|
||||
function animationNamesFor(attr: string, key: string): { name: string; onPseudo: boolean }[] {
|
||||
const rules = [...stylesSource.matchAll(new RegExp(`html\\[data-${attr}-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))];
|
||||
return rules.flatMap((rule) =>
|
||||
[...rule[2].matchAll(/animation-name:\s*([\w-]+);/g)].map((m) => ({
|
||||
name: m[1],
|
||||
onPseudo: rule[1].includes('::before'),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function keyframeBody(name: string): string | null {
|
||||
const start = stylesSource.indexOf(`@keyframes ${name} {`);
|
||||
if (start === -1) return null;
|
||||
return stylesSource.slice(start, stylesSource.indexOf('\n}', start));
|
||||
}
|
||||
|
||||
describe('entrance animation styles', () => {
|
||||
for (const surface of SURFACES) {
|
||||
describe(`${surface.attr} surface`, () => {
|
||||
it('backs every style with a rule that names a keyframe block that exists', () => {
|
||||
for (const key of styleKeys(surface.array)) {
|
||||
if (CSS_LESS_STYLES.has(key)) {
|
||||
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
|
||||
continue;
|
||||
}
|
||||
const names = animationNamesFor(surface.attr, key);
|
||||
expect(names.length, `no animation-name for ${surface.attr}/${key}`).toBeGreaterThan(0);
|
||||
for (const { name } of names) {
|
||||
expect(keyframeBody(name), `@keyframes ${name} missing`).not.toBeNull();
|
||||
}
|
||||
// The style has to reach the element the surface actually animates,
|
||||
// not just any selector carrying the attribute.
|
||||
expect(stylesSource).toContain(`html[data-${surface.attr}-anim="${key}"] ${surface.selector}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('ships the blur style on all four surfaces', () => {
|
||||
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
|
||||
});
|
||||
|
||||
it('gives every theme an <option> and only styles that exist', () => {
|
||||
for (const theme of themes()) {
|
||||
expect(indexSource, `no <option value="${theme.key}">`).toContain(`<option value="${theme.key}">`);
|
||||
for (const surface of SURFACES) {
|
||||
expect(styleKeys(surface.array), `theme ${theme.key} names an unknown ${surface.attr} style`).toContain(
|
||||
theme[surface.attr]
|
||||
);
|
||||
}
|
||||
}
|
||||
// 'custom' is a readout of a lab mix, never a theme you can select into.
|
||||
expect(indexSource).toContain('<option value="custom">');
|
||||
expect(themes().map((t) => t.key)).not.toContain('custom');
|
||||
});
|
||||
|
||||
it('keeps every entrance under the reduced-motion kill switch', () => {
|
||||
const start = stylesSource.indexOf('@media (prefers-reduced-motion: reduce) {\n .session-tab.tab-enter,');
|
||||
expect(start, 'the entrance reduced-motion block moved or was renamed').toBeGreaterThan(-1);
|
||||
const block = stylesSource.slice(
|
||||
start,
|
||||
stylesSource.indexOf('\n}', stylesSource.indexOf('animation: none', start))
|
||||
);
|
||||
for (const surface of SURFACES) expect(block).toContain(surface.selector);
|
||||
});
|
||||
|
||||
/**
|
||||
* ⚠ The FitAddon rule. It reads getComputedStyle(parent).width/height, i.e. the
|
||||
* untransformed LAYOUT box, so paint-level properties are invisible to it and a
|
||||
* box-model property here would resize the PTY mid-animation.
|
||||
*/
|
||||
it('animates only paint-level properties on the terminal pane', () => {
|
||||
const allowed = new Set(['opacity', 'transform', 'clip-path', 'filter']);
|
||||
for (const key of styleKeys('TERM_ANIM_STYLES')) {
|
||||
if (CSS_LESS_STYLES.has(key)) continue;
|
||||
for (const { name, onPseudo } of animationNamesFor('term', key)) {
|
||||
if (onPseudo) continue; // a wash over the pane, it has no layout of its own
|
||||
const body = keyframeBody(name);
|
||||
expect(body).not.toBeNull();
|
||||
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
|
||||
expect(allowed.has(prop), `@keyframes ${name} animates ${prop} on the terminal pane`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The `blur` line entrance animates `filter`, and a keyframe listing only the
|
||||
* blur would drop each line's own glow for the length of the run and pop it
|
||||
* back at the end. Both frames say `blur(N) var(--line-glow)` so the function
|
||||
* lists match and interpolate, which only works while both kinds of line
|
||||
* actually define that variable.
|
||||
*/
|
||||
it('routes both kinds of connection line through --line-glow', () => {
|
||||
for (const selector of ['.connection-line {', '.connection-line.lineage-line {']) {
|
||||
const start = stylesSource.indexOf(selector);
|
||||
expect(start, `${selector} not found`).toBeGreaterThan(-1);
|
||||
const block = stylesSource.slice(start, stylesSource.indexOf('\n}', start));
|
||||
expect(block, `${selector} must define --line-glow`).toContain('--line-glow:');
|
||||
expect(block, `${selector} must apply it`).toContain('filter: var(--line-glow);');
|
||||
}
|
||||
const blur = keyframeBody('line-enter-blur') as string;
|
||||
expect(blur).not.toBeNull();
|
||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the Ctrl+V paste trap in image-input.js.
|
||||
*
|
||||
* `_handleImagePaste()` appends a hidden contenteditable div (the "paste
|
||||
* trap"), focuses it, and reads the clipboard out of the paste event the
|
||||
* browser delivers there. Two things can deliver that event for a single
|
||||
* Ctrl+V: the `document.execCommand('paste')` the function issues itself, and
|
||||
* the keydown's own default action, which still runs because xterm's custom key
|
||||
* handler returns false without cancelling the event. A browser that honours
|
||||
* execCommand('paste') therefore fires the trap's listener twice, and the
|
||||
* clipboard text used to reach the PTY twice with it — while right-click →
|
||||
* Paste, which involves no keydown, stayed correct.
|
||||
*
|
||||
* Loads the browser module into a vm sandbox with a fake document, so the tests
|
||||
* drive the trap's listener directly rather than through a real browser.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface TrapListener {
|
||||
(e: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
interface FakeTrap {
|
||||
contentEditable: string;
|
||||
style: { cssText: string };
|
||||
parentNode: unknown;
|
||||
focus: () => void;
|
||||
addEventListener: (ev: string, fn: TrapListener) => void;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
|
||||
firePaste: (payload: { text?: string; images?: string[] }) => void;
|
||||
/** Text handed to xterm's terminal.paste(), one entry per call. */
|
||||
pastedText: string[];
|
||||
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
|
||||
uploadedBatches: Array<Array<{ type: string }>>;
|
||||
/** How many trap divs are still attached to the fake body. */
|
||||
attachedTraps: () => number;
|
||||
runTimers: () => void;
|
||||
}
|
||||
|
||||
function loadPasteHarness(): Harness {
|
||||
const traps: FakeTrap[] = [];
|
||||
const listeners: TrapListener[] = [];
|
||||
const attached = new Set<FakeTrap>();
|
||||
const timers: Array<() => void> = [];
|
||||
|
||||
const documentObj = {
|
||||
createElement: (): FakeTrap => {
|
||||
const trap: FakeTrap = {
|
||||
contentEditable: '',
|
||||
style: { cssText: '' },
|
||||
parentNode: null,
|
||||
focus: () => {},
|
||||
addEventListener: (ev: string, fn: TrapListener) => {
|
||||
if (ev === 'paste') listeners.push(fn);
|
||||
},
|
||||
};
|
||||
traps.push(trap);
|
||||
return trap;
|
||||
},
|
||||
body: {
|
||||
appendChild: (el: FakeTrap) => {
|
||||
attached.add(el);
|
||||
el.parentNode = documentObj.body;
|
||||
},
|
||||
removeChild: (el: FakeTrap) => {
|
||||
attached.delete(el);
|
||||
el.parentNode = null;
|
||||
},
|
||||
},
|
||||
// A browser that honours the command fires the trap's paste listener from
|
||||
// here as well; the tests model that by firing the listener twice.
|
||||
execCommand: () => true,
|
||||
getElementById: () => null,
|
||||
};
|
||||
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: documentObj,
|
||||
setTimeout: (fn: () => void) => {
|
||||
timers.push(fn);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
console,
|
||||
});
|
||||
|
||||
vm.runInContext('class CodemanApp {}', context);
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'image-input.js' });
|
||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||
|
||||
const pastedText: string[] = [];
|
||||
const uploadedBatches: Array<Array<{ type: string }>> = [];
|
||||
const app = new CodemanApp();
|
||||
app.activeSessionId = 'session-1';
|
||||
app.terminal = {
|
||||
paste: (text: string) => pastedText.push(text),
|
||||
focus: () => {},
|
||||
};
|
||||
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
|
||||
uploadedBatches.push(Array.from(files));
|
||||
};
|
||||
app.showToast = () => {};
|
||||
|
||||
(app._handleImagePaste as () => void).call(app);
|
||||
|
||||
return {
|
||||
firePaste({ text = '', images = [] }) {
|
||||
const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
|
||||
const event = {
|
||||
clipboardData: {
|
||||
items,
|
||||
getData: () => text,
|
||||
},
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
};
|
||||
for (const fn of listeners) fn(event);
|
||||
},
|
||||
pastedText,
|
||||
uploadedBatches,
|
||||
attachedTraps: () => attached.size,
|
||||
runTimers: () => {
|
||||
const pending = timers.splice(0, timers.length);
|
||||
for (const fn of pending) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Ctrl+V paste trap', () => {
|
||||
it('sends clipboard text to the terminal once for a single paste event', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ text: 'hello world' });
|
||||
|
||||
expect(h.pastedText).toEqual(['hello world']);
|
||||
});
|
||||
|
||||
it('ignores a second paste event for the same Ctrl+V', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
// execCommand('paste') and the uncancelled keydown's default action both
|
||||
// land on the same trap in browsers that honour the command.
|
||||
h.firePaste({ text: 'hello world' });
|
||||
h.firePaste({ text: 'hello world' });
|
||||
|
||||
expect(h.pastedText).toEqual(['hello world']);
|
||||
});
|
||||
|
||||
it('uploads a pasted image once when the trap sees two paste events', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ images: ['image/png'] });
|
||||
h.firePaste({ images: ['image/png'] });
|
||||
|
||||
expect(h.uploadedBatches).toHaveLength(1);
|
||||
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
|
||||
expect(h.pastedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the trap and hands focus back after the paste it accepted', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ text: 'hello world' });
|
||||
expect(h.attachedTraps()).toBe(1);
|
||||
|
||||
h.runTimers();
|
||||
expect(h.attachedTraps()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../scripts/pr-bot/report.js';
|
||||
import { classifyCi, latestRunPerWorkflow, type PrSummary, type WorkflowRun } from '../scripts/pr-bot/github.js';
|
||||
import { parseCallback, parseCommand, prNumberFromMessageText } from '../scripts/pr-bot/telegram.js';
|
||||
import { trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
|
||||
import { findModelLimitNotice, trustDialogKey } from '../scripts/pr-bot/codeman-client.js';
|
||||
import { buildConfig, parseEnvFile } from '../scripts/pr-bot/config.js';
|
||||
import { buildReviewBrief } from '../scripts/pr-bot/review-task.js';
|
||||
|
||||
@@ -302,6 +302,38 @@ describe('trustDialogKey', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findModelLimitNotice', () => {
|
||||
// Captured off prbot-394's pane on 2026-09-08, the run that lost 40 minutes: Claude
|
||||
// Code answers a spent budget inside the turn and then simply sits there.
|
||||
const SPENT_PANE = [
|
||||
'\x1b[38;5;153m\u276f\x1b[39m Read /home/arkon/.codeman/pr-bot/jobs/pr-394/brief.md and do the review.',
|
||||
" \u23bf You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
|
||||
'\u273b Saut\u00e9ed for 1s \u00b7 done 7:16 PM',
|
||||
].join('\n');
|
||||
|
||||
it('finds the notice on a real pane, ANSI and gutter glyph stripped', () => {
|
||||
expect(findModelLimitNotice(SPENT_PANE)).toBe(
|
||||
"You've reached your Fable limit. Run /usage-credits to continue or switch models with /model."
|
||||
);
|
||||
});
|
||||
|
||||
it('is not tied to one model name or to a straight apostrophe', () => {
|
||||
// The pane renders a typographic apostrophe, and every model prints this sentence.
|
||||
expect(
|
||||
findModelLimitNotice(' \u23bf You\u2019ve reached your Opus limit. Run /usage-credits to continue.')
|
||||
).toContain('reached your Opus limit');
|
||||
expect(findModelLimitNotice('You have reached your Sonnet 5 limit.')).toContain('Sonnet 5');
|
||||
});
|
||||
|
||||
it('says nothing about an ordinary working pane', () => {
|
||||
expect(findModelLimitNotice('\u273b Actualizing\u2026 (13m 23s \u00b7 esc to interrupt)')).toBeUndefined();
|
||||
expect(findModelLimitNotice('')).toBeUndefined();
|
||||
// The bare word is not the notice: a review whose own findings discuss usage limits
|
||||
// must not be reported as an exhausted account.
|
||||
expect(findModelLimitNotice('the usage limit parser handles the 5-hour reset')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('parses env files with quotes, comments and export prefixes', () => {
|
||||
const env = parseEnvFile('# c\nexport A="x y"\nB=\'z\'\nC=plain\nbad line\n=nokey\n');
|
||||
|
||||
@@ -191,7 +191,9 @@ describe('file-raw range requests', () => {
|
||||
});
|
||||
|
||||
it('still refuses files past the raw size cap before looking at Range', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||
// 3GB, past the 2GB CODEMAN_MAX_DOWNLOAD_BYTES default. The cap is checked
|
||||
// before the range, so a small slice of an oversized file is refused too.
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
@@ -199,6 +201,18 @@ describe('file-raw range requests', () => {
|
||||
headers: { range: 'bytes=0-99' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('serves a 100MB file that the historical 50MB cap would have refused', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl('big.mp4'),
|
||||
headers: { range: 'bytes=0-99' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -802,14 +802,27 @@ describe('file-routes', () => {
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects overly large raw files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
|
||||
it('serves a file past the historical 50MB cap', async () => {
|
||||
// The body is streamed and Range-aware, so size costs a read stream, not
|
||||
// RSS. The old 50MB refusal only blocked legitimate artifact downloads.
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never); // 100MB
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('still refuses a file past the configured download cap', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=enormous.bin`,
|
||||
});
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -863,8 +876,9 @@ describe('file-routes', () => {
|
||||
});
|
||||
|
||||
it('downloads files scoped to the session working directory', async () => {
|
||||
const content = Buffer.from('download content');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
// The body is streamed (shared sendFileBody path), so the bytes come from
|
||||
// the createReadStream mock rather than from readFile.
|
||||
const content = Buffer.from('fake file bytes');
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
@@ -874,7 +888,19 @@ describe('file-routes', () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
|
||||
expect(res.body).toBe('download content');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.body).toBe('fake file bytes');
|
||||
});
|
||||
|
||||
it('refuses a download past the configured cap', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=enormous.bin`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
|
||||
it('rejects absolute paths outside the session working directory', async () => {
|
||||
|
||||
@@ -851,6 +851,85 @@ describe('session-routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) keeps every leading row so the restored cursor lands on the right line', async () => {
|
||||
// The capture ends with an absolute cursor move, so its rows and the pane's
|
||||
// rows must line up one for one. Three transforms used to run over it and
|
||||
// each could delete a leading line: redraw-bloat stripping, the trim that
|
||||
// cuts everything above the Claude banner, and a leading-whitespace strip.
|
||||
// Any one of them shifted the frame up and left the caret a row off.
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
// A blank first row, then the banner — the shape a real pane has.
|
||||
const rendered = ['', '\x1b[1mClaude Code v2.1.266', 'conversation', '\u276f ', '\x1b[4;3H'].join('\r\n');
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? rendered : 'visible frame')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
// The blank first row survives, so row N of the reply is row N of the pane.
|
||||
expect(body.data.terminalBuffer.startsWith('\r\n')).toBe(true);
|
||||
expect(body.data.terminalBuffer.split('\r\n')).toHaveLength(rendered.split('\r\n').length);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) still strips the byte history when no capture came back', async () => {
|
||||
// The row-preserving skips exist for a rendered pane. When the capture is
|
||||
// unavailable the reply IS the byte stream — successive Ink frames, no row
|
||||
// alignment to protect — so keying the skips on the query parameter rather
|
||||
// than on the capture returned it unstripped, which is the whole reason
|
||||
// stripInkRedrawBloat exists. A session with no mux takes this path on
|
||||
// every first selection, not just during an outage.
|
||||
harness.ctx._session.mode = 'claude';
|
||||
// A VPA cluster the stripper will collapse: >= 10 sequences, spanning the
|
||||
// 32KB minimum, with real content after it.
|
||||
const frame = '\x1b[12d' + 'spinner frame '.repeat(240);
|
||||
harness.ctx._session.terminalBuffer = frame.repeat(20) + 'REAL CONTENT AFTER THE BLOAT';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('history');
|
||||
expect(body.data.terminalBuffer).toContain('REAL CONTENT AFTER THE BLOAT');
|
||||
// Stripped, not passed through whole.
|
||||
expect(body.data.terminalBuffer.length).toBeLessThan(harness.ctx._session.terminalBuffer.length);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) keeps the byte history when the capture is empty', async () => {
|
||||
// Pins the contract the capture side relies on: an empty capture means
|
||||
// "nothing to replay" and the byte history survives. capturePaneBuffer
|
||||
// returns '' for an all-blank pane precisely to reach this branch, since
|
||||
// retaining trailing blank rows and appending a cursor move would
|
||||
// otherwise make a blank screen non-empty and replace the history with it.
|
||||
// (The blank-pane decision itself is unit-tested on hasVisibleContent —
|
||||
// capturePaneBuffer short-circuits under IS_TEST_MODE and cannot run here.)
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'a real conversation worth keeping';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? '' : 'visible frame')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('history');
|
||||
expect(body.data.terminalBuffer).toContain('a real conversation worth keeping');
|
||||
});
|
||||
|
||||
it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => {
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'byte history survives';
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @fileoverview A terminal is measured only once its own font can be measured.
|
||||
*
|
||||
* The first fit runs while the browser is still painting with a fallback font,
|
||||
* whose character cell is a different size from the terminal font's. The grid
|
||||
* that fit produces is therefore wrong, the pane is sized to it, and the
|
||||
* correction arrives after the session's buffer has been replayed — so the CLI
|
||||
* repaints for a shape that does not match what is on screen.
|
||||
*
|
||||
* Two properties carry the fix and both are pinned here:
|
||||
*
|
||||
* - The wait REQUESTS each measurable face and then forces xterm to re-measure.
|
||||
* Waiting alone buys nothing: `FitAddon.proposeDimensions()` divides by a
|
||||
* cached cell size that xterm refreshes only from `open()`, from a resize
|
||||
* that changed the grid, and on a device-pixel-ratio change. Nothing in it
|
||||
* listens for font loading, so a fit after the font arrives can still divide
|
||||
* by the fallback cell and short-circuit.
|
||||
* - The wait is BOUNDED. `FontFaceSet.ready` has no deadline, and `selectSession`
|
||||
* awaits this before painting, so an unbounded wait would strand the session
|
||||
* instead of merely mis-measuring it.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
|
||||
* extracts the real mixin methods from terminal-ui.js.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The mixin runs inside the vm context, so its `document` must live there. */
|
||||
let currentDocument: unknown;
|
||||
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
get document() {
|
||||
return currentDocument;
|
||||
},
|
||||
});
|
||||
// constants.js supplies TERMINAL_FONT_WAIT_MS and TERMINAL_FONT_UNMEASURED.
|
||||
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(dir, 'terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${source}`, context);
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
type FontApp = {
|
||||
_awaitTerminalFont: () => Promise<void>;
|
||||
terminal: unknown;
|
||||
};
|
||||
|
||||
function makeApp(fontFamily: string, opts: { measure?: () => void } = {}) {
|
||||
const measure = vi.fn(opts.measure);
|
||||
const app = {
|
||||
_awaitTerminalFont: mixin._awaitTerminalFont,
|
||||
terminal: {
|
||||
options: { fontFamily, fontSize: 14 },
|
||||
_core: { _charSizeService: { measure } },
|
||||
},
|
||||
} as unknown as FontApp & { terminal: { _core: { _charSizeService: { measure: typeof measure } } } };
|
||||
return { app, measure };
|
||||
}
|
||||
|
||||
/** A FontFaceSet stub recording what was asked for. */
|
||||
function fontsStub(overrides: { load?: unknown; ready?: Promise<unknown> } = {}) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
fonts: {
|
||||
load: overrides.load ?? ((spec: string) => (requested.push(spec), Promise.resolve([]))),
|
||||
ready: overrides.ready ?? Promise.resolve(),
|
||||
status: 'loaded',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
currentDocument = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('terminal font settle', () => {
|
||||
it('requests every measurable family in the stack, unquoted', async () => {
|
||||
const stub = fontsStub();
|
||||
currentDocument = stub;
|
||||
const { app } = makeApp('"Fira Code", "JetBrains Mono", monospace');
|
||||
|
||||
await app._awaitTerminalFont();
|
||||
|
||||
expect(stub.requested).toEqual(['14px "Fira Code"', '14px "JetBrains Mono"']);
|
||||
});
|
||||
|
||||
it('does not wait on faces that cannot move the measured cell', async () => {
|
||||
// The symbols face is ~1.2MB of private-use-area glyphs and xterm measures
|
||||
// `W`, so awaiting it puts a megabyte in front of the first frame for
|
||||
// nothing. The generics match no FontFace at all.
|
||||
const stub = fontsStub();
|
||||
currentDocument = stub;
|
||||
const { app } = makeApp('"JetBrains Mono", "Symbols Nerd Font Mono", monospace, serif, system-ui');
|
||||
|
||||
await app._awaitTerminalFont();
|
||||
|
||||
expect(stub.requested).toEqual(['14px "JetBrains Mono"']);
|
||||
});
|
||||
|
||||
it('forces xterm to re-measure, because loading a font does not', async () => {
|
||||
// The property the whole change rests on. Without this the fit that follows
|
||||
// still divides the container by the fallback cell.
|
||||
currentDocument = fontsStub();
|
||||
const { app, measure } = makeApp('"JetBrains Mono"');
|
||||
|
||||
await app._awaitTerminalFont();
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on a font that never arrives, and still re-measures', async () => {
|
||||
// FontFaceSet.ready has no deadline of its own, and selectSession awaits
|
||||
// this before painting: unbounded here means a session that never renders.
|
||||
currentDocument = fontsStub({ ready: new Promise(() => {}) });
|
||||
const { app, measure } = makeApp('"JetBrains Mono"');
|
||||
|
||||
const started = Date.now();
|
||||
await app._awaitTerminalFont();
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
// Bounded by TERMINAL_FONT_WAIT_MS (2s), not left pending.
|
||||
expect(Date.now() - started).toBeLessThan(4000);
|
||||
}, 10_000);
|
||||
|
||||
it('survives a rejecting load and a browser with no font API', async () => {
|
||||
currentDocument = fontsStub({ load: () => Promise.reject(new Error('network')) });
|
||||
const { app: rejecting, measure: m1 } = makeApp('"JetBrains Mono"');
|
||||
await expect(rejecting._awaitTerminalFont()).resolves.toBeUndefined();
|
||||
expect(m1).toHaveBeenCalledTimes(1);
|
||||
|
||||
currentDocument = {};
|
||||
const { app: noApi, measure: m2 } = makeApp('"JetBrains Mono"');
|
||||
await expect(noApi._awaitTerminalFont()).resolves.toBeUndefined();
|
||||
// No font API means nothing to wait for and nothing to re-measure against.
|
||||
expect(m2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when the terminal was disposed mid-wait', async () => {
|
||||
currentDocument = fontsStub();
|
||||
const app = { _awaitTerminalFont: mixin._awaitTerminalFont, terminal: null } as unknown as FontApp;
|
||||
|
||||
await expect(app._awaitTerminalFont()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSession font gate', () => {
|
||||
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const selectStart = appSource.indexOf('async selectSession(sessionId, options = {})');
|
||||
const body = appSource.slice(
|
||||
selectStart,
|
||||
appSource.indexOf('\n // Shared cleanup for all session data', selectStart)
|
||||
);
|
||||
|
||||
it('waits for the font before the first fit', () => {
|
||||
const wait = body.indexOf('await this._terminalFontReady');
|
||||
const fit = body.indexOf('if (this.fitAddon) this.fitAddon.fit();');
|
||||
expect(wait).toBeGreaterThan(-1);
|
||||
expect(fit).toBeGreaterThan(-1);
|
||||
expect(wait).toBeLessThan(fit);
|
||||
});
|
||||
|
||||
it('waits BEFORE opening the buffer-load gate', () => {
|
||||
// Inside the gate every live SSE event queues instead of painting, so a slow
|
||||
// font would hold output back rather than only mis-measuring the grid.
|
||||
const wait = body.indexOf('await this._terminalFontReady');
|
||||
const gate = body.indexOf('this._beginBufferLoad(selectGen)');
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(wait).toBeLessThan(gate);
|
||||
});
|
||||
|
||||
it('keeps the synchronous focus ahead of the wait (iOS Safari)', () => {
|
||||
// iOS honours programmatic focus only inside the user-gesture call stack,
|
||||
// which the first await ends.
|
||||
const focus = body.indexOf('if (shouldFocusTerminal && this.terminal) this.terminal.focus();');
|
||||
const wait = body.indexOf('await this._terminalFontReady');
|
||||
expect(focus).toBeGreaterThan(-1);
|
||||
expect(focus).toBeLessThan(wait);
|
||||
});
|
||||
|
||||
it('re-checks for a newer selection after the wait', () => {
|
||||
const wait = body.indexOf('await this._terminalFontReady');
|
||||
const guard = body.indexOf('this._isStaleSelect(selectGen)', wait);
|
||||
expect(guard).toBeGreaterThan(-1);
|
||||
expect(guard - wait).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCursorRestore, hasVisibleContent } from '../src/tmux-manager.js';
|
||||
|
||||
describe('tmux full-history pane capture (COD-47)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodBody = source.slice(methodStart, methodStart + 4000);
|
||||
// Bounded at the next method so `methodBody` really is one method: the
|
||||
// ordering assertions below would otherwise be satisfiable by a neighbour.
|
||||
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
|
||||
const methodBody = source.slice(methodStart, methodEnd);
|
||||
|
||||
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
||||
expect(methodStart).toBeGreaterThan(-1);
|
||||
@@ -42,13 +46,37 @@ describe('tmux full-history pane capture (COD-47)', () => {
|
||||
});
|
||||
|
||||
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
|
||||
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
|
||||
// repaint (which is single-screen and would clip a multi-screen history).
|
||||
const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);');
|
||||
// The fullHistory branch returns before the formatPaneSnapshot repaint,
|
||||
// which is single-screen and would clip a multi-screen history.
|
||||
const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry');
|
||||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||||
expect(earlyReturn).toBeGreaterThan(-1);
|
||||
expect(branch).toBeGreaterThan(-1);
|
||||
expect(snapshot).toBeGreaterThan(-1);
|
||||
expect(earlyReturn).toBeLessThan(snapshot);
|
||||
expect(branch).toBeLessThan(snapshot);
|
||||
// …and what it returns is normalized linear scrollback, not a repaint.
|
||||
expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol(');
|
||||
});
|
||||
|
||||
it('appends the pane cursor to the full-history capture', () => {
|
||||
// A linear replay leaves the caret wherever the last character landed — the
|
||||
// status line, for an agent CLI — and every cursor-relative update the CLI
|
||||
// sends afterwards is then measured from the wrong row.
|
||||
const restore = methodBody.indexOf('formatCursorRestore(geometry)');
|
||||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||||
expect(restore).toBeGreaterThan(-1);
|
||||
expect(restore).toBeLessThan(snapshot);
|
||||
});
|
||||
|
||||
it('keeps the trailing rows only when a cursor move will follow', () => {
|
||||
// Trailing blank rows are the bottom of the screen and the cursor move counts
|
||||
// up from them, so the two decisions travel together: no geometry, no move,
|
||||
// and the old trim applies instead.
|
||||
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
|
||||
expect(methodBody).toContain("if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\\n+$/g, ''))");
|
||||
});
|
||||
|
||||
it('defers to the byte history when the pane holds nothing visible', () => {
|
||||
expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''");
|
||||
});
|
||||
|
||||
it('captureActivePaneBuffer forwards the capture options', () => {
|
||||
@@ -59,3 +87,37 @@ describe('tmux full-history pane capture (COD-47)', () => {
|
||||
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full-history cursor restore', () => {
|
||||
it('counts up from the last replayed row rather than down from the top', () => {
|
||||
// Relative, not `CUP`: absolute row addressing is only correct while the
|
||||
// browser's row count equals the pane's, and resizeWindow does not wait for
|
||||
// tmux, so a capture can be taken before a requested resize has applied.
|
||||
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C');
|
||||
});
|
||||
|
||||
it('emits no row move when the caret is already on the last row', () => {
|
||||
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C');
|
||||
});
|
||||
|
||||
it('emits no column move for column zero', () => {
|
||||
expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasVisibleContent', () => {
|
||||
it('is false for a pane of blank rows', () => {
|
||||
expect(hasVisibleContent('\n'.repeat(23))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for blank rows carrying only SGR attributes', () => {
|
||||
// `capture-pane -e` styles every row, so an all-blank pane is not an empty
|
||||
// string. Treating it as content would replace the byte history with a
|
||||
// blank screen.
|
||||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false);
|
||||
});
|
||||
|
||||
it('is true as soon as one row carries a character', () => {
|
||||
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user