mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
fix(tiles): page a hollow tile's CLI transcript and report its clicks (#555 parity)
#555 made the primary pane page opencode's transcript with PageUp/PageDown from the wheel, because opencode draws in place on the alternate screen and leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile, the split's Pane B) left every wheel to xterm, so in an opencode tile the wheel scrolled nothing, or only stale rows. The tile now runs the primary pane's own gates aimed at itself (its terminal, its session, never the active one): xterm's tracking mode, the Claude forwarding gate, then the hollow-buffer test. A wheel that passes them is consumed in the capture phase and turned into PageUp/PageDown through the shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin of the primary pane's queue) and sent ephemeral on the tile's own socket. Every other wheel stays with xterm as before, the shell history pull included. The file names no CLI: the mode rules stay in terminal-ui.js, and terminal-tile.js joins the frontend no-id-branching guard. A plain port of the primary's baseY === 0 test would almost never fire in a grid. A tile's first capture is taken at the PTY's previous size (usually the taller primary pane's) and written into a shorter xterm, and its own row-shrinking fits (zoom-out, divider drags, tile count changes) push more rows above the screen. The tile counts those rows as its own overflow: all of them after a load whose capture held a single screen (the server's captureRows), plus whatever a local fit or a PTY geometry report pushes up, reset by a clear and clamped to baseY. The paging gate gets baseY minus that count. Output that scrolls real lines still counts as history, so the tile stops paging there. #555's other half, stripping opencode's mouse DECSETs so a drag selects text, is server-side and already reached tile sockets. It also left the tile's xterm unable to encode opencode's clicks, so the tile now installs the primary pane's desktop click report (bubble phase, gated on the session's cliMouseTracking, the tile's own link hover and selection). Both listeners, the flush timer and the page-key state are torn down in destroy(). Still out of scope, as the fileoverview now says: touch paging (tiles have no touch path) and SGR wheel forwarding to Claude's fullscreen renderer (tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the wheel to xterm. Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm harness (session targeting, every no-page case, accumulation, the cap, coalescing, byte parity with the primary pane, the overflow discount through a load, a fit, a geometry report and a clear, the click report and destroy); the discount cases fail with it removed. The fake xterm gains opt-in row emulation. test/terminal-tile-scroll.browser.test.ts checks the same model against a real xterm with trusted wheel events (browser suite, not the gate). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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. 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)
|
||||
|
||||
@@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -92,7 +92,10 @@ view needs a wide viewport). So:
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user.
|
||||
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
|
||||
which has gained two pieces of the primary pane through its own gates aimed
|
||||
at the tile: hollow-buffer wheel paging (#555) and the desktop click report
|
||||
for a CLI with `cliMouseTracking` on. See that file's fileoverview.)
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
|
||||
@@ -998,7 +998,12 @@ exits green. Use the browser runner for those files and read the file count.
|
||||
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||
on drain) for grids over slow links.
|
||||
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
||||
a "Load full history" action inside a tile.
|
||||
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`,
|
||||
|
||||
@@ -219,7 +219,8 @@ Worth knowing:
|
||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
always local scrollback. OpenCode's wheel and swipes page its own conversation
|
||||
(PageUp/PageDown). Other CLIs scroll locally.
|
||||
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
|
||||
too. Other CLIs scroll locally.
|
||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||
`Ctrl+Shift+C` always copies.
|
||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||
|
||||
@@ -11,12 +11,27 @@
|
||||
*
|
||||
* Deliberately plainer than the primary pane (this.terminal/this._ws in
|
||||
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
|
||||
* handlers, no keyboard accessory bar. Desktop-only by nature; see
|
||||
* docs/split-pane-sessions-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):
|
||||
* - 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
|
||||
* (_maybePageCliTranscript) through the primary pane's gates, plus an
|
||||
* overflow-row discount for this pane's capture-before-resize load
|
||||
* (_localRows).
|
||||
* - The desktop click report: a plain left-click hand-encoded as SGR while
|
||||
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
|
||||
* whose mouse DECSETs the server strips (_installClickListener).
|
||||
*
|
||||
* @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)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
|
||||
@@ -157,6 +172,19 @@
|
||||
// flag, app._linkHovered, belongs to its terminal alone).
|
||||
this._linkHovered = false;
|
||||
this._onFocusIn = null;
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
|
||||
// whole page, carried to the next wheel event.
|
||||
this._pageKeyPending = 0;
|
||||
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
|
||||
this._scrollBytes = '';
|
||||
this._scrollFlushTimer = null;
|
||||
// Rows above the screen that this pane pushed there itself rather than
|
||||
// received as history: a capture taken at the PTY's previous, taller size
|
||||
// and row-shrinking fits (_overflowAfterLoad, _noteResizeRows). Not
|
||||
// history, so the paging gate leaves them out (_localRows).
|
||||
this._overflowRows = 0;
|
||||
// The desktop click reporter (_installClickListener).
|
||||
this._onClick = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -192,6 +220,7 @@
|
||||
});
|
||||
|
||||
this._installWheelListener();
|
||||
this._installClickListener();
|
||||
|
||||
// Focusing this terminal makes it the pane the keyboard is in, so the
|
||||
// app-level shortcuts, voice and paste act on it (app._focusedPane).
|
||||
@@ -600,6 +629,7 @@
|
||||
// Cleared at the load's turn, not when it was asked for: a grid tile
|
||||
// waiting in the queue keeps its last frame instead of sitting blank.
|
||||
this.terminal?.clear();
|
||||
this._overflowRows = 0;
|
||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue
|
||||
// a trailing refresh behind a pull that the socket's close then
|
||||
// interrupts), so a refresh on a closed socket owes it back once its
|
||||
@@ -635,6 +665,7 @@
|
||||
() => this._destroyed,
|
||||
(cancel) => (this._cancelReplay = cancel)
|
||||
);
|
||||
if (!this._destroyed && this.terminal) this._overflowRows = this._overflowAfterLoad(payload);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort: live output still arrives once the socket connects. */
|
||||
@@ -699,18 +730,140 @@
|
||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this.terminal?.clear();
|
||||
else this._clearTerminal();
|
||||
}
|
||||
|
||||
// A clear leaves no rows above the screen, the pane's own overflow included.
|
||||
_clearTerminal() {
|
||||
this.terminal?.clear();
|
||||
this._overflowRows = 0;
|
||||
}
|
||||
|
||||
// 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. Passive: this only observes,
|
||||
// xterm keeps doing the scrolling.
|
||||
// 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 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._maybePageCliTranscript(ev)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||
};
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
|
||||
}
|
||||
|
||||
// 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
|
||||
// drag selects text), which leaves this xterm's own mouse encoder idle for
|
||||
// them; without this a click in such a pane never reached the CLI. Only
|
||||
// while this pane's session has tracking on (cliMouseTracking), through the
|
||||
// same skips as the primary pane. Bubble phase, as there. The target is
|
||||
// built per click, so the terminal and the link hover are read live.
|
||||
_installClickListener() {
|
||||
this._onClick = (ev) => {
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
global.app?._handleDesktopTerminalClick?.(ev, {
|
||||
terminal: this.terminal,
|
||||
sessionId: this.sessionId,
|
||||
linkHovered: this._linkHovered,
|
||||
});
|
||||
};
|
||||
this.mountEl.addEventListener('click', this._onClick);
|
||||
}
|
||||
|
||||
// Hollow-buffer paging, the twin of the primary pane's
|
||||
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
|
||||
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
|
||||
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
|
||||
// travel pages the CLI's own transcript with PageUp/PageDown. Every 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. Returns true when the wheel was consumed here.
|
||||
_maybePageCliTranscript(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it (a shell running htop), as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// The primary pane would forward this wheel to Claude's fullscreen
|
||||
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
|
||||
// follow-up 4), so the wheel stays with xterm, as before.
|
||||
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
|
||||
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
|
||||
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return false;
|
||||
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
|
||||
this._pageKeyPending = step.pending;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
|
||||
// fling cannot build a backlog that keeps paging after it stops. A narrow
|
||||
// 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
|
||||
// session, over this pane's socket while it is open.
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || this._destroyed) return;
|
||||
if (this._scrollBytes.length > 512) return;
|
||||
this._scrollBytes += data;
|
||||
if (this._scrollFlushTimer) return;
|
||||
this._scrollFlushTimer = setTimeout(() => {
|
||||
this._scrollFlushTimer = null;
|
||||
const bytes = this._scrollBytes;
|
||||
this._scrollBytes = '';
|
||||
if (bytes && !this._destroyed) global.app?._sendInputEphemeral?.(this.sessionId, bytes);
|
||||
}, 40);
|
||||
}
|
||||
|
||||
// History rows in this xterm, for the paging gate: baseY less the rows this
|
||||
// pane pushed up itself. Clamped, because a clear (Ctrl+L, a `{t:'c'}`
|
||||
// frame) or an ED3/RIS in the stream drops rows behind this count's back.
|
||||
_localRows() {
|
||||
const baseY = this.terminal?.buffer?.active?.baseY || 0;
|
||||
this._overflowRows = Math.min(this._overflowRows, baseY);
|
||||
return baseY - this._overflowRows;
|
||||
}
|
||||
|
||||
// After a local resize: rows a shrinking fit pushed above the screen count
|
||||
// as overflow, and rows a growing one pulled back come off it. `before` is
|
||||
// baseY just before the resize (xterm resizes synchronously).
|
||||
_noteResizeRows(before) {
|
||||
const after = this.terminal?.buffer?.active?.baseY || 0;
|
||||
this._overflowRows = Math.max(0, Math.min(after, this._overflowRows + (after - before)));
|
||||
}
|
||||
|
||||
// The overflow a finished load leaves: everything above the screen when the
|
||||
// capture held a single screen (the server says how tall in `captureRows`;
|
||||
// the full-history path keeps every pane row and trims one newline), none
|
||||
// when it carried history. The counterpart of the primary pane resizing the
|
||||
// PTY before it captures (app.js selectSession's sendResize), which this
|
||||
// pane does not do: its first capture is taken at the PTY's previous size
|
||||
// (usually the primary pane's, taller) and written into a shorter xterm,
|
||||
// whose extra rows land above the screen with nothing after them to clear
|
||||
// them. Without a `captureRows` the raw baseY stands, as in the primary.
|
||||
_overflowAfterLoad(payload) {
|
||||
const captureRows = payload?.captureRows;
|
||||
if (!Number.isFinite(captureRows)) return 0;
|
||||
const text = payload.terminalBuffer || '';
|
||||
let lines = 1;
|
||||
for (let i = text.indexOf('\n'); i !== -1 && lines <= captureRows; i = text.indexOf('\n', i + 1)) lines++;
|
||||
if (lines > captureRows) return 0;
|
||||
return this.terminal?.buffer?.active?.baseY || 0;
|
||||
}
|
||||
|
||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||
@@ -818,6 +971,7 @@
|
||||
}
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
this._overflowRows = 0; // the reset leaves nothing above the screen
|
||||
replayed = true;
|
||||
if (this._wsClosed) this._markerOwed = true;
|
||||
await writeChunked(
|
||||
@@ -849,7 +1003,7 @@
|
||||
const cutoff = replayed ? capturedAt : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < cutoff) continue;
|
||||
if (entry.clear) this.terminal?.clear();
|
||||
if (entry.clear) this._clearTerminal();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// Settled after the queue flush so the marker is the last thing on
|
||||
@@ -894,7 +1048,9 @@
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
const before = this.terminal?.buffer?.active?.baseY || 0;
|
||||
this.fitAddon.fit();
|
||||
this._noteResizeRows(before);
|
||||
}
|
||||
|
||||
// Reflow to the container and tell the PTY, as one step: the xterm and the
|
||||
@@ -955,7 +1111,9 @@
|
||||
{ cols, rows }
|
||||
);
|
||||
if (!verdict?.adopt) return;
|
||||
const before = terminal.buffer?.active?.baseY || 0;
|
||||
terminal.resize(verdict.cols, terminal.rows);
|
||||
this._noteResizeRows(before); // a column change reflows rows above the screen
|
||||
this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
|
||||
}
|
||||
|
||||
@@ -982,6 +1140,15 @@
|
||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||
this._onWheel = null;
|
||||
}
|
||||
if (this._onClick) {
|
||||
this.mountEl?.removeEventListener('click', this._onClick);
|
||||
this._onClick = null;
|
||||
}
|
||||
// Page keys still waiting for their flush go nowhere: the pane is gone.
|
||||
clearTimeout(this._scrollFlushTimer);
|
||||
this._scrollFlushTimer = null;
|
||||
this._scrollBytes = '';
|
||||
this._pageKeyPending = 0;
|
||||
this._detachSocket();
|
||||
if (this._onFocusIn) {
|
||||
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* (`session-ui.js`, `mobile-overview.js`), plus the files that draw a session
|
||||
* header's harness logo and model (`constants.js`, `terminal-split.js`,
|
||||
* `tile-grid.js`: the logo's `run-mode-dot <cliId>` class is the id as DATA),
|
||||
* mirroring
|
||||
* and `terminal-tile.js`, whose wheel paging and click reports reach the
|
||||
* primary pane's CLI rules through terminal-ui.js and must not grow a copy of
|
||||
* them, mirroring
|
||||
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||
*
|
||||
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||
@@ -24,7 +26,14 @@ import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js', 'constants.js', 'terminal-split.js', 'tile-grid.js'];
|
||||
const SCANNED_FILES = [
|
||||
'session-ui.js',
|
||||
'mobile-overview.js',
|
||||
'constants.js',
|
||||
'terminal-split.js',
|
||||
'tile-grid.js',
|
||||
'terminal-tile.js',
|
||||
];
|
||||
|
||||
/**
|
||||
* Every currently-surviving branch, each with the COUNT of physical call
|
||||
@@ -100,6 +109,20 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
reason: 'attach route: a shell session attaches through /shell, an agent through /interactive',
|
||||
},
|
||||
|
||||
// terminal-tile.js: the pane's two shell-only mechanisms, both mirrors of the
|
||||
// primary pane's own shell checks (terminal-ui.js / app.js). Its wheel
|
||||
// paging and click reports name no CLI: they ask terminal-ui.js's gates.
|
||||
"terminal-tile.js::mode !== 'shell'": {
|
||||
count: 2,
|
||||
reason:
|
||||
'Ctrl+Z reaches the PTY only in a shell (job control), and the scroll-to-top history pull is shell-only, ' +
|
||||
'both as in the primary pane',
|
||||
},
|
||||
"terminal-tile.js::mode === 'shell'": {
|
||||
count: 1,
|
||||
reason: 'the load query: a shell loads the bounded tail= window instead of a full capture, as in the primary pane',
|
||||
},
|
||||
|
||||
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||
"mobile-overview.js::mode !== 'shell'": {
|
||||
@@ -188,7 +211,7 @@ function actualCounts(): Map<string, number> {
|
||||
}
|
||||
|
||||
describe('no NEW CLI-id branching in the scanned frontend files', () => {
|
||||
it('scans both files (sanity)', () => {
|
||||
it('scans every listed file (sanity)', () => {
|
||||
// If this drops to zero the scanner or the file list drifted and every
|
||||
// assertion below would pass vacuously.
|
||||
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
||||
|
||||
@@ -61,15 +61,45 @@ export class FakeFit {
|
||||
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
|
||||
export class FakeTerminal {
|
||||
static last: FakeTerminal | null = null;
|
||||
/**
|
||||
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
|
||||
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
|
||||
* beyond the screen, a clear leaves one line, and a resize recomputes it
|
||||
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
|
||||
* back). The viewport follows the bottom. Off, `baseY` stays where a test
|
||||
* puts it.
|
||||
*/
|
||||
static emulateScroll = false;
|
||||
options: Record<string, unknown>;
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
dataCb: ((data: string) => void) | null = null;
|
||||
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||
buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } };
|
||||
/** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */
|
||||
modes = { mouseTrackingMode: 'none' };
|
||||
/** Lines in the buffer while emulating (the cursor line counts). */
|
||||
lineCount = 1;
|
||||
emulate = FakeTerminal.emulateScroll;
|
||||
/** Called after an emulated resize, so a test can stand in for a reflow. */
|
||||
afterResize: ((cols: number, rows: number) => void) | null = null;
|
||||
/** Where the screen sits and how big a cell renders, for the click-to-cell math. */
|
||||
screenRect = { left: 10, top: 20 };
|
||||
element = {
|
||||
querySelector: (sel: string) =>
|
||||
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
|
||||
};
|
||||
_core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } };
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = { ...options };
|
||||
FakeTerminal.last = this;
|
||||
}
|
||||
/** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */
|
||||
settleRows() {
|
||||
const active = this.buffer.active;
|
||||
active.baseY = Math.max(0, this.lineCount - this.rows);
|
||||
active.viewportY = active.baseY;
|
||||
active.length = Math.max(this.lineCount, this.rows);
|
||||
}
|
||||
loadAddon(addon: FakeFit) {
|
||||
addon.term = this;
|
||||
}
|
||||
@@ -101,16 +131,28 @@ export class FakeTerminal {
|
||||
// An empty write puts nothing on screen; the replay queues one only to hear
|
||||
// (its callback) that everything before it has been parsed.
|
||||
if (data) this.writes.push(data);
|
||||
if (data && this.emulate) {
|
||||
this.lineCount += data.split('\n').length - 1;
|
||||
this.settleRows();
|
||||
}
|
||||
if (!this.holdParse) cb?.();
|
||||
}
|
||||
clear() {
|
||||
this.writes.push('<CLEAR>');
|
||||
if (this.emulate) {
|
||||
this.lineCount = 1;
|
||||
this.settleRows();
|
||||
}
|
||||
}
|
||||
resizes: Array<[number, number]> = [];
|
||||
resize(cols: number, rows: number) {
|
||||
this.resizes.push([cols, rows]);
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
if (this.emulate) {
|
||||
this.settleRows();
|
||||
this.afterResize?.(cols, rows);
|
||||
}
|
||||
}
|
||||
scrollToLine() {}
|
||||
scrollToTop() {}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview Real Chromium + real xterm coverage for a TerminalTile's
|
||||
* hollow-buffer paging (#555 parity), the parts a fake xterm cannot prove:
|
||||
*
|
||||
* - the capture-phase wheel listener on the tile's mount really keeps the
|
||||
* paged wheel away from xterm (its viewport does not move into the stale
|
||||
* rows above the screen), while a wheel the tile does not page still
|
||||
* reaches xterm and scrolls it, and
|
||||
* - real xterm puts a one-screen capture taken at a taller size above the
|
||||
* screen, and a row-shrinking fit pushes more rows up, which is what the
|
||||
* tile's overflow discount (`_localRows`) counts. Output that scrolls real
|
||||
* lines is history, and the tile stops paging.
|
||||
*
|
||||
* 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
|
||||
* still there is the tile's preventDefault (xterm 6's scrollable element skips
|
||||
* a wheel whose default was prevented); its stopPropagation, the primary
|
||||
* pane's other half, keeps xterm's own handlers from seeing the event at all.
|
||||
* Dropping both makes this test fail.
|
||||
*
|
||||
* The tile's load and socket are stubbed in the page (fetch answers its one
|
||||
* capture, WebSocket never opens), so nothing here needs a PTY; the page keys
|
||||
* are read from a spy on `app._sendInputEphemeral`. The gates themselves are
|
||||
* the real ones in terminal-ui.js, asked for the tile's own session.
|
||||
*
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PROBE_ID = 'tile-scroll-probe';
|
||||
|
||||
type Snap = { rows: number; baseY: number; viewportY: number; localRows: number; sent: Array<[string, string]> };
|
||||
|
||||
describe('TerminalTile wheel paging in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal && (window as any).TerminalTile, null, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/** The tile's state plus every page key sent so far. */
|
||||
const snap = () =>
|
||||
page.evaluate(() => {
|
||||
const probe = (window as any).__tileProbe;
|
||||
const term = probe.tile.terminal;
|
||||
return {
|
||||
rows: term.rows,
|
||||
baseY: term.buffer.active.baseY,
|
||||
viewportY: term.buffer.active.viewportY,
|
||||
localRows: probe.tile._localRows(),
|
||||
sent: probe.sent.slice(),
|
||||
} as Snap;
|
||||
});
|
||||
|
||||
/** A real wheel-up of a whole screen over the tile, then time for the 40 ms flush and a frame. */
|
||||
async function wheelUp(rows: number) {
|
||||
await page.mouse.move(200, 60);
|
||||
await page.mouse.wheel(0, -rows * 25);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
it('pages a hollow tile, keeps xterm still, survives a shrink, and stops once real lines scroll', 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) => `row ${i}`).join('\r\n');
|
||||
|
||||
// The tile's session, as the app knows it: opencode, which draws in place.
|
||||
app.sessions.set(id, { id, mode: 'opencode' });
|
||||
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() {}
|
||||
};
|
||||
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:640px;height:300px;z-index:99999;background:#000';
|
||||
document.body.appendChild(mount);
|
||||
const tile = new w.TerminalTile(id, mount, { mode: 'opencode' });
|
||||
try {
|
||||
await tile.connect();
|
||||
} finally {
|
||||
w.fetch = realFetch;
|
||||
w.WebSocket = RealWebSocket;
|
||||
}
|
||||
w.__tileProbe = { tile, mount, sent, realEphemeral };
|
||||
}, PROBE_ID);
|
||||
|
||||
try {
|
||||
// A 40-row capture in a shorter xterm: the extra rows sit above the
|
||||
// screen, and the tile counts every one of them as its own overflow.
|
||||
const afterLoad = await snap();
|
||||
expect(afterLoad.rows).toBeLessThan(40);
|
||||
expect(afterLoad.baseY).toBe(40 - afterLoad.rows);
|
||||
expect(afterLoad.viewportY).toBe(afterLoad.baseY);
|
||||
expect(afterLoad.localRows).toBe(0);
|
||||
|
||||
// The wheel is consumed and paged: xterm never saw it, so its viewport
|
||||
// stayed at the bottom instead of scrolling into the stale rows.
|
||||
await wheelUp(afterLoad.rows);
|
||||
const afterWheel = await snap();
|
||||
expect(afterWheel.viewportY).toBe(afterLoad.baseY);
|
||||
expect(afterWheel.sent.length).toBeGreaterThanOrEqual(1);
|
||||
expect(afterWheel.sent.every(([id]) => id === PROBE_ID)).toBe(true);
|
||||
expect(afterWheel.sent.map(([, data]) => data).join('')).toMatch(/^(?:\x1b\[5~)+$/);
|
||||
|
||||
// A row-shrinking fit pushes more rows up; still not history, still paged.
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as any).__tileProbe;
|
||||
probe.mount.style.height = '200px';
|
||||
probe.tile.localFit();
|
||||
});
|
||||
const afterShrink = await snap();
|
||||
expect(afterShrink.rows).toBeLessThan(afterLoad.rows);
|
||||
expect(afterShrink.baseY).toBeGreaterThan(afterLoad.baseY);
|
||||
expect(afterShrink.localRows).toBe(0);
|
||||
await wheelUp(afterShrink.rows);
|
||||
const afterSecondWheel = await snap();
|
||||
expect(afterSecondWheel.sent.length).toBeGreaterThan(afterWheel.sent.length);
|
||||
expect(afterSecondWheel.viewportY).toBe(afterShrink.baseY);
|
||||
|
||||
// Output that scrolled real lines is history: the wheel goes back to
|
||||
// xterm, which scrolls its own buffer, and no page key is sent.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
(window as any).__tileProbe.tile.terminal.write('real 1\r\nreal 2\r\nreal 3\r\n', resolve)
|
||||
)
|
||||
);
|
||||
const afterOutput = await snap();
|
||||
expect(afterOutput.localRows).toBe(3);
|
||||
await wheelUp(afterOutput.rows);
|
||||
const afterThirdWheel = await snap();
|
||||
expect(afterThirdWheel.sent.length).toBe(afterSecondWheel.sent.length);
|
||||
expect(afterThirdWheel.viewportY).toBeLessThan(afterOutput.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* @fileoverview A TerminalTile pages a hollow buffer's CLI transcript, and
|
||||
* reports a plain click to a CLI whose mouse DECSETs the server strips, the way
|
||||
* the primary pane does (#555), through the primary pane's own gates aimed at
|
||||
* the TILE: its terminal, its session, never the active one.
|
||||
*
|
||||
* opencode draws in place on the alternate screen, so tmux keeps no history
|
||||
* for it and the browser's buffer holds one screen. The primary pane turns the
|
||||
* wheel into PageUp/PageDown there (`_maybePageCliTranscript`, terminal-ui.js);
|
||||
* a tile left the wheel to xterm, which scrolled nothing. #555 also strips
|
||||
* opencode's mouse DECSETs on the server, which reaches tile sockets too: a
|
||||
* drag selects text again, but xterm's own encoder no longer reports a click,
|
||||
* so the tile now hand-encodes it like the primary pane.
|
||||
*
|
||||
* A tile's buffer is rarely empty above the screen even so: its first capture
|
||||
* is taken at the PTY's previous, taller size and written into a shorter
|
||||
* xterm, and its own row-shrinking fits push more rows up. Those rows are not
|
||||
* history, so the tile discounts them (`_localRows`); the overflow cases below
|
||||
* fail without that discount.
|
||||
*
|
||||
* 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),
|
||||
* with the fake xterm's row emulation switched on.
|
||||
*
|
||||
* Port: none (no server, no browser).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function loadContext() {
|
||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||
const windowStub: Record<string, unknown> = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
CodemanBase: { base: '' },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
|
||||
// running inside this context.
|
||||
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: FakeSocket,
|
||||
Terminal: FakeTerminal,
|
||||
FitAddon: { FitAddon: FakeFit },
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
location: { protocol: 'http:', host: 'codeman.test' },
|
||||
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: windowStub,
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => false,
|
||||
isHandheldDevice: () => false,
|
||||
getDeviceType: () => 'desktop',
|
||||
},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||
'globalThis.__CodemanApp = CodemanApp;',
|
||||
context
|
||||
);
|
||||
return {
|
||||
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
|
||||
windowStub,
|
||||
};
|
||||
}
|
||||
|
||||
const { CodemanApp, windowStub } = loadContext();
|
||||
|
||||
type Session = { mode: string; cliVersion?: string; cliMouseTracking?: boolean };
|
||||
type App = Record<string, unknown> & {
|
||||
sessions: Map<string, Session>;
|
||||
activeSessionId: string | null;
|
||||
_linkHovered?: boolean;
|
||||
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
function makeApp(sessions: Record<string, Session>, activeSessionId: string | null = 'other'): App {
|
||||
const app = Object.create(CodemanApp.prototype) as App;
|
||||
app._clientId = 'c-test';
|
||||
app._wsTabNonce = 'nonce-1';
|
||||
app._seqCounters = new Map();
|
||||
app._pendingDeliveries = new Map();
|
||||
app._postDraining = new Set();
|
||||
app._extraInputSockets = new Map();
|
||||
app._persistReliableState = vi.fn();
|
||||
app._persistReliableNow = vi.fn();
|
||||
app._updateConnectionIndicator = vi.fn();
|
||||
app.markIdleAlertSeen = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
||||
app.sessions = new Map(Object.entries(sessions));
|
||||
app.activeSessionId = activeSessionId;
|
||||
return app;
|
||||
}
|
||||
|
||||
/** The tile's mount element: records its listeners by type, with their options. */
|
||||
function makeMount() {
|
||||
const listeners: Record<string, Array<{ fn: (ev: unknown) => void; opts: unknown }>> = {};
|
||||
return {
|
||||
listeners,
|
||||
addEventListener: vi.fn((type: string, fn: (ev: unknown) => void, opts?: unknown) => {
|
||||
(listeners[type] ||= []).push({ fn, opts });
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
fire(type: string, ev: unknown) {
|
||||
for (const { fn } of listeners[type] || []) fn(ev);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Tile = {
|
||||
connect(): Promise<void>;
|
||||
destroy(): void;
|
||||
fit(opts?: { force?: boolean }): void;
|
||||
ws: FakeSocket | null;
|
||||
sessionId: string;
|
||||
_linkHovered: boolean;
|
||||
_scrollFlushTimer: unknown;
|
||||
_onPtyGeometryReport(cols: number, rows: number): void;
|
||||
};
|
||||
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||
|
||||
const liveTiles: Tile[] = [];
|
||||
|
||||
const lines = (n: number) => Array.from({ length: n }, (_, i) => `row ${i}`).join('\r\n');
|
||||
|
||||
/** The capture a tile's first load receives. `captureRows` absent = the server could not say. */
|
||||
function serveCapture(terminalBuffer: string, captureRows?: number) {
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { terminalBuffer, ...(captureRows === undefined ? {} : { captureRows }) } }),
|
||||
}));
|
||||
}
|
||||
|
||||
async function connectTile(app: App, opts: { sessionId?: string; mode?: string } = {}) {
|
||||
windowStub.app = app;
|
||||
const mount = makeMount();
|
||||
const tile = new TerminalTile(opts.sessionId ?? 's-tile', mount, { mode: opts.mode ?? 'opencode' });
|
||||
liveTiles.push(tile);
|
||||
await tile.connect();
|
||||
const ws = FakeSocket.instances.at(-1)!;
|
||||
ws.open();
|
||||
return { tile, ws, term: FakeTerminal.last!, mount };
|
||||
}
|
||||
|
||||
function wheel(deltaY: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
deltaY,
|
||||
deltaX: 0,
|
||||
deltaMode: 0,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** One wheel event worth `rowsOfTravel` lines (pixel mode: 25 px a line). */
|
||||
const wheelLines = (rowsOfTravel: number, extra: Record<string, unknown> = {}) => wheel(rowsOfTravel * 25, extra);
|
||||
|
||||
const PAGE_UP = '\x1b[5~';
|
||||
const PAGE_DOWN = '\x1b[6~';
|
||||
|
||||
/** Frames the tile's socket sent once the 40 ms coalescer has flushed. */
|
||||
function flushed(ws: FakeSocket) {
|
||||
vi.advanceTimersByTime(40);
|
||||
return ws.inputFrames();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||
FakeSocket.instances = [];
|
||||
FakeTerminal.emulateScroll = true;
|
||||
fetchMock.mockReset();
|
||||
serveCapture(lines(10), 24);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||
FakeTerminal.emulateScroll = false;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
it('turns half a screen of wheel-up into one ephemeral PageUp on its own socket, and down into PageDown', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
expect(term.buffer.active.baseY).toBe(0);
|
||||
|
||||
const up = wheelLines(-term.rows / 2);
|
||||
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: PAGE_UP }]); // no seq: never persisted
|
||||
|
||||
mount.fire('wheel', wheelLines(term.rows / 2));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
|
||||
});
|
||||
|
||||
it("reads the TILE's session, not the active one", async () => {
|
||||
// Active session is a shell; the tile shows opencode: the tile still pages.
|
||||
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
|
||||
const { ws, mount } = await connectTile(app);
|
||||
|
||||
mount.fire('wheel', wheelLines(-12));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it.each(['shell', 'codex', 'antigravity'])(
|
||||
'leaves a %s tile to xterm even while the active session is opencode',
|
||||
async (mode) => {
|
||||
const app = makeApp({ other: { mode: 'opencode' }, 's-tile': { mode } }, 'other');
|
||||
const { ws, mount } = await connectTile(app, { mode });
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('still pulls history on a 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));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
|
||||
});
|
||||
|
||||
it('never pages Shift, a tracking xterm, the alternate buffer, real history or a horizontal swipe', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-12));
|
||||
term.modes.mouseTrackingMode = 'none';
|
||||
|
||||
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
|
||||
fireAndCheck(wheelLines(-12));
|
||||
term.buffer.active.type = 'normal';
|
||||
|
||||
fireAndCheck(wheel(0, { deltaX: 120 })); // pure horizontal: nothing to page
|
||||
|
||||
term.write(lines(40)); // real output scrolled real lines above the screen
|
||||
expect(term.buffer.active.baseY).toBeGreaterThan(0);
|
||||
fireAndCheck(wheelLines(-12));
|
||||
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
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 });
|
||||
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-12));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('pages a Claude tile whose CLI version is unknown', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude', cliMouseTracking: true } }), {
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
mount.fire('wheel', wheelLines(-12));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('page-key travel, cap and coalescing', () => {
|
||||
beforeEach(() => {
|
||||
FakeFit.proposed = { cols: 80, rows: 36 };
|
||||
});
|
||||
|
||||
it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
mount.fire('wheel', wheelLines(-10));
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
mount.fire('wheel', wheelLines(-10));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('caps one wheel at three PageUps, in one frame', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
mount.fire('wheel', wheelLines(-1000));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(3) }]);
|
||||
});
|
||||
|
||||
it('sends two pages queued within 40 ms as one frame', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
mount.fire('wheel', wheelLines(-18));
|
||||
vi.advanceTimersByTime(20);
|
||||
mount.fire('wheel', wheelLines(-18));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP.repeat(2) }]);
|
||||
});
|
||||
|
||||
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
|
||||
const sequence = [-10, -10, -4, 30, -1000, 7, -18, 200].map((n) => wheelLines(n));
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
for (const ev of sequence) mount.fire('wheel', ev);
|
||||
const tileBytes = flushed(ws)
|
||||
.map((f) => f.d)
|
||||
.join('');
|
||||
|
||||
const primary = makeApp({ other: { mode: 'opencode' } }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
_maybePageCliTranscript(ev: unknown, lines: number): boolean;
|
||||
_wheelScrollLinesFloat(ev: unknown): number;
|
||||
_flushWheelSgrQueue(): void;
|
||||
};
|
||||
const sent: string[] = [];
|
||||
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
primary.terminal = {
|
||||
rows: 36,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { type: 'normal', baseY: 0, viewportY: 0 } },
|
||||
};
|
||||
for (const ev of sequence) primary._maybePageCliTranscript(ev, primary._wheelScrollLinesFloat(ev));
|
||||
primary._flushWheelSgrQueue();
|
||||
|
||||
expect(tileBytes).not.toBe('');
|
||||
expect(tileBytes).toBe(sent.join(''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rows the tile pushed above the screen itself are not history', () => {
|
||||
it('pages after a one-screen capture taken at a taller size (the first load)', async () => {
|
||||
serveCapture(lines(40), 40); // the PTY was 40 rows when it was captured
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
expect(term.buffer.active.baseY).toBe(16); // 40 lines into a 24-row xterm
|
||||
|
||||
mount.fire('wheel', wheelLines(-12));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('does not page a capture that carried real history', async () => {
|
||||
serveCapture(lines(60), 24);
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
expect(term.buffer.active.baseY).toBeGreaterThan(0);
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to the raw baseY when the server sent no captureRows', async () => {
|
||||
serveCapture(lines(40));
|
||||
const tall = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
expect(tall.term.buffer.active.baseY).toBe(16);
|
||||
tall.mount.fire('wheel', wheelLines(-12));
|
||||
expect(flushed(tall.ws)).toEqual([]);
|
||||
|
||||
serveCapture(lines(10));
|
||||
const short = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
expect(short.term.buffer.active.baseY).toBe(0);
|
||||
short.mount.fire('wheel', wheelLines(-12));
|
||||
expect(flushed(short.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('keeps paging after a row-shrinking fit pushes more rows up', async () => {
|
||||
serveCapture(lines(40), 40);
|
||||
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
FakeFit.proposed = { cols: 80, rows: 20 }; // a zoom-out or a divider drag
|
||||
tile.fit();
|
||||
expect(term.buffer.active.baseY).toBe(20);
|
||||
|
||||
mount.fire('wheel', wheelLines(-10));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('stops paging once output scrolls real lines above the screen', async () => {
|
||||
serveCapture(lines(40), 40);
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
ws.receive({ t: 'o', d: 'a\r\nb\r\nc\r\n' }); // three real lines scrolled off
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets the overflow on a server clear, so later real history counts', async () => {
|
||||
serveCapture(lines(40), 40);
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
ws.receive({ t: 'c' });
|
||||
expect(term.buffer.active.baseY).toBe(0);
|
||||
ws.receive({ t: 'o', d: '\r\n'.repeat(term.rows + 1) }); // two real lines above the screen
|
||||
expect(term.buffer.active.baseY).toBe(2);
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps paging when the PTY geometry report reflows the rows above the screen', async () => {
|
||||
serveCapture(lines(40), 40);
|
||||
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
// A narrower width wraps the overflow rows onto more rows, as xterm's reflow does.
|
||||
term.afterResize = () => {
|
||||
term.lineCount += 3;
|
||||
term.settleRows();
|
||||
};
|
||||
|
||||
tile._onPtyGeometryReport(60, 40);
|
||||
expect(term.cols).toBe(60);
|
||||
expect(term.buffer.active.baseY).toBe(19);
|
||||
|
||||
mount.fire('wheel', wheelLines(-12));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tile reports a plain click to a CLI with mouse tracking on', () => {
|
||||
const click = (overrides: Record<string, unknown> = {}) => ({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
|
||||
clientX: 171,
|
||||
clientY: 101,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
...overrides,
|
||||
});
|
||||
const TAP = '\x1b[<0;21;6M\x1b[<0;21;6m';
|
||||
|
||||
it("sends one seq-tagged SGR press+release on the tile's socket, from the tile's own geometry", async () => {
|
||||
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
||||
const { ws, mount } = await connectTile(app);
|
||||
|
||||
mount.fire('click', click());
|
||||
|
||||
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'number']]);
|
||||
});
|
||||
|
||||
it('sends nothing without the flag, over a selection, over its own hovered link, or scrolled up', async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'opencode' } });
|
||||
const { tile, ws, term, mount } = await connectTile(app);
|
||||
|
||||
mount.fire('click', click()); // the CLI has no tracking mode on
|
||||
app.sessions.set('s-tile', { mode: 'opencode', cliMouseTracking: true });
|
||||
|
||||
term.selection = 'picked';
|
||||
mount.fire('click', click()); // a drag-selection just ended
|
||||
term.selection = '';
|
||||
|
||||
tile._linkHovered = true;
|
||||
mount.fire('click', click()); // the link provider opens this one
|
||||
tile._linkHovered = false;
|
||||
|
||||
term.buffer.active.baseY = 10;
|
||||
term.buffer.active.viewportY = 0;
|
||||
mount.fire('click', click()); // would hit-test a different row
|
||||
term.buffer.active.viewportY = 10;
|
||||
|
||||
expect(ws.inputFrames()).toEqual([]);
|
||||
|
||||
mount.fire('click', click()); // nothing in the way any more
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
||||
});
|
||||
|
||||
it("is not blocked by the primary pane's own link hover", async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'opencode', cliMouseTracking: true } });
|
||||
app._linkHovered = true;
|
||||
const { ws, mount } = await connectTile(app);
|
||||
|
||||
mount.fire('click', click());
|
||||
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
||||
});
|
||||
|
||||
it("follows the tile session's flag, never the active session's", async () => {
|
||||
const reported = await connectTile(
|
||||
makeApp({
|
||||
other: { mode: 'claude', cliMouseTracking: false },
|
||||
's-tile': { mode: 'opencode', cliMouseTracking: true },
|
||||
})
|
||||
);
|
||||
reported.mount.fire('click', click());
|
||||
expect(reported.ws.inputFrames().map((f) => f.d)).toEqual([TAP]);
|
||||
|
||||
const silent = await connectTile(
|
||||
makeApp({
|
||||
other: { mode: 'claude', cliMouseTracking: true },
|
||||
's-tile': { mode: 'opencode', cliMouseTracking: false },
|
||||
})
|
||||
);
|
||||
silent.mount.fire('click', click());
|
||||
expect(silent.ws.inputFrames()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('drops queued page keys and detaches both listeners it registered', async () => {
|
||||
const { tile, ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
mount.fire('wheel', wheelLines(-12)); // queued, not yet flushed
|
||||
const wheelFn = mount.listeners.wheel[0].fn;
|
||||
const clickFn = mount.listeners.click[0].fn;
|
||||
|
||||
tile.destroy();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(ws.inputFrames()).toEqual([]);
|
||||
expect(tile._scrollFlushTimer).toBeNull();
|
||||
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', wheelFn, { capture: true });
|
||||
expect(mount.removeEventListener).toHaveBeenCalledWith('click', clickFn);
|
||||
});
|
||||
|
||||
it('registers the wheel listener non-passive in the capture phase and the click one in the bubble phase', async () => {
|
||||
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
|
||||
expect(mount.listeners.wheel.map((l) => l.opts)).toEqual([{ capture: true, passive: false }]);
|
||||
expect(mount.listeners.click.map((l) => l.opts)).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ type PaneUnderTest = {
|
||||
_onLiveOutput(data: string): void;
|
||||
_onLiveClear(): void;
|
||||
_installWheelListener(): void;
|
||||
_installClickListener(): void;
|
||||
_onClick: unknown;
|
||||
_writeDisconnectedMarker(): void;
|
||||
_onSocketClosed(): void;
|
||||
};
|
||||
@@ -763,9 +765,13 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
// Capture phase: xterm's own wheel handler stopPropagation()s the events it
|
||||
// consumes, so a bubbling listener would never fire while the pane still has
|
||||
// scrollback to scroll, and the pull would work only from the exact top row.
|
||||
// Not passive: the hollow-buffer paging route consumes its wheel right here
|
||||
// (test/terminal-tile-scroll.test.ts). This stub window has neither the
|
||||
// shared paging helpers nor the app's gates, so that route stays inert and
|
||||
// every wheel below falls through to the pull, as before.
|
||||
const [type, listener, options] = mount.addEventListener.mock.calls[0];
|
||||
expect(type).toBe('wheel');
|
||||
expect(options).toEqual({ capture: true, passive: true });
|
||||
expect(options).toEqual({ capture: true, passive: false });
|
||||
|
||||
listener({ deltaY: 120 }); // wheel down
|
||||
listener({ deltaY: 0 });
|
||||
@@ -789,6 +795,22 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
expect(pane._onWheel).toBeNull();
|
||||
});
|
||||
|
||||
it('destroy() detaches exactly the click listener it registered', () => {
|
||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const pane = makePane('opencode', mount);
|
||||
pane._installWheelListener();
|
||||
pane._installClickListener();
|
||||
const [type, registered, options] = mount.addEventListener.mock.calls[1];
|
||||
// Bubble phase, like the primary pane's click reporter (terminal-ui.js).
|
||||
expect(type).toBe('click');
|
||||
expect(options).toBeUndefined();
|
||||
|
||||
pane.destroy();
|
||||
|
||||
expect(mount.removeEventListener).toHaveBeenCalledWith('click', registered);
|
||||
expect(pane._onClick).toBeNull();
|
||||
});
|
||||
|
||||
it('connect() installs the wheel listener (static guard)', () => {
|
||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
||||
// rather than executed; the listener's behaviour is exercised above.
|
||||
@@ -800,6 +822,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
|
||||
expect(end).toBeGreaterThan(start);
|
||||
const connect = SOURCE.slice(start, end);
|
||||
expect(connect).toContain('this._installWheelListener();');
|
||||
expect(connect).toContain('this._installClickListener();');
|
||||
expect(connect).toContain('this._onLiveClear();');
|
||||
expect(connect).not.toContain('this.terminal.clear();');
|
||||
// The tests below drive the close through _onSocketClosed() directly; the
|
||||
|
||||
Reference in New Issue
Block a user