Compare commits

..
Author SHA1 Message Date
Codeman maintainer 53f61ca539 fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.

The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.

Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.

Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.

Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:25:54 +02:00
Codeman maintainer b59145effd feat(templates): new cases' CLAUDE.md asks for absolute file paths and points at the codeman skill
Codeman turns absolute paths in the terminal into links that open the file
viewer, but agents usually report created files as relative paths, which
cannot be clicked. The generated CLAUDE.md now asks for the full absolute
path of every created file in the final reply, and says why relative, ~/
and markdown-link forms do not work.

It also tells the agent about the codeman skill (start, prompt, wait on and
clean up worker sessions) when the skill is available, and how the user can
install it when it is not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:28:55 +02:00
18 changed files with 612 additions and 1692 deletions
+2 -2
View File
@@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. 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**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` 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)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` 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**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -329,7 +329,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) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `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) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `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) → `spreadsheet-preview.js`(16.5). `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). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
+2 -10
View File
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
@@ -877,7 +877,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
### Entrance animations
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-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. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section.
**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. 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`.
⚠️ 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.
@@ -889,14 +889,6 @@ Tests: `test/terminal-touch-tap.test.ts`.
⚠️ 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.
**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`.
⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both.
⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible.
⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says.
### Mobile tab strip scrolling
**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.
+12 -11
View File
@@ -97,10 +97,7 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own `settle` style, the default of App Settings → Animations → Tile Animations, which
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
Opening, each tile
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
@@ -1011,13 +1008,17 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile. (Done since: a tile pages a
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
#555 route, and hand-reports a plain click while its session has
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
tile leaves the wheel to xterm.)
4. Tile parity extras: a "Load full history" action inside a tile. (Done
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
the primary pane's #555 route, hand-reports a plain click while its session
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
renderer as SGR wheel reports from its own cells
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
since xterm turns it into a horizontal no-op off macOS.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
+1 -10
View File
@@ -90,6 +90,7 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -103,16 +104,6 @@ every session or only the active tab.
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
All per device, all off by default, applied as you pick them.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
+2
View File
@@ -56,3 +56,5 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
+43 -490
View File
@@ -1,13 +1,12 @@
/**
* @fileoverview Entrance animations for the things that appear when work
* @fileoverview Entrance animations for the four things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Animations.
* → Appearance → Entrance Animations.
*
* Four constraints shape the design:
*
@@ -35,28 +34,13 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -110,6 +94,7 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ 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 },
@@ -117,79 +102,30 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
{ 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' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -199,8 +135,6 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -230,10 +164,6 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -289,18 +219,6 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -316,29 +234,22 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
/** The theme matching the four current styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
return match ? match.key : 'custom';
},
// ── App Settings → Animations ─────────────────────────────────────────────
// ── App Settings picker ───────────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -346,40 +257,16 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -416,10 +303,6 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -618,317 +501,6 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -966,12 +538,6 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -986,11 +552,6 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -1009,17 +570,11 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -1046,16 +601,19 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = this._currentAnimStyles();
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -1089,15 +647,10 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
/** @param {'tabs'|'term'|'window'|'all'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
+16 -69
View File
@@ -1663,10 +1663,6 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-animations">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<span>Animations</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-models">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<span>Models</span>
@@ -2057,7 +2053,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<h2>Appearance</h2>
</div>
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
<div class="set-group">
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
@@ -2081,6 +2077,21 @@
</optgroup>
</select>
</div>
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
<div class="set-row-text">
<span class="set-row-label">Entrance Animations</span>
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<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>
</select>
</div>
</div>
</div>
@@ -2237,70 +2248,6 @@
</div>
</section>
<!-- ══ Animations ═══════════════════════════════════════════════
Every entrance animation in one place (entrance-animations.js).
All per-device localStorage keys, applied as they are picked,
never part of PUT /api/settings. -->
<section class="set-section" id="settings-animations" data-label="Animations">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<h2>Animations</h2>
</div>
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
<div class="set-group">
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
<div class="set-row-text">
<span class="set-row-label">Entrance Theme</span>
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="launch">Launch (tiles fly from tabs)</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>
</select>
</div>
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
<div class="set-row-text">
<span class="set-row-label">Tile Animations</span>
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
</div>
<select id="appSettingsTileAnim" class="set-select">
<option value="settle">Off (default)</option>
<option value="fly">Fly from tab</option>
<option value="deal">Deal</option>
<option value="crt">CRT</option>
<option value="beam">Beam down</option>
<option value="cascade">Cascade</option>
<option value="pop">Pop</option>
<option value="soft">Soft</option>
<option value="off">None (tiles just appear)</option>
</select>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Lab</h4></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
<div class="set-row-text">
<span class="set-row-label">Animation Lab</span>
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
</div>
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
</div>
</div>
</div>
</section>
<!-- ══ Models ═══════════════════════════════════════════════════ -->
<section class="set-section" id="settings-models" data-label="Models">
<div class="set-section-head">
+51 -588
View File
@@ -1270,26 +1270,6 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -2px 0 10px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
}
.anim-lab-select select {
background: var(--bg-dark, #111);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.66rem;
padding: 2px 4px;
}
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
@@ -1476,20 +1456,11 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
first capture lands: the same rules, so a tile combines its frame's own
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
.terminal-container.term-enter,
.tile-body.term-enter {
.terminal-container.term-enter {
animation-fill-mode: both;
}
.tile-body.term-enter .xterm {
transition: none;
}
.terminal-container.term-enter::before,
.tile-body.term-enter::before {
.terminal-container.term-enter::before {
content: "";
position: absolute;
inset: 0;
@@ -1499,29 +1470,14 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-fill-mode: both;
}
/* A screen whose content lands while its frame is still entering waits for
it (--tile-screen-delay, entrance-animations.js): the body holds its first
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
static block). */
.tile-body.term-enter {
animation-delay: var(--tile-screen-delay, 0ms);
}
.tile-body.term-enter::before {
animation-delay: var(--tile-screen-delay, 0ms);
animation-fill-mode: forwards;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter,
html[data-term-anim="crt"] .tile-body.term-enter {
html[data-term-anim="crt"] .terminal-container.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before,
html[data-term-anim="crt"] .tile-body.term-enter::before {
html[data-term-anim="crt"] .terminal-container.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1542,16 +1498,55 @@ html[data-term-anim="crt"] .tile-body.term-enter::before {
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter,
html[data-term-anim="wipe"] .tile-body.term-enter {
html[data-term-anim="wipe"] .terminal-container.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
html[data-term-anim="wipe"] .tile-body.term-enter::before {
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
@@ -1570,8 +1565,7 @@ html[data-term-anim="wipe"] .tile-body.term-enter::before {
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter,
html[data-term-anim="slide"] .tile-body.term-enter {
html[data-term-anim="slide"] .terminal-container.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
@@ -1583,8 +1577,7 @@ html[data-term-anim="slide"] .tile-body.term-enter {
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter,
html[data-term-anim="fade"] .tile-body.term-enter {
html[data-term-anim="fade"] .terminal-container.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1622,8 +1615,7 @@ html[data-term-anim="fade"] .tile-body.term-enter {
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,
html[data-term-anim="blur"] .tile-body.term-enter {
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);
@@ -1746,20 +1738,10 @@ html[data-line-anim="blur"] .connection-line.line-enter {
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.tile-body.term-enter,
.tile-body.term-enter::before,
.tile.tile--entering,
.tile.tile--entering::before,
.connection-line.line-enter,
.connection-line-packet,
.connection-line.tile-beam-line,
.session-tab.tab-launch::after {
.connection-line-packet {
animation: none !important;
}
.tile-beam-lines {
display: none;
}
}
.session-tab .tab-status {
@@ -20080,314 +20062,6 @@ body.tile-grid-resizing--row * {
}
}
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
Picked in App Settings → Animations (Tile Animations, or preset by the
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
and what a reload restores with. Any other style is held one frame
(.tile--enter-hold: invisible, not animating) while the entrance module
orders the cascade and measures the tile's source on the final layout, then
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
FRAME keyframes animate transform and opacity only: FitAddon reads the
untransformed layout box (#464), and six tiles run at once, so no filter
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
::before wash. Each style also has its own way out on the closing grid's
still copy (tile-leave-*, below). */
html[data-tile-anim="settle"] .tile.tile--entering {
animation-name: tile-enter;
}
.tile.tile--entering.tile--enter-themed {
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.tile.tile--entering.tile--enter-themed::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
}
.tile.tile--enter-hold {
opacity: 0;
}
.tile.tile--enter-hold,
.tile.tile--enter-hold::before {
animation: none !important;
}
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
overshoots a hair and lands. */
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-fly;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
}
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
animation-name: tile-wash-launch;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-fly {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
}
16% {
opacity: 1;
}
74% {
transform: translate(0, 0) scale(1.01);
}
100% {
opacity: 1;
transform: none;
}
}
@keyframes tile-wash-launch {
0% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
}
55% {
opacity: 0.55;
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
}
100% {
opacity: 0;
background: transparent;
box-shadow: inset 0 0 0 1px transparent;
}
}
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
squared up in its cell. */
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-deal;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
}
@keyframes tile-enter-deal {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
rotate(var(--tile-from-rot, -10deg));
}
10% {
opacity: 1;
}
70% {
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
}
88% {
transform: scale(0.996) rotate(0deg);
}
100% {
opacity: 1;
transform: none;
}
}
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-crt {
0% {
opacity: 0;
transform: scale3d(0.5, 0.006, 1);
}
22% {
opacity: 1;
transform: scale3d(1, 0.01, 1);
}
58% {
opacity: 1;
transform: scale3d(1, 1.04, 1);
}
80% {
opacity: 1;
transform: scale3d(1, 0.985, 1);
}
100% {
opacity: 1;
transform: none;
}
}
/* Beam down: holds still while a beam draws down from its tab (the entrance
module's own overlay, .tile-beam-line), then materializes out of the agent
windows' green wash. No transform, so the beam lands where the tile is. */
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-beam {
0% {
opacity: 0;
}
20% {
opacity: 0.55;
}
32% {
opacity: 0.2;
}
48% {
opacity: 0.9;
}
100% {
opacity: 1;
}
}
/* Cascade: swings down from its top edge, a diagonal at a time. */
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-cascade;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tile-enter-cascade {
0% {
opacity: 0;
transform: perspective(1400px) rotateX(-80deg);
}
55% {
opacity: 1;
transform: perspective(1400px) rotateX(8deg);
}
80% {
transform: perspective(1400px) rotateX(-2.5deg);
}
100% {
opacity: 1;
transform: perspective(1400px) rotateX(0deg);
}
}
/* Pop: springs open from its centre, rippling out from the focused tile. */
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-pop;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
58% {
opacity: 1;
transform: scale(1.03);
}
80% {
transform: scale(0.993);
}
100% {
opacity: 1;
transform: none;
}
}
/* Soft: a slow drift up into place; with the Soft focus theme each screen
then focus-pulls in (the pane's `blur` style on .tile-body). */
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-soft;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tile-enter-soft {
0% {
opacity: 0;
transform: translateY(16px) scale(0.94);
}
100% {
opacity: 1;
transform: none;
}
}
/* The tab a tile flies (or beams) out of glows as it goes. */
.session-tab.tab-launch:not(.tab-loading)::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
}
@keyframes tab-launch-flash {
0% {
opacity: 0;
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
background: transparent;
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
}
}
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
tile, held while the tile materializes, then faded. Their own overlay
(#tileBeamLines), removed by the entrance module once they are done. */
.connection-line.tile-beam-line {
stroke-dasharray: var(--line-len);
animation:
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
var(--line-enter-delay, 0ms) both,
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
}
@keyframes tile-beam-out {
from {
opacity: 0.9;
}
to {
opacity: 0;
}
}
.tile-body .xterm {
transition: opacity 160ms ease-out;
}
@@ -20462,220 +20136,9 @@ html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
}
}
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
on the still copy only and only for the Tiles toggle's close: a re-form's
copy (--now) covers tiles that stay, so it keeps the plain fade above.
Copies leave in reading order (--tile-exit-delay), so the last one ends
last and takes the layer with it. */
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-fly {
0% {
opacity: 0.8;
transform: scale(0.985);
}
75% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
}
}
@keyframes tile-wash-return {
0% {
opacity: 0;
background: transparent;
}
100% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
}
}
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-deal {
0% {
opacity: 0.8;
transform: scale(0.985);
}
22% {
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
}
85% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
rotate(var(--tile-to-rot, -10deg));
}
}
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-crt {
0% {
opacity: 0.8;
transform: scale(0.985);
}
42% {
opacity: 1;
transform: scale3d(1, 0.012, 1);
}
72% {
opacity: 1;
transform: scale3d(0.03, 0.012, 1);
}
100% {
opacity: 0;
transform: scale3d(0, 0, 1);
}
}
@keyframes tile-wash-crt-off {
0% {
opacity: 0;
background: transparent;
}
38% {
opacity: 1;
background: rgba(220, 255, 235, 0.95);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 1);
}
}
/* Beam down: beamed back up, flickering out under a rising wash. */
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-beam {
0% {
opacity: 0.8;
}
40% {
opacity: 0.9;
}
55% {
opacity: 0.3;
}
70% {
opacity: 0.6;
}
100% {
opacity: 0;
}
}
@keyframes tile-wash-beam-up {
0% {
opacity: 0;
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
}
100% {
opacity: 1;
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
}
}
/* Cascade: folds back up on its top edge. */
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
transform-origin: 50% 0%;
}
@keyframes tile-leave-cascade {
0% {
opacity: 0.8;
transform: perspective(1400px) rotateX(0deg) scale(0.985);
}
100% {
opacity: 0;
transform: perspective(1400px) rotateX(82deg) scale(0.985);
}
}
/* Pop: a last swell, then it pops away. */
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-pop {
0% {
opacity: 0.8;
transform: scale(0.985);
}
35% {
opacity: 0.9;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Soft: sinks away slowly. */
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-soft {
0% {
opacity: 0.8;
transform: scale(0.985);
}
100% {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.tile-count-menu,
.tile.tile--entering,
.tile.tile--entering::before,
.tile.tile--needs::after,
.tile.tile--loading .tile-body::after,
.tile-grid-ghosts .tile.tile--leaving,
+92 -15
View File
@@ -11,14 +11,20 @@
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
* handlers (a swipe on a touch screen pages nothing), and no keyboard
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
* and docs/tile-grid-plan.md.
*
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
@@ -43,7 +49,7 @@
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -201,6 +207,9 @@
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
this._scrollBytes = '';
this._scrollFlushTimer = null;
@@ -1027,16 +1036,18 @@
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Not passive: the one route
// this pane takes over, paging a hollow buffer's CLI transcript
// (_maybePageCliTranscript), is consumed right here (preventDefault plus
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
// stopPropagation in the capture phase, the primary pane's technique), so
// xterm's viewport, a descendant, never sees it. Every other wheel is left
// xterm's viewport, a descendant, never sees them. Every other wheel is left
// to xterm, which keeps doing the scrolling, and only observed for the
// shell history pull.
_installWheelListener() {
this._onWheel = (ev) => {
if (this._maybePageCliTranscript(ev)) {
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
ev.preventDefault();
ev.stopPropagation();
return;
@@ -1046,6 +1057,71 @@
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
}
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
// pane's session through its own coalescer. Returns true when the wheel
// belongs to the CLI: a gesture with no whole line or no measurable cell is
// consumed too, as in the primary pane, so xterm never scrolls the stale
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
_maybeForwardWheelToCli(ev) {
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
// history" gesture, here as in the primary pane (whose capture-phase wheel
// handler scrolls with terminal.scrollLines() for the same reason). Left to
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
// wheel into a horizontal one, so the viewport never moved. Reads the
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
// for the next event (a trackpad's small deltas), and on the way up still
// asks a shell pane for more history. Returns true when the wheel was
// consumed here.
_maybeScrollLocalOnShift(ev) {
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
const input = global.CodemanTerminalInput;
if (!input?.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
const lines = Math.trunc(total);
this._shiftScrollPending = total - lines;
if (lines) {
this.terminal.scrollLines(lines);
if (lines < 0) this._maybeLoadMoreHistory();
}
return true;
}
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
@@ -1087,9 +1163,9 @@
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// The primary pane would forward this wheel to Claude's fullscreen
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
// follow-up 4), so the wheel stays with xterm, as before.
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
@@ -1111,8 +1187,9 @@
return true;
}
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
// fling cannot build a backlog that keeps paging after it stops. A narrow
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
+34 -6
View File
@@ -107,6 +107,32 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
// live-screen relative): button 64 per line up, 65 per line down, capped at
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
// byte-identical reports to its own session.
function sgrWheelReports(lines, pos) {
if (!lines || !pos) return '';
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
@@ -260,6 +286,9 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
@@ -5563,9 +5592,8 @@ Object.assign(CodemanApp.prototype, {
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
const lines = this._wheelScrollLinesFloat(ev);
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
return Math.round(lines) || (lines > 0 ? 1 : -1);
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
@@ -5639,13 +5667,13 @@ Object.assign(CodemanApp.prototype, {
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
},
/**
+21 -92
View File
@@ -237,15 +237,8 @@ Object.assign(CodemanApp.prototype, {
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
entry.el.classList.toggle('tile--loading', state !== 'idle');
// The first capture has landed (or failed): the terminal fades in,
// whole, instead of showing its replay scroll by, or plays the
// terminal pane's entrance style (entrance-animations.js).
if (state === 'idle') {
entry.el.classList.remove('tile--revealing');
if (entry.screenOwed) {
entry.screenOwed = false;
this.playTileScreenEntrance?.(entry.body);
}
}
// whole, instead of showing its replay scroll by.
if (state === 'idle') entry.el.classList.remove('tile--revealing');
},
});
}
@@ -543,28 +536,14 @@ Object.assign(CodemanApp.prototype, {
// Zoomed, only the zoomed tile is on screen (the others are display: none
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
const zoomed = section.classList.contains('tile-grid--zoomed');
const copies = [];
for (const id of grid.ids) {
const el = grid.tiles.get(id)?.el;
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
const ghost = el.cloneNode(true);
ghost.classList.remove(
'tile--entering',
'tile--enter-themed',
'tile--enter-hold',
'tile--needs',
'tile--loading',
'tile--drop-target',
'tile--dragging'
);
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
ghost.classList.add('tile--leaving');
layer.appendChild(ghost);
copies.push({ ghost, el, sessionId: id });
}
// The entrance style's own way out (entrance-animations.js): back into the
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
main.appendChild(layer);
let fallback = null;
let holdCap = null;
@@ -578,11 +557,10 @@ Object.assign(CodemanApp.prototype, {
if (fallback !== null || !layer.isConnected) return;
clearTimeout(holdCap);
layer.classList.add('tile-grid-ghosts--release');
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
};
layer.addEventListener('animationend', (e) => {
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
if (/^tile-leave/.test(e.animationName)) done();
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
});
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
else release();
@@ -1345,10 +1323,11 @@ Object.assign(CodemanApp.prototype, {
/**
* Builds one tile (header, body, TerminalTile); where it goes is the
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
* group (_beginTileEntrance), and its terminal stays transparent until its
* first capture lands (.tile--revealing, cleared by the load queue, with a
* backstop timer). Opacity and transform only, never anything its fit reads.
* caller's (grid.cells). It enters with a short fade and settle
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
* and its terminal stays transparent until its first capture lands
* (.tile--revealing, cleared by the load queue, with a backstop timer).
* Opacity and transform only, never anything its fit reads.
*/
_mountTile(sessionId, { enterIndex = 0 } = {}) {
const grid = this._tileGrid;
@@ -1357,11 +1336,16 @@ Object.assign(CodemanApp.prototype, {
const el = document.createElement('div');
el.className = 'tile tile--revealing';
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
// Its screen plays the terminal pane's entrance once its first capture
// lands, when its frame entered in a tile style (Tile Animations): never
// with the default `settle`, nor on a reload's restore, which settles.
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
const screenOwed = !!entered && entered !== 'settle';
if (this._tileMotionAllowed()) {
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
const onEnd = (e) => {
if (e.target !== el || e.animationName !== 'tile-enter') return;
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering');
};
el.addEventListener('animationend', onEnd);
}
el.dataset.sessionId = sessionId;
// Header and body are siblings: the chrome is refreshed in place
// (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1407,61 +1391,12 @@ Object.assign(CodemanApp.prototype, {
renaming: false,
// The pid this tile last saw, so a pane that starts later is noticed.
pid: this.sessions.get(sessionId)?.pid ?? null,
// Its screen plays the terminal entrance once its first capture lands.
screenOwed,
});
// Where it goes is the caller's (grid.cells).
this._renderTileHeader(sessionId);
return true;
},
/**
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
* entrance style (entrance-animations.js, App Settings → Entrance
* Animations). `settle` is the grid's own fade and settle (styles.css
* .tile--entering), and what a reload restores with whatever the theme:
* nothing animates on page load. Any other style is timed by the entrance
* module a frame later, once the layout is final (_stageTileEntrance).
* Opacity and transform only, never anything the fit reads.
*
* @returns {string|null} the style it enters with, or null when it does not animate
*/
_beginTileEntrance(el, sessionId, enterIndex = 0) {
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
if (!this._tileMotionAllowed() || style === 'off') return null;
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
let backstop = null;
const finish = () => {
clearTimeout(backstop);
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
};
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
// never a child's animation or a wash on its ::before.
const onEnd = (e) => {
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
finish();
};
el.addEventListener('animationend', onEnd);
el._tileEnterFinish = finish;
if (style !== 'settle') {
this._stageTileEntrance?.(el, sessionId, (ms) => {
clearTimeout(backstop);
backstop = setTimeout(finish, ms);
});
}
return style;
},
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
_replayTileEntrance(el, sessionId, enterIndex = 0) {
el._tileEnterFinish?.();
void el.offsetWidth;
return this._beginTileEntrance(el, sessionId, enterIndex);
},
/**
* Makes `el` a drop target for a session tab dragged from the strip (the
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -2663,13 +2598,7 @@ Object.assign(CodemanApp.prototype, {
if (this._tilesOwnTerminal()) return false;
const stored = this._readStoredTileGrid();
if (!stored?.open || stored.ids.length === 0) return false;
// Put back, not opened: the tiles settle in whatever the entrance theme.
this._tileEnterQuiet = true;
try {
return this._openStoredTileGrid(stored);
} finally {
this._tileEnterQuiet = false;
}
return this._openStoredTileGrid(stored);
},
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
-25
View File
@@ -80,31 +80,6 @@ describe('App Settings modal structure', () => {
expect(system).toContain('id="appSettingsTunnelEnabled"');
});
/**
* Owner decision (2026-10-09): every animation setting has its own
* Animations section, right after Appearance, so it is easy to find. The
* selects are wired by id in entrance-animations.js, not by the load/save
* path above, so they get their own check here.
*/
it('keeps every animation setting in its own Animations section, after Appearance', () => {
const modal = settingsModal();
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
for (const list of [rail, order]) {
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
}
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
}
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(anim).toContain(`document.getElementById('${id}')`);
}
});
it('keeps Local Echo the first row of the second section', () => {
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
+7 -123
View File
@@ -31,7 +31,6 @@ const SURFACES = [
{ 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' },
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
] as const;
/**
@@ -41,11 +40,6 @@ const SURFACES = [
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
function isCssLess(attr: string, key: string): boolean {
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
}
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
@@ -53,21 +47,12 @@ function styleKeys(arrayName: string): string[] {
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
function themes(): Theme[] {
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));
const parsed = [
...body.matchAll(
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
// Every theme entry must parse: a theme missing a surface (or a regex that
// drifted from the source) would otherwise pass the checks below vacuously.
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
expect(parsed.length).toBeGreaterThan(0);
return parsed;
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] }));
}
/**
@@ -97,7 +82,7 @@ describe('entrance animation styles', () => {
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 (isCssLess(surface.attr, key)) {
if (CSS_LESS_STYLES.has(key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
@@ -114,16 +99,8 @@ describe('entrance animation styles', () => {
});
}
/**
* Tiles are the exception: six frames animate at once, so their styles stay
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
*/
it('ships the blur style on the four single-element surfaces', () => {
for (const surface of SURFACES) {
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
else expect(styleKeys(surface.array)).toContain('blur');
}
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', () => {
@@ -196,96 +173,3 @@ describe('entrance animation styles', () => {
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
describe('tile grid entrance styles', () => {
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
const tileNames = (key: string, scope: RegExp) =>
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
.filter((rule) => scope.test(rule[1]))
.flatMap((rule) =>
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
/**
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
* A tile fits once at its final size (#464); a box-model property here would
* resize its PTY mid-animation, and a filter on six live terminals at once
* is the frame-time cost the pane's `blur` takes for one.
*/
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
if (key === 'off') continue;
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
for (const { name, onPseudo } of names) {
const body = keyframeBody(name);
expect(body, `@keyframes ${name} missing`).not.toBeNull();
if (onPseudo) continue; // a wash over the tile, no layout of its own
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
}
}
}
});
/**
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
* animationend, and the still copy goes on its last tile's `tile-leave*`:
* a keyframe named otherwise would leave the class on (or the copy up)
* until a backstop timer.
*/
it('names every frame keyframe for the events tile-grid.js listens for', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
}
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
}
}
});
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
expect(timed.length).toBeGreaterThan(0);
for (const key of timed) {
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
}
expect(timed).not.toContain('settle');
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
// The legacy theme (the default) leaves the grid's own motion untouched.
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
});
/**
* App Settings → Appearance → Tile Animations: one option per style, wired
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
* own `settle` first and named as the off default.
*/
it('lists every tile style in the Tile Animations setting, off by default', () => {
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
expect(values[0]).toBe('settle');
expect(select).toContain('<option value="settle">Off (default)</option>');
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
// Nothing new for an install that never picks it: no saved key means settle.
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
});
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
it('plays every terminal pane style on a tile screen too', () => {
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
// The body itself, not only its ::before wash.
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
}
});
});
+13
View File
@@ -199,6 +199,19 @@ export class FakeTerminal {
}
scrollToLine() {}
scrollToTop() {}
/** Every scrollLines() amount; the viewport moves within [0, baseY], as in xterm. */
scrolledLines: number[] = [];
scrollLines(amount: number) {
this.scrolledLines.push(amount);
const active = this.buffer.active;
active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount));
}
/** Times scrollToBottom() was called; the viewport moves to the live screen, as in xterm. */
scrolledToBottom = 0;
scrollToBottom() {
this.scrolledToBottom++;
this.buffer.active.viewportY = this.buffer.active.baseY;
}
dispose() {}
type(data: string) {
this.dataCb?.(data);
+14
View File
@@ -61,6 +61,20 @@ describe('generateClaudeMd', () => {
expect(result).toContain('CODEMAN_MUX=1');
});
it('should tell the agent to print absolute paths of files it creates', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('full absolute path');
expect(result).toContain('clickable');
});
it('should point the agent at the codeman skill', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('`codeman` skill');
expect(result).toContain('codeman skill install');
});
it('should include workflow rules', () => {
const result = generateClaudeMd('my-project');
+97
View File
@@ -14,6 +14,12 @@
* wheel-down scrolls xterm home instead of being paged, and paging resumes
* from the live screen.
*
* - a fullscreen Claude tile sends the wheel to the CLI as SGR wheel reports
* at the pointer's cell in the TILE (Claude scrolls its own transcript on
* them), and xterm's viewport stays on the live screen instead of
* scrolling the stale replayed frames above it. Shift+wheel scrolls the
* local scrollback, which xterm alone turns into a horizontal no-op.
*
* The wheel is a real one (`page.mouse.wheel()`), and the last step proves it
* reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so
* "xterm did not scroll" on a paged wheel means something. What keeps xterm
@@ -200,4 +206,95 @@ describe('TerminalTile wheel paging in a real browser', () => {
}, PROBE_ID);
}
});
it("forwards a fullscreen Claude tile's wheel as SGR reports from its own cells, and keeps xterm on the live screen", async () => {
await page.evaluate(async (id) => {
const w = window as any;
const app = w.app;
const CAPTURE_ROWS = 40;
const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `frame ${i}`).join('\r\n');
// The tile's session: Claude in its fullscreen renderer (mouse tracking on).
app.sessions.set(id, { id, mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true });
const sent: Array<[string, string]> = [];
const realEphemeral = app._sendInputEphemeral;
app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]);
const realFetch = w.fetch;
w.fetch = async (url: string, init?: unknown) =>
String(url).includes(`/api/sessions/${id}/terminal`)
? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } }))
: realFetch(url, init);
const RealWebSocket = w.WebSocket;
w.WebSocket = class {
static OPEN = 1;
readyState = 0;
send() {}
close() {}
};
// Offset from the page corner, so a cell computed against the wrong
// element (the primary pane's) would come out different.
const mount = document.createElement('div');
mount.style.cssText =
'position:fixed;left:100px;top:120px;width:640px;height:300px;z-index:99999;background:#000';
document.body.appendChild(mount);
const tile = new w.TerminalTile(id, mount, { mode: 'claude' });
try {
await tile.connect();
} finally {
w.fetch = realFetch;
w.WebSocket = RealWebSocket;
}
w.__tileProbe = { tile, mount, sent, realEphemeral };
}, PROBE_ID);
try {
// Where the pointer will be, as the tile's own screen maps it (1-based).
const expected = await page.evaluate(() => {
const term = (window as any).__tileProbe.tile.terminal;
const rect = term.element.querySelector('.xterm-screen').getBoundingClientRect();
const cell = term._core._renderService.dimensions.css.cell;
const x = rect.left + cell.width * 7.5;
const y = rect.top + cell.height * 3.5;
return { x, y, col: 8, row: 4 };
});
// The stale frames sit above the live screen: something xterm COULD scroll.
const before = await snap();
expect(before.baseY).toBeGreaterThan(0);
expect(before.viewportY).toBe(before.baseY);
await page.mouse.move(expected.x, expected.y);
await page.mouse.wheel(0, -100); // one Chrome-on-Windows notch up
await page.waitForTimeout(150);
await page.mouse.wheel(0, 50); // half a notch down
await page.waitForTimeout(150);
const after = await snap();
expect(after.viewportY).toBe(before.baseY); // xterm never scrolled the stale frames
expect(after.sent.every(([sid]) => sid === PROBE_ID)).toBe(true);
const up = `\x1b[<64;${expected.col};${expected.row}M`;
const down = `\x1b[<65;${expected.col};${expected.row}M`;
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
await page.keyboard.down('Shift');
await page.mouse.wheel(0, -100);
await page.keyboard.up('Shift');
await page.waitForTimeout(150);
const shifted = await snap();
expect(shifted.sent.length).toBe(after.sent.length);
expect(shifted.viewportY).toBeLessThan(before.baseY);
} finally {
await page.evaluate((id) => {
const w = window as any;
const probe = w.__tileProbe;
probe.tile.destroy();
probe.mount.remove();
w.app.sessions.delete(id);
w.app._sendInputEphemeral = probe.realEphemeral;
delete w.__tileProbe;
}, PROBE_ID);
}
});
});
+205 -8
View File
@@ -18,6 +18,13 @@
* history, so the tile discounts them (`_localRows`); the overflow cases below
* fail without that discount.
*
* Claude's fullscreen renderer is the other half: it scrolls its own
* transcript on SGR wheel reports, which the primary pane sends it, while a
* tile's xterm holds only replayed repaint frames. A tile left that wheel to
* xterm too, so in a grid of fullscreen Claude sessions the wheel dragged
* stale frames around or did nothing; it now forwards the reports to its own
* session, from its own geometry (`_maybeForwardWheelToCli`).
*
* Real code under test: constants.js + app.js + terminal-ui.js +
* terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts.
* xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts),
@@ -260,7 +267,8 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
expect(ev.stopPropagation).not.toHaveBeenCalled();
};
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
// The explicit "local scrollback" gesture: scrolled locally, never paged.
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
fireAndCheck(wheelLines(-12));
@@ -280,18 +288,207 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
});
});
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => {
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
const { ws, mount } = await connectTile(app, { mode: 'claude' });
describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => {
const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true };
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
const AT = { clientX: 171, clientY: 101 };
const UP = '\x1b[<64;21;6M';
const DOWN = '\x1b[<65;21;6M';
const ev = wheelLines(-12);
mount.fire('wheel', ev);
it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const up = wheelLines(-3, AT);
mount.fire('wheel', up);
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
mount.fire('wheel', wheelLines(2, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
});
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-100, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
mount.fire('wheel', wheelLines(-1000, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
const sideways = wheel(0, { ...AT, deltaX: 120 });
mount.fire('wheel', sideways);
expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames
expect(flushed(ws)).toHaveLength(1);
});
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-2, AT));
vi.advanceTimersByTime(20);
mount.fire('wheel', wheelLines(-2, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
});
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
term.buffer.active.baseY = 10;
term.buffer.active.viewportY = 0;
mount.fire('wheel', wheelLines(-1, AT));
expect(term.scrolledToBottom).toBe(1);
expect(term.buffer.active.viewportY).toBe(10);
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
});
it("follows the tile session's gate, never the active session's", async () => {
// Active session is a shell; the tile shows fullscreen Claude: the tile forwards.
const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), {
mode: 'claude',
});
forwarded.mount.fire('wheel', wheelLines(-1, AT));
expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]);
// Active session is fullscreen Claude; the tile shows inline Claude (no
// tracking): no reports, the hollow buffer is paged instead.
const inline = await connectTile(
makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'),
{ mode: 'claude' }
);
inline.mount.fire('wheel', wheelLines(-12, AT));
expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
mount.fire('wheel', ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(ev.stopPropagation).not.toHaveBeenCalled();
};
const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture
mount.fire('wheel', shifted);
expect(shifted.preventDefault).toHaveBeenCalled();
expect(term.scrolledLines).toEqual([-3]);
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
fireAndCheck(wheelLines(-3, AT));
term.modes.mouseTrackingMode = 'none';
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
fireAndCheck(wheelLines(-3, AT));
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(flushed(ws)).toEqual([]);
});
it('pages, never reports, a Claude older than 2.1.187', async () => {
const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } });
const { ws, mount } = await connectTile(app, { mode: 'claude' });
mount.fire('wheel', wheelLines(-12, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT));
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
const tileBytes: string[] = [];
for (const ev of sequence) {
mount.fire('wheel', ev);
tileBytes.push(
...flushed(ws)
.splice(tileBytes.length)
.map((f) => f.d as string)
);
}
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
terminal: unknown;
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
_wheelScrollLines(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = new FakeTerminal({});
for (const ev of sequence) {
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
primary._flushWheelSgrQueue();
}
expect(tileBytes.join('')).not.toBe('');
expect(tileBytes).toEqual(sent);
});
});
describe("Shift+wheel scrolls the tile's local scrollback itself", () => {
// xterm's own scroller turns a Shift+vertical wheel into a horizontal one off
// macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so
// left to xterm the gesture moved nothing.
it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => {
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode });
term.write(lines(40));
const bottom = term.buffer.active.baseY;
expect(bottom).toBeGreaterThan(5);
const up = wheelLines(-3, { shiftKey: true });
mount.fire('wheel', up);
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(term.buffer.active.viewportY).toBe(bottom - 3);
mount.fire('wheel', wheelLines(3, { shiftKey: true }));
expect(term.buffer.active.viewportY).toBe(bottom);
expect(flushed(ws)).toEqual([]);
});
it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => {
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true }));
expect(term.scrolledLines).toEqual([-4]);
});
it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => {
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line
mount.fire('wheel', wheel(-10, { shiftKey: true }));
expect(term.scrolledLines).toEqual([]);
mount.fire('wheel', wheel(-10, { shiftKey: true }));
expect(term.scrolledLines).toEqual([-1]);
});
it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => {
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
mount.fire('wheel', wheelLines(-3, { shiftKey: true }));
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
});
});
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => {
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
-243
View File
@@ -1,243 +0,0 @@
/**
* @fileoverview tile-grid.js's side of the tile entrance styles (the entrance
* module, entrance-animations.js, picks the style and times it):
*
* - `settle` (the default, and what the harness gets with no entrance module
* loaded) is the grid's own motion, untouched: tile-grid-motion.test.ts.
* - Any other style hands each mounted tile to `_stageTileEntrance`, which
* arms the backstop that ends the entrance if animationend never comes.
* - `.tile--entering` comes off on the tile's own `tile-enter*` end only,
* never on a ::before wash ending first.
* - `off` mounts tiles with no entrance and no screen beat.
* - A reload's restore settles whatever the style, and owes no screen beat.
* - The screen beat (`playTileScreenEntrance`) plays once, when the load queue
* reports a tile's first capture done, and only for a tile style (never
* with the default `settle`).
* - The closing grid's still copy hands its copies to `_stageTileExit` and
* waits as long as it says before the fallback removes the copy.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
localStore,
main,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d'];
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
/** An app whose entrance module picks `style`, with the module's hooks recorded. */
function styledApp(style: string): GridApp {
const app = makeGridApp(IDS);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
app.tileEntranceStyle = () => style;
app._stageTileEntrance = vi.fn();
app.playTileScreenEntrance = vi.fn();
return app;
}
/** Swaps in a load queue that hands the grid's state callback to the test. */
function captureQueue() {
const Queue = windowStub.TileLoadQueue;
const box: { report: (tile: unknown, state: string) => void; restore: () => void } = {
report: () => {},
restore: () => {
windowStub.TileLoadQueue = Queue;
},
};
windowStub.TileLoadQueue = class {
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
box.report = opts.onChange;
}
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
return box;
}
beforeEach(() => {
resetGridHarness();
});
afterEach(() => {
vi.useRealTimers();
});
describe('a themed entrance', () => {
it('hands every mounted tile to the entrance module, entering and in reading order', () => {
const app = styledApp('fly');
app.openTileGrid(IDS.slice(0, 3), { focusedId: 's-b' });
expect(app._stageTileEntrance).toHaveBeenCalledTimes(3);
const calls = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.map((c) => c[1])).toEqual(['s-a', 's-b', 's-c']);
for (const [el] of calls) expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
});
it('the backstop the module arms ends the entrance when animationend never comes', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const [el, , setBackstop] = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls[0];
(el as FakeEl).classList.add('tile--enter-themed', 'tile--enter-hold');
setBackstop(800);
vi.advanceTimersByTime(799);
expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
vi.advanceTimersByTime(1);
expect((el as FakeEl).className.split(' ')).not.toContain('tile--entering');
expect((el as FakeEl).classList.contains('tile--enter-themed')).toBe(false);
expect((el as FakeEl).classList.contains('tile--enter-hold')).toBe(false);
});
it('ends on its own tile-enter-* animationend, never on a ::before wash or a child', () => {
const app = styledApp('crt');
app.openTileGrid(['s-a']);
const el = tiles()[0];
el.dispatch('animationend', { target: el, animationName: 'win-enter-crt-flash', pseudoElement: '::before' });
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '::before' });
el.dispatch('animationend', { target: new FakeEl(), animationName: 'tile-enter-crt' });
expect(el.classList.contains('tile--entering')).toBe(true);
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '' });
expect(el.classList.contains('tile--entering')).toBe(false);
});
it('`off` mounts tiles with no entrance and owes them no screen beat', () => {
const queue = captureQueue();
try {
const app = styledApp('off');
app.openTileGrid(['s-a', 's-b']);
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('`settle` stays on the grid default: no hand-off to the module', () => {
const app = styledApp('settle');
app.openTileGrid(['s-a', 's-b']);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1']);
});
});
describe('the screen beat', () => {
it('never plays with the default settle: the grid behaves exactly as before', () => {
const queue = captureQueue();
try {
const app = styledApp('settle');
app.openTileGrid(['s-a']);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
} finally {
queue.restore();
}
});
it('plays once, on the first capture landing, on that tile body', () => {
const queue = captureQueue();
try {
const app = styledApp('fly');
app.openTileGrid(['s-a', 's-b']);
app._tileLoadQueue();
const tileA = app._tileFor('s-a');
queue.report(tileA, 'running');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
expect(app.playTileScreenEntrance).toHaveBeenCalledWith(app._tileGrid.tiles.get('s-a').body);
// A later load (a reconnect refresh) plays nothing again.
queue.report(tileA, 'running');
queue.report(tileA, 'idle');
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('a reload restoring the grid', () => {
it('settles whatever the style, and owes no screen beat (nothing animates on page load)', () => {
const queue = captureQueue();
try {
localStore.set(
'codeman:tile-grid',
JSON.stringify({
v: 1,
open: true,
ids: ['s-a', 's-b'],
focused: 's-a',
zoomed: null,
colFr: [1, 1],
rowFr: [1],
})
);
const app = styledApp('deal');
expect(app._restoreTileGrid()).toBe(true);
expect(app._stageTileEntrance).not.toHaveBeenCalled();
expect(tiles().every((t) => t.classList.contains('tile--entering'))).toBe(true);
app._tileLoadQueue();
queue.report(app._tileFor('s-a'), 'idle');
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
// The quiet flag is gone after the restore: the next open is themed.
app.closeTileGrid({ reselect: false });
app.openTileGrid(['s-c']);
expect(app._stageTileEntrance).toHaveBeenCalledTimes(1);
} finally {
queue.restore();
}
});
});
describe('closing with the Tiles toggle', () => {
it('hands the copies to the exit, stripped of entrance state, and waits as long as it says', () => {
vi.useFakeTimers();
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 1200);
app.openTileGrid(IDS.slice(0, 2));
for (const t of tiles()) t.classList.add('tile--enter-themed', 'tile--enter-hold');
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
app.toggleTileGrid();
const [copies, opts] = (app._stageTileExit as ReturnType<typeof vi.fn>).mock.calls[0];
expect(opts).toEqual({ now: false });
expect((copies as Array<{ sessionId: string }>).map((c) => c.sessionId)).toEqual(['s-a', 's-b']);
const layer = ghosts()[0];
for (const g of layer.children) {
expect(g.classList.contains('tile--enter-themed')).toBe(false);
expect(g.classList.contains('tile--enter-hold')).toBe(false);
}
// Held 700 ms (the single view never settles here), then released: the
// fallback is the exit's 1200 ms, not the default fade's 450.
vi.advanceTimersByTime(700);
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
vi.advanceTimersByTime(1199);
expect(ghosts()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(ghosts()).toHaveLength(0);
});
it('the copy goes on its last tile’s tile-leave-* end, not on a ::before wash', () => {
const app = styledApp('crt');
app._stageTileExit = vi.fn(() => 900);
app.openTileGrid(IDS.slice(0, 2));
app.toggleTileGrid();
const layer = ghosts()[0];
const last = layer.lastElementChild!;
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '::before' });
expect(ghosts()).toHaveLength(1);
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '' });
expect(ghosts()).toHaveLength(0);
});
});