mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 17:59:41 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82aeeaec02 | ||
|
|
ad57394618 |
@@ -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) 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)
|
||||
**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)
|
||||
**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)
|
||||
|
||||
@@ -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 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`.
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
|
||||
+7
-11
@@ -1008,17 +1008,13 @@ 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: 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.)
|
||||
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.)
|
||||
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`,
|
||||
|
||||
@@ -99,6 +99,7 @@ every session or only the active tab.
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| 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). |
|
||||
|
||||
@@ -73,6 +73,10 @@ precedence there.
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
|
||||
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
@@ -56,5 +56,3 @@ 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`.
|
||||
|
||||
@@ -6709,7 +6709,9 @@ class CodemanApp {
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
|
||||
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
|
||||
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
|
||||
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
line1.appendChild(badge);
|
||||
} else {
|
||||
// The agent's logo: PR #532's slot, the mode id as data (an id with no
|
||||
// logo rule gets the slot's plain dot).
|
||||
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
|
||||
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -369,6 +369,9 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -2216,6 +2216,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
|
||||
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -2577,6 +2578,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -3533,6 +3535,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3669,6 +3672,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3956,6 +3969,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4005,6 +4026,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4253,7 +4282,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -3010,6 +3010,19 @@ body.solo-mode .btn-lifecycle-log {
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
|
||||
applyTabOrientation() and the pre-paint script in index.html stamp the
|
||||
attribute). The logo leaves the session tabs (header strip, vertical rail,
|
||||
sidebar, grouped rail, phone chips) and the desktop home rail; the status
|
||||
dot and the shell's SH pill stay. Every row it sits in spaces its children
|
||||
with flex `gap`, which skips a display:none item, so no empty slot is left.
|
||||
Tile and split headers (.tile-harness, .split-harness) and the Run menus
|
||||
keep their logos: only these two classes are named here. */
|
||||
html[data-tab-logos='off'] .session-tab .tab-harness,
|
||||
html[data-tab-logos='off'] .home-sessions-harness {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
|
||||
@@ -11,20 +11,14 @@
|
||||
*
|
||||
* 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), and no keyboard
|
||||
* accessory bar. 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), 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.
|
||||
*
|
||||
* 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
|
||||
@@ -49,7 +43,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/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_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
|
||||
*/
|
||||
@@ -207,9 +201,6 @@
|
||||
// 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;
|
||||
@@ -1036,18 +1027,16 @@
|
||||
|
||||
// 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 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
|
||||
// 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
|
||||
// stopPropagation in the capture phase, the primary pane's technique), so
|
||||
// xterm's viewport, a descendant, never sees them. Every other wheel is left
|
||||
// xterm's viewport, a descendant, never sees it. 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._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
|
||||
if (this._maybePageCliTranscript(ev)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
@@ -1057,71 +1046,6 @@
|
||||
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
|
||||
@@ -1163,9 +1087,9 @@
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
@@ -1187,9 +1111,8 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -107,32 +107,6 @@
|
||||
: 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
|
||||
@@ -286,9 +260,6 @@
|
||||
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,
|
||||
@@ -5592,8 +5563,9 @@ 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) {
|
||||
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
|
||||
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
|
||||
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);
|
||||
},
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
@@ -5667,13 +5639,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;
|
||||
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
|
||||
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));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
|
||||
* false hides the agent logo on session tabs and the desktop home rail
|
||||
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
|
||||
* and the Run menus keep their logos.
|
||||
*/
|
||||
showTabCliLogos: z.boolean().optional(),
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
|
||||
@@ -199,19 +199,6 @@ 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);
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* @fileoverview CLI Logos on Tabs (`showTabCliLogos`): the per-device switch
|
||||
* that hides the agent logo on the session tabs and the desktop home rail.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The switch is a row in App Settings → Appearance → Tabs, right after Tall
|
||||
* Tabs, and the REAL openAppSettings() / saveAppSettings() load and save it
|
||||
* by its id. The load/save contract is getElementById by id, so a renamed
|
||||
* control would otherwise just stop loading or saving, silently.
|
||||
* - It is modelled on tabTwoRows: a display key in the server-settings merge
|
||||
* (a phone never overwrites a desktop's choice) AND an optional boolean in
|
||||
* the .strict() SettingsUpdateSchema (the save PUTs it; a key the schema did
|
||||
* not declare would 400 the whole settings save).
|
||||
* - Default ON on every device; only an explicit false turns it off.
|
||||
* - The pre-paint script in index.html stamps `data-tab-logos` from the same
|
||||
* stored blob, so a reload never flashes the logos it is about to hide, and
|
||||
* a Save stamps it live through applyTabOrientation() without re-rendering
|
||||
* the tabs (the logo spans are always in the markup; CSS hides them).
|
||||
* - The CSS names exactly the tab logo and the home-rail logo. Tile and split
|
||||
* headers, the Run menus and the welcome launchers keep theirs.
|
||||
* - The row reads in Chinese under zh-CN.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss, { type Rule } from 'postcss';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const STYLES = read('styles.css');
|
||||
const MOBILE = read('mobile.css');
|
||||
const I18N = read('i18n.js');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
const PHONE_KEY = 'codeman-app-settings-mobile';
|
||||
|
||||
type Device = 'desktop' | 'mobile';
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
loadAppSettingsFromStorage(): Settings;
|
||||
getDefaultSettings(): Settings;
|
||||
tabCliLogosEnabled(s: Settings): boolean;
|
||||
applyTabOrientation(): void;
|
||||
_cachedAppSettings?: unknown;
|
||||
_apiPut: (path: string, body: Settings) => Promise<unknown>;
|
||||
_fullRenderSessionTabs: ReturnType<typeof vi.fn>;
|
||||
updateTabOverflowMode: ReturnType<typeof vi.fn>;
|
||||
_updateConnectionLinesImmediate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods that run for real. Every other method of the app is a no-op here:
|
||||
* openAppSettings() and saveAppSettings() fan out into the voice, webhook,
|
||||
* tunnel, model and push panels, none of which this setting touches, and the
|
||||
* load/save lines under test sit between those calls.
|
||||
*/
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'tabCliLogosEnabled',
|
||||
'applyTabOrientation',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Methods the load/save paths call that other modules add to the prototype
|
||||
* (terminal-ui.js, panels-ui.js, session-ui.js), which this harness does not
|
||||
* load. Stubbed as own properties.
|
||||
*/
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot (new CodemanApp(): SSE,
|
||||
// timers, the terminal) never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => ${JSON.stringify(device)},
|
||||
isHandheldDevice: () => ${JSON.stringify(device)} !== 'desktop',
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
target._fullRenderSessionTabs = vi.fn();
|
||||
target.updateTabOverflowMode = vi.fn();
|
||||
target._updateConnectionLinesImmediate = vi.fn();
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const doc = win.document;
|
||||
const checkbox = () => doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const storedBlob = () =>
|
||||
JSON.parse(win.localStorage.getItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY) || '{}') as Settings;
|
||||
return { win, doc, app, checkbox, storedBlob };
|
||||
}
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const input = doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const row = input?.closest('.set-row') as HTMLElement;
|
||||
|
||||
it('is a switch in Appearance → Tabs (device scope), right after Tall Tabs', () => {
|
||||
expect(input?.type).toBe('checkbox');
|
||||
expect(input.parentElement!.matches('label.switch.switch-sm')).toBe(true);
|
||||
const group = row.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Tabs');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('device');
|
||||
expect(row.closest('.set-section')!.id).toBe('settings-appearance');
|
||||
expect(row.previousElementSibling!.querySelector('#appSettingsTabTwoRows')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says what it shows, what stays, and what it does not touch', () => {
|
||||
expect(row.querySelector('.set-row-label')!.textContent).toBe('CLI Logos on Tabs');
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
for (const part of ['logo', 'status dot', 'SH badge', 'Tiles', 'split headers', 'Run menus']) {
|
||||
expect(desc, part).toContain(part);
|
||||
}
|
||||
});
|
||||
|
||||
it('is found by the settings search for logo, icon, harness, agent, cli, tab and hide', () => {
|
||||
const words = row.getAttribute('data-search')!.split(/\s+/);
|
||||
for (const word of ['logo', 'icon', 'harness', 'agent', 'cli', 'tab', 'hide']) expect(words, word).toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load and save through settings-ui.js', () => {
|
||||
it('loads the stored value into the switch, and an absent key as on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{ showTabCliLogos: false }, false],
|
||||
[{ showTabCliLogos: true }, true],
|
||||
[{}, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot('desktop', stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves the switch to this device and sends it in the settings PUT, which the schema accepts', async () => {
|
||||
const { app, checkbox, storedBlob } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(storedBlob().showTabCliLogos).toBe(false);
|
||||
expect(app._apiPut).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = (app._apiPut as unknown as { mock: { calls: [string, Settings][] } }).mock.calls[0];
|
||||
expect(path).toBe('/api/settings');
|
||||
expect(body.showTabCliLogos).toBe(false);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('a Save stamps the attribute live', async () => {
|
||||
const { app, checkbox, doc } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
checkbox().checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device like tabTwoRows', () => {
|
||||
it('is a display key: a server value only seeds a device that has none', async () => {
|
||||
// Local choice made: the server's value (another device's) never replaces it,
|
||||
// while a synced key in the same payload does (the merge really ran).
|
||||
const kept = await boot('desktop', { showTabCliLogos: true, autoNameSessions: false });
|
||||
const merged = await kept.app.loadAppSettingsFromServer(
|
||||
Promise.resolve({ showTabCliLogos: false, autoNameSessions: true })
|
||||
);
|
||||
expect(merged.showTabCliLogos).toBe(true);
|
||||
expect(merged.autoNameSessions).toBe(true);
|
||||
|
||||
// A fresh device takes the server's value as its seed.
|
||||
const fresh = await boot('desktop');
|
||||
expect(
|
||||
(await fresh.app.loadAppSettingsFromServer(Promise.resolve({ showTabCliLogos: false }))).showTabCliLogos
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is an optional boolean in SettingsUpdateSchema, and nothing else passes', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({}).success).toBe(true);
|
||||
for (const bad of ['off', 'false', 0, 1, null, {}]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: bad }).success, JSON.stringify(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to on, on a desktop and on a phone', async () => {
|
||||
for (const device of ['desktop', 'mobile'] as const) {
|
||||
const { app, checkbox } = await boot(device);
|
||||
expect(app.tabCliLogosEnabled(app.loadAppSettingsFromStorage()), device).toBe(true);
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, device).toBe(true);
|
||||
}
|
||||
// The phone's defaults blob carries it explicitly, like its other tab keys.
|
||||
expect((await boot('mobile')).app.getDefaultSettings().showTabCliLogos).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off', async () => {
|
||||
const { app } = await boot();
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: false })).toBe(false);
|
||||
for (const value of [true, undefined, 'off', 0, null]) {
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: value }), String(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTabOrientation() stamps data-tab-logos without re-rendering', () => {
|
||||
async function settled(stored: Settings) {
|
||||
const booted = await boot('desktop', stored);
|
||||
booted.app.applyTabOrientation();
|
||||
for (const fn of [
|
||||
booted.app._fullRenderSessionTabs,
|
||||
booted.app.updateTabOverflowMode,
|
||||
booted.app._updateConnectionLinesImmediate,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
const apply = (next: Settings) => {
|
||||
booted.win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(next));
|
||||
delete booted.app._cachedAppSettings;
|
||||
booted.app.applyTabOrientation();
|
||||
};
|
||||
return { ...booted, apply };
|
||||
}
|
||||
|
||||
it('flips the attribute and leaves the tab markup alone', async () => {
|
||||
const { doc, app, apply } = await settled({ showTabCliLogos: true });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-measures the strip wrap and re-anchors the lines on a flip, and only on a flip', async () => {
|
||||
// Every agent tab just got narrower (or wider) with no render behind it: the
|
||||
// one-row wrap decision and the lines drawn from tab rects are stale.
|
||||
const { app, apply } = await settled({ showTabCliLogos: true });
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: true });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(2);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(2);
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pre-paint script in index.html', () => {
|
||||
// The layout one: there is an earlier inline script with the same opening.
|
||||
const PRE_PAINT =
|
||||
[...INDEX.matchAll(/<script>(try\{var m=window\.innerWidth[\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.find((src) => src.includes('dataset.tabStateOrder')) ?? '';
|
||||
|
||||
/**
|
||||
* Runs the real inline script in a fresh window as `device` (a phone is a
|
||||
* 393px viewport, the width test the script uses), with `blob` stored under
|
||||
* that device's key, or under `storeAs`'s key when given.
|
||||
*/
|
||||
function prePaint(blob: string | null, device: Device = 'desktop', storeAs: Device = device) {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const win = dom.window as unknown as Window & { eval(src: string): void };
|
||||
Object.defineProperty(win, 'innerWidth', { value: device === 'desktop' ? 1440 : 393, configurable: true });
|
||||
if (blob !== null) win.localStorage.setItem(storeAs === 'desktop' ? DESKTOP_KEY : PHONE_KEY, blob);
|
||||
win.eval(PRE_PAINT);
|
||||
return win.document.documentElement;
|
||||
}
|
||||
|
||||
it('is found (the checks below are not vacuous)', () => {
|
||||
expect(PRE_PAINT).toContain('localStorage.getItem(k)');
|
||||
});
|
||||
|
||||
it('stamps off from the stored setting, on a desktop and on a phone', () => {
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false })).dataset.tabLogos).toBe('off');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile').dataset.tabLogos).toBe('off');
|
||||
});
|
||||
|
||||
it('leaves the logos on otherwise', () => {
|
||||
expect(prePaint(null).dataset.tabLogos).toBe('on');
|
||||
expect(prePaint('{}').dataset.tabLogos).toBe('on');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: true })).dataset.tabLogos).toBe('on');
|
||||
// A phone reads its own blob: a desktop's choice does not reach it.
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile', 'desktop').dataset.tabLogos).toBe('on');
|
||||
});
|
||||
|
||||
it('falls back to on, beside the other fallbacks, when the stored blob does not parse', () => {
|
||||
const root = prePaint('{not json');
|
||||
expect(root.dataset.tabLogos).toBe('on');
|
||||
expect(root.dataset.tabArrangement).toBe('classic');
|
||||
expect(root.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the CSS', () => {
|
||||
const rulesNaming = (css: string, pattern: RegExp) => {
|
||||
const found: Rule[] = [];
|
||||
postcss.parse(css).walkRules((rule: Rule) => {
|
||||
if (pattern.test(rule.selector)) found.push(rule);
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const selectorsOf = (rule: Rule) => rule.selectors.map((s) => s.replace(/\s+/g, ' ').trim());
|
||||
|
||||
it('hides the tab logo and the home-rail logo under the attribute, in one rule, and nothing else', () => {
|
||||
const rules = rulesNaming(STYLES, /data-tab-logos/);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(selectorsOf(rules[0])).toEqual([
|
||||
"html[data-tab-logos='off'] .session-tab .tab-harness",
|
||||
"html[data-tab-logos='off'] .home-sessions-harness",
|
||||
]);
|
||||
const decls: string[] = [];
|
||||
rules[0].walkDecls((d) => {
|
||||
decls.push(`${d.prop}: ${d.value}${d.important ? ' !important' : ''}`);
|
||||
});
|
||||
expect(decls).toEqual(['display: none']);
|
||||
expect(rules[0].parent?.type).toBe('root');
|
||||
});
|
||||
|
||||
it('leaves the tile and split headers, the Run menus and the welcome launchers their logos', () => {
|
||||
const selectors = rulesNaming(STYLES, /data-tab-logos/)
|
||||
.flatMap(selectorsOf)
|
||||
.join(' ');
|
||||
for (const other of ['tile-harness', 'split-harness', 'run-mode-option', 'welcome', 'mobile-overview']) {
|
||||
expect(selectors, other).not.toContain(other);
|
||||
}
|
||||
// Not the shared slot either: the Run menus draw the same `.run-mode-dot`.
|
||||
expect(selectors).not.toMatch(/(^|\s)\.run-mode-dot/);
|
||||
});
|
||||
|
||||
it('nothing on a phone or in a skin brings the logo back over the rule', () => {
|
||||
expect(MOBILE).not.toContain('data-tab-logos');
|
||||
for (const css of [STYLES, MOBILE]) {
|
||||
for (const rule of rulesNaming(css, /tab-harness|home-sessions-harness|run-mode-dot/)) {
|
||||
rule.walkDecls('display', (d) => {
|
||||
// The only other display rule on the logo is the compact rail's rename
|
||||
// rule, which hides it too.
|
||||
expect(d.value, rule.selector).toBe('none');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh-CN', () => {
|
||||
const rowText = (doc: Document) => {
|
||||
const row = doc.getElementById('appSettingsShowTabCliLogos')!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
};
|
||||
const english = rowText(new JSDOM(INDEX).window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowText(dom.window.document);
|
||||
/** What may stay Latin: the CLI and SH names. */
|
||||
const leftover = (text: string) => text.replace(/\b(CLI|SH|Shell)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
expect(chinese.label).toBe('标签页上的 CLI 图标');
|
||||
expect(chinese.description).not.toBe(english.description);
|
||||
expect(leftover(chinese.label)).toEqual([]);
|
||||
expect(leftover(chinese.description)).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const key of [english.label, english.description]) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect(I18N.match(new RegExp(`^\\s*(?:'${escaped}'|"${escaped}"):`, 'gm')) ?? [], key).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -61,20 +61,6 @@ 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');
|
||||
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
* 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
|
||||
@@ -206,95 +200,4 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,13 +18,6 @@
|
||||
* 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),
|
||||
@@ -267,8 +260,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
// The explicit "local scrollback" gesture: scrolled locally, never paged.
|
||||
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
|
||||
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-12));
|
||||
@@ -288,207 +280,18 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
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(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' } });
|
||||
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' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-12, AT));
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
|
||||
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(ev.preventDefault).not.toHaveBeenCalled();
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user