mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
Grid tiles and the split view's Pane B now get two fixes the primary pane already had in this release:
- opencode's hollow-buffer wheel paging and its click reports (#555)
- the Android soft-keyboard controller, so autocorrect no longer duplicates a line in a tile (#541, with the #441 drain)
Gate green on the branch tip 7409ad26: 525 files and 10243 tests, plus the touched and adjacent browser files.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,15 @@ 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 three pieces of the primary pane: hollow-buffer wheel
|
||||
paging (#555) and the desktop click report for a CLI with
|
||||
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||
tile, and its own keyCode-229 soft-keyboard controller
|
||||
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
|
||||
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
|
||||
width gate is all that keeps a phone out, and a wide Android tablet clears
|
||||
it. 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
|
||||
|
||||
+15
-4
@@ -215,8 +215,10 @@ no `+`, owner decision 9).
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
|
||||
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
|
||||
- Phones. The grid is gated on width alone at 1180 px like the split
|
||||
(`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`); a
|
||||
wide tablet, or a large foldable unfolded in landscape, can reach it (see the
|
||||
keyboard exception below).
|
||||
- More than 9 tiles.
|
||||
- WebGL rendering inside tiles (see "Rendering" below).
|
||||
- Full parity with the main terminal's touch and IME features: local-echo
|
||||
@@ -224,7 +226,11 @@ no `+`, owner decision 9).
|
||||
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
|
||||
history" banner. These exist for touch devices or rare cases; a desktop
|
||||
keyboard user types straight into xterm, which is how Codeman behaved before
|
||||
those features existed.
|
||||
those features existed. (One exception, since the 1180 px gate is width
|
||||
only and a wide Android tablet clears it: every tile wires the main
|
||||
terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js,
|
||||
so an Android autocorrect is sent as an edit rather than a duplicated line,
|
||||
#541, and a character committed with Enter is not lost, #441.)
|
||||
- Server-side persistence of grids (named presets per owner).
|
||||
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
|
||||
|
||||
@@ -998,7 +1004,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
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
* also be a CAPTURE listener; see the measured table at the addEventListener
|
||||
* call below.
|
||||
*
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
|
||||
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
|
||||
* @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by
|
||||
* terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm)
|
||||
* @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
+281
-10
@@ -10,13 +10,41 @@
|
||||
* synchronous tmux call that blocks the server's event loop.
|
||||
*
|
||||
* 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.
|
||||
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
|
||||
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
|
||||
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
|
||||
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
|
||||
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
|
||||
*
|
||||
* 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), and only while the viewport is on the live screen (a
|
||||
* wheel-down from those overflow rows is xterm's, and brings it home).
|
||||
* - 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), sent
|
||||
* ephemeral, like every mouse report from this pane (_onTerminalData).
|
||||
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
|
||||
* pane, on this pane's own textarea and composition helper and sending to
|
||||
* this pane's session (_createKeyCode229Recovery): it forwards an
|
||||
* `insertText` xterm refused, settles a pending textarea edit at the next
|
||||
* keydown ahead of that key (#441: the last character an Android keyboard
|
||||
* commits in the same task as Enter), and replaces xterm's append-only
|
||||
* keyCode-229 diff with an edit-based one (#541: autocorrect on space
|
||||
* duplicated the line). Not a desktop-only concern: the grid and the split
|
||||
* are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide
|
||||
* Android tablet, or a large foldable unfolded in landscape, reaches.
|
||||
*
|
||||
* @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.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
|
||||
*/
|
||||
|
||||
@@ -157,6 +185,22 @@
|
||||
// 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;
|
||||
// The soft-keyboard controller (terminal-keycode229-recovery.js): created
|
||||
// in connect() once the xterm is open, torn down in destroy().
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -192,13 +236,33 @@
|
||||
});
|
||||
|
||||
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).
|
||||
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
|
||||
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
|
||||
|
||||
this.terminal.onData((data) => this._onTerminalData(data));
|
||||
this._createKeyCode229Recovery();
|
||||
// The twin of terminal-ui.js's onData gate (initTerminal; keep the two in
|
||||
// step). Canonical xterm data tells the controller this keystroke was
|
||||
// delivered, but not a query reply or a focus/mouse report, which xterm
|
||||
// emits on its own and which would otherwise stand a pending recovery
|
||||
// down. The notify lives HERE and not in _onTerminalData(): the
|
||||
// controller's own recovered bytes go through _onTerminalData() too, and
|
||||
// must never count as xterm's, or a second pending character from the
|
||||
// same keystroke window would stand down and be lost.
|
||||
this.terminal.onData((data) => {
|
||||
try {
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
}
|
||||
} catch {
|
||||
/* Bookkeeping must never block real input. */
|
||||
}
|
||||
this._onTerminalData(data);
|
||||
});
|
||||
|
||||
// xterm has no gates of its own, so every app-level chord that the
|
||||
// document capture-phase handler (app.js) only preventDefault()s (never
|
||||
@@ -213,6 +277,19 @@
|
||||
// here too. Ctrl+V goes through the primary pane's paste trap
|
||||
// (image-input.js), aimed at this pane (below).
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
// FIRST, above the IME early return below, as in terminal-ui.js: every
|
||||
// keydown settles this pane's pending textarea edit and drains a
|
||||
// pending recovery BEFORE xterm handles the key, so a character an
|
||||
// Android keyboard committed in the same task as Enter is sent ahead
|
||||
// of the \r. Below that return a keyCode-229 keydown would skip the
|
||||
// settle, the drain and the snapshot, and the panes would differ.
|
||||
// Read at call time, never captured, so the controller can be swapped
|
||||
// (the tests count xterm's emissions through it).
|
||||
try {
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
/* The controller must never interfere with xterm's own handling. */
|
||||
}
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
@@ -523,6 +600,44 @@
|
||||
app?._sendInputAsync?.(this.sessionId, data);
|
||||
}
|
||||
|
||||
// The soft-keyboard controller, the twin of the primary pane's wiring in
|
||||
// terminal-ui.js initTerminal() (keep the two in step); the behaviour lives
|
||||
// once, in terminal-keycode229-recovery.js. Everything it is handed is THIS
|
||||
// pane's: its textarea, its xterm's CompositionHelper (whose
|
||||
// `_handleAnyTextareaChanges` it patches, per instance) and its send path.
|
||||
// Recovered text goes straight to _onTerminalData(), never through xterm's
|
||||
// onData, so it is not counted as xterm's own (see connect()'s onData).
|
||||
// Created after terminal.open(): xterm's capture `input` listener on the
|
||||
// textarea is registered there, and must run before the controller's. No
|
||||
// device or mode gate, as in the primary pane: with a hardware keyboard it
|
||||
// costs one assignment per keydown. A failure leaves xterm's own handling.
|
||||
_createKeyCode229Recovery() {
|
||||
this._destroyKeyCode229Recovery();
|
||||
if (!this.terminal) return;
|
||||
try {
|
||||
this._keyCode229Recovery =
|
||||
global.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => this._onTerminalData(data),
|
||||
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
|
||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||
}) ?? null;
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Restores xterm's own textarea diff and removes the controller's capture
|
||||
// listeners from the live textarea, so it runs before terminal.dispose().
|
||||
_destroyKeyCode229Recovery() {
|
||||
try {
|
||||
this._keyCode229Recovery?.destroy?.();
|
||||
} catch {
|
||||
/* Optional; teardown must continue. */
|
||||
}
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
|
||||
// Joins the app's input-socket map for this session and flushes anything
|
||||
// already queued for it (typed while the socket was down, or left over from
|
||||
// a reload) over the fresh socket. Called from onopen.
|
||||
@@ -600,6 +715,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 +751,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 +816,154 @@
|
||||
// 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,
|
||||
// Like every mouse report from this pane (_onTerminalData): once,
|
||||
// never persisted, so a reload cannot replay it onto a later screen.
|
||||
ephemeral: true,
|
||||
});
|
||||
};
|
||||
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;
|
||||
// Only from the live screen. The one gate the primary pane never needs: a
|
||||
// primary hollow buffer has baseY 0, so its viewport is always at the
|
||||
// bottom, while a tile's is hollow with its own overflow rows still above
|
||||
// the screen, and Shift+PageUp, a scrollbar drag or a wheel during the
|
||||
// first replay can leave the viewport up there. Paging from there would
|
||||
// swallow every wheel (wheel-down included) and keep the stale rows on
|
||||
// screen while the CLI pages out of view; left to xterm, a wheel-down
|
||||
// brings the viewport home and paging resumes from there. The click
|
||||
// report refuses an off-bottom viewport for the same reason
|
||||
// (_terminalViewportAtBottom).
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) 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 +1071,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 +1103,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 +1148,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 +1211,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,11 +1240,24 @@
|
||||
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);
|
||||
this._onFocusIn = null;
|
||||
}
|
||||
// Before dispose(): puts xterm's own textarea diff back and takes the
|
||||
// controller's listeners off the textarea; its pending timers are inert
|
||||
// once it is destroyed.
|
||||
this._destroyKeyCode229Recovery();
|
||||
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
|
||||
// primary terminal (_focusedPane also skips a destroyed tile on its own).
|
||||
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
|
||||
|
||||
+116
-47
@@ -92,6 +92,36 @@
|
||||
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
|
||||
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
||||
|
||||
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
|
||||
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
|
||||
// Shift-axis trap and the deltaMode units), pure so a TerminalTile pages with
|
||||
// the same math against its own row count.
|
||||
function wheelDeltaLines(ev, rows) {
|
||||
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
|
||||
if (!delta) return 0;
|
||||
return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel)
|
||||
? delta
|
||||
: ev.deltaMode === 2 // DOM_DELTA_PAGE
|
||||
? delta * (rows || 24)
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
}
|
||||
|
||||
// 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
|
||||
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
|
||||
// its own pending travel) pages identically.
|
||||
function pageKeysForTravel(pending, lines, rows) {
|
||||
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
|
||||
const total = (pending || 0) + lines;
|
||||
const pages = Math.trunc(total / perPage);
|
||||
const keys = pages
|
||||
? (pages < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN).repeat(Math.min(Math.abs(pages), PAGE_KEY_MAX_PER_BATCH))
|
||||
: '';
|
||||
return { pending: total - pages * perPage, keys };
|
||||
}
|
||||
|
||||
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
|
||||
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
|
||||
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
|
||||
@@ -229,6 +259,8 @@
|
||||
KEY_PAGE_DOWN,
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
wheelDeltaLines,
|
||||
pageKeysForTravel,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
||||
@@ -1806,6 +1838,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// registers its own listener with `capture: true`; on bubble xterm's
|
||||
// `cancel()` (stopPropagation) would swallow exactly the handled events —
|
||||
// see the measured table in terminal-keycode229-recovery.js.
|
||||
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
|
||||
// connect() key handler and onData) wires its own controller the same way
|
||||
// for every grid tile and the split's Pane B; keep the two in step.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
@@ -5395,42 +5430,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
||||
* injecting bytes. After a server restart the flag is false until the CLI
|
||||
* re-emits its DECSET, which closing and reopening a dialog does.
|
||||
*
|
||||
* `sessionId` defaults to the primary pane's session; a TerminalTile passes
|
||||
* its own (through _handleDesktopTerminalClick's target), never the active one.
|
||||
*/
|
||||
_shouldReportMouseToCli() {
|
||||
return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true;
|
||||
_shouldReportMouseToCli(sessionId = this.activeSessionId) {
|
||||
return this.sessions?.get(sessionId)?.cliMouseTracking === true;
|
||||
},
|
||||
|
||||
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
||||
// local scrollback). SGR coordinates are only meaningful then: the TUI's
|
||||
// screen is the bottom `rows` of the buffer, so a report computed from a
|
||||
// scrolled-up viewport would hit-test a completely different row.
|
||||
_terminalViewportAtBottom() {
|
||||
const buf = this.terminal?.buffer?.active;
|
||||
// `terminal` defaults to the primary pane's (a TerminalTile passes its own).
|
||||
_terminalViewportAtBottom(terminal = this.terminal) {
|
||||
const buf = terminal?.buffer?.active;
|
||||
return !buf || buf.viewportY >= buf.baseY;
|
||||
},
|
||||
|
||||
// Map a viewport point to a 1-based terminal cell the same way xterm maps a
|
||||
// click: offset inside .xterm-screen divided by the rendered cell size,
|
||||
// clamped to the grid. Returns null when the terminal isn't measurable yet.
|
||||
_clientPointToCell(clientX, clientY) {
|
||||
if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
|
||||
const screen = this.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = this.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
// `terminal` defaults to the primary pane's (a TerminalTile passes its own).
|
||||
_clientPointToCell(clientX, clientY, terminal = this.terminal) {
|
||||
if (!terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
|
||||
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
if (!screen || !cell?.width || !cell?.height) return null;
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1));
|
||||
const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1));
|
||||
const col = Math.max(1, Math.min(terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1));
|
||||
const row = Math.max(1, Math.min(terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1));
|
||||
return { col, row };
|
||||
},
|
||||
|
||||
// Encode a tap as an SGR mouse report (press + release at button 0) and send it
|
||||
// to the PTY directly, bypassing xterm's mouse encoder.
|
||||
_sendSyntheticSgrTap(clientX, clientY) {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
// to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal,
|
||||
// sessionId, ephemeral }) aims it at a TerminalTile instead of the primary
|
||||
// pane; either of the first two left out means the primary pane's.
|
||||
// `ephemeral: true` sends it through _sendInputEphemeral instead of the
|
||||
// persisted exactly-once queue: a TerminalTile's mouse reports never enter
|
||||
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one
|
||||
// onto a later screen. Left out, the report stays on _sendInputAsync, as the
|
||||
// primary pane always sent it.
|
||||
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
|
||||
const sessionId = target.sessionId || this.activeSessionId;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
if (!sessionId) return;
|
||||
if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing
|
||||
const pos = this._clientPointToCell(clientX, clientY, terminal);
|
||||
if (!pos) return;
|
||||
this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
|
||||
const report = `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`;
|
||||
if (target.ephemeral) this._sendInputEphemeral(sessionId, report);
|
||||
else this._sendInputAsync(sessionId, report);
|
||||
},
|
||||
|
||||
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
|
||||
@@ -5486,18 +5537,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
* sub-line fractions across events instead of forcing every tiny trackpad
|
||||
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. */
|
||||
* delta to a whole ±1 line. Same unit handling and Shift-axis trap. The
|
||||
* math is the pure CodemanTerminalInput.wheelDeltaLines (top of this file),
|
||||
* which a TerminalTile calls with its own row count. */
|
||||
_wheelScrollLinesFloat(ev) {
|
||||
const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
|
||||
if (!delta) return 0;
|
||||
return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel)
|
||||
? delta
|
||||
: ev.deltaMode === 2 // DOM_DELTA_PAGE
|
||||
? delta * (this.terminal?.rows || 24)
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
_shouldForwardWheelToApp(ev) {
|
||||
// `target` ({ terminal, sessionId }) asks the question for a TerminalTile:
|
||||
// its own terminal's tracking mode and its own session, never the active one.
|
||||
// Either field left out means the primary pane's.
|
||||
_shouldForwardWheelToApp(ev, target = {}) {
|
||||
if (ev.shiftKey) return false;
|
||||
// Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the
|
||||
// plain wheel to xterm's own scrollback like pre-#144, for users who prefer
|
||||
@@ -5514,9 +5564,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// falls through to _maybePageCliTranscript, so the gesture still pages the
|
||||
// CLI's transcript and the setting keeps meaning exactly what it says.
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
@@ -5571,6 +5621,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* tmux send-keys server-side, so per-event writes would spawn a process storm
|
||||
* on a single flick; the queue is bounded so a wild scroll can't build a
|
||||
* backlog that keeps scrolling after the finger stops.
|
||||
*
|
||||
* A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes,
|
||||
* terminal-tile.js: same 40ms window, same 512-byte bound) because this queue
|
||||
* flushes to the active session only; keep the two in step.
|
||||
*/
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || !this.activeSessionId) return;
|
||||
@@ -5601,13 +5655,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Every other mode is deliberately absent: shell/pi own real terminal
|
||||
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
|
||||
* is unverified (docs/scrollback-fix-plan.md).
|
||||
*
|
||||
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
|
||||
* calls this with its own session and terminal, so the mode list above stays
|
||||
* here alone. `localRows` replaces `baseY` as the history row count: a tile
|
||||
* discounts the stale rows its own load order leaves above the screen
|
||||
* (TerminalTile._localRows). Every field left out means the primary pane's.
|
||||
*/
|
||||
_localScrollbackIsHollow() {
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
_localScrollbackIsHollow(target = {}) {
|
||||
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
|
||||
if (mode !== 'claude' && mode !== 'opencode') return false;
|
||||
const buf = this.terminal?.buffer?.active;
|
||||
const buf = (target.terminal || this.terminal)?.buffer?.active;
|
||||
if (!buf || buf.type === 'alternate') return false;
|
||||
return (buf.baseY || 0) === 0;
|
||||
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
|
||||
return (rows || 0) === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5631,6 +5692,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* @returns true when the gesture was consumed here (the caller must not also
|
||||
* scroll locally).
|
||||
*
|
||||
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
|
||||
* through the same gates and the same pageKeysForTravel arithmetic; keep the
|
||||
* two in step. The tile adds one gate this pane cannot need, viewport at the
|
||||
* bottom: hollow here means baseY 0, so this viewport is always there, while a
|
||||
* tile is hollow with its own discounted rows still above the screen.
|
||||
*/
|
||||
_maybePageCliTranscript(ev, lines) {
|
||||
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
|
||||
@@ -5640,15 +5707,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
this._pageKeyPending = 0;
|
||||
}
|
||||
const tuning = window.CodemanTerminalInput;
|
||||
const perPage = Math.max(2, Math.round((this.terminal?.rows || 24) * tuning.PAGE_KEY_SCREEN_FRACTION));
|
||||
const pending = (this._pageKeyPending || 0) + lines;
|
||||
const pages = Math.trunc(pending / perPage);
|
||||
this._pageKeyPending = pending - pages * perPage;
|
||||
if (pages) {
|
||||
const key = pages < 0 ? tuning.KEY_PAGE_UP : tuning.KEY_PAGE_DOWN;
|
||||
this._queueScrollBytes(key.repeat(Math.min(Math.abs(pages), tuning.PAGE_KEY_MAX_PER_BATCH)));
|
||||
}
|
||||
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
|
||||
this._pageKeyPending = step.pending;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
this._logScrollRouting('page-keys');
|
||||
return true;
|
||||
},
|
||||
@@ -5703,18 +5764,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// synthetic SGR press could e.g. dismiss a claude permission dialog),
|
||||
// clicks outside the cell grid, and sessions where xterm's own encoder is
|
||||
// live (it reported the click itself — a second report would double-move).
|
||||
_handleDesktopTerminalClick(ev) {
|
||||
if (!this.terminal || !ev?.isTrusted) return;
|
||||
//
|
||||
// `target` ({ terminal, sessionId, linkHovered, ephemeral }) runs the same
|
||||
// skips for a TerminalTile's click: its own terminal, its own session's
|
||||
// tracking flag and its own link hover (the primary pane's _linkHovered
|
||||
// belongs to its terminal alone). `ephemeral` reaches _sendSyntheticSgrTap,
|
||||
// so a TerminalTile's mouse report never enters the persisted input queue.
|
||||
// Every field left out means the primary pane's.
|
||||
_handleDesktopTerminalClick(ev, target = {}) {
|
||||
const terminal = target.terminal || this.terminal;
|
||||
if (!terminal || !ev?.isTrusted) return;
|
||||
if (ev.button !== 0 || ev.detail !== 1) return;
|
||||
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const mode = this.terminal.modes?.mouseTrackingMode;
|
||||
const mode = terminal.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return;
|
||||
if (!this._shouldReportMouseToCli()) return;
|
||||
if (this.terminal.hasSelection?.()) return;
|
||||
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
|
||||
if (!this._shouldReportMouseToCli(target.sessionId)) return;
|
||||
if (terminal.hasSelection?.()) return;
|
||||
if (target.linkHovered ?? this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
|
||||
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
|
||||
if (!ev.target?.closest?.('.xterm-screen')) return;
|
||||
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
|
||||
this._sendSyntheticSgrTap(ev.clientX, ev.clientY, target);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,54 @@ 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;
|
||||
/**
|
||||
* Opt-in, set by a test BEFORE the tile connects (and reset after): extra
|
||||
* fields merged into `_core`, e.g. xterm's `_compositionHelper` for the
|
||||
* keyCode-229 controller, which reads it once when the tile creates it.
|
||||
*/
|
||||
static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null;
|
||||
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: Record<string, unknown> = {
|
||||
_renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } },
|
||||
...(FakeTerminal.coreFactory?.(this) ?? {}),
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -79,12 +118,26 @@ export class FakeTerminal {
|
||||
}
|
||||
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||
focusListeners: Array<() => void> = [];
|
||||
/** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */
|
||||
textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = [];
|
||||
textarea = {
|
||||
addEventListener: (type: string, fn: () => void) => {
|
||||
if (type === 'focus') this.focusListeners.push(fn);
|
||||
/** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */
|
||||
value: '',
|
||||
addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
|
||||
if (type === 'focus') this.focusListeners.push(fn as () => void);
|
||||
else this.textareaListeners.push({ type, fn, capture });
|
||||
},
|
||||
removeEventListener: (type: string, fn: () => void) => {
|
||||
removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
|
||||
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
||||
else {
|
||||
this.textareaListeners = this.textareaListeners.filter(
|
||||
(l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture))
|
||||
);
|
||||
}
|
||||
},
|
||||
/** Delivers `ev` to the textarea's listeners of `type`, in registration order. */
|
||||
fire: (type: string, ev: Record<string, unknown> = {}) => {
|
||||
for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev });
|
||||
},
|
||||
};
|
||||
focusTextarea() {
|
||||
@@ -101,16 +154,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() {}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* - a keystroke xterm DOES handle is delivered exactly once, not twice;
|
||||
* - a character committed in the SAME page task as Enter reaches the send
|
||||
* path ahead of the `\r`, which is the ordering the zero-delay timer
|
||||
* alone cannot produce.
|
||||
* alone cannot produce;
|
||||
* - a TerminalTile (grid tile, split Pane B) wires its own controller, so
|
||||
* the same shapes come out right there too, addressed to the tile's session.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
@@ -393,4 +395,192 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
// Byte for byte what the phone sent in the device log.
|
||||
expect(line).toBe('testing the peompttesting the prompt rompt ');
|
||||
});
|
||||
|
||||
/**
|
||||
* The same keyboard shapes through a TerminalTile, the pane a grid tile and the split view's
|
||||
* Pane B are made of. It wires its OWN controller (terminal-tile.js _createKeyCode229Recovery):
|
||||
* its own xterm, helper textarea and composition helper, sending to its own session through
|
||||
* `app._sendInputAsync(tileSessionId, …)`, never the active one. Declared after the primary
|
||||
* pane's control above, which only switched the PRIMARY controller off.
|
||||
*/
|
||||
describe("in a TerminalTile (a grid tile, the split view's Pane B)", () => {
|
||||
const TILE_ID = 'cod541-tile-browser';
|
||||
|
||||
beforeAll(async () => {
|
||||
await page.evaluate(async (id) => {
|
||||
const w = window as any;
|
||||
const mount = document.createElement('div');
|
||||
mount.id = 'tile229Mount';
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:480px;height:320px;z-index:9999;';
|
||||
document.body.appendChild(mount);
|
||||
// A session that does not exist: its socket is refused (4004) and the load finds
|
||||
// nothing, neither of which the controller needs. Input is stubbed per test below.
|
||||
const tile = new w.TerminalTile(id, mount, { mode: 'shell' });
|
||||
w.__tile229 = tile;
|
||||
await tile.connect();
|
||||
}, TILE_ID);
|
||||
await page.waitForFunction(() => (window as any).__tile229?._keyCode229Recovery, null, { timeout: 30000 });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
w.__tile229?.destroy();
|
||||
delete w.__tile229;
|
||||
document.getElementById('tile229Mount')?.remove();
|
||||
});
|
||||
});
|
||||
|
||||
type Scenario = 'autocorrect' | 'lastCharThenEnter' | 'autocorrectThenEnter' | 'orphanThenEnter' | 'selfRescued';
|
||||
|
||||
/**
|
||||
* Runs one keyboard shape against the tile's own textarea (scoped to its mount:
|
||||
* `document.querySelector` would find the PRIMARY pane's) and reports what reached the send
|
||||
* path, which session each chunk was addressed to, and how often xterm itself spoke.
|
||||
*/
|
||||
async function inTile(scenario: Scenario) {
|
||||
return page.evaluate(
|
||||
async ({ scenario, tileId }) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const tile = w.__tile229;
|
||||
const textarea = document.querySelector('#tile229Mount .xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const originalSendInput = app._sendInputAsync;
|
||||
const originalSessionId = app.activeSessionId;
|
||||
const rec = tile._keyCode229Recovery;
|
||||
const sent: Array<[string, string]> = [];
|
||||
let xtermEmitted = 0;
|
||||
const keydown = (init: KeyboardEventInit, keyCode: number) => {
|
||||
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, composed: true, ...init });
|
||||
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
|
||||
textarea.dispatchEvent(down);
|
||||
};
|
||||
const key229 = () => keydown({ key: 'Unidentified' }, 229);
|
||||
const enter = () => keydown({ key: 'Enter', code: 'Enter' }, 13);
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const typeKeys = async (text: string) => {
|
||||
for (const ch of text) {
|
||||
key229();
|
||||
document.execCommand('insertText', false, ch);
|
||||
await tick();
|
||||
}
|
||||
};
|
||||
const autocorrectEdit = () => {
|
||||
key229();
|
||||
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
|
||||
document.execCommand('delete');
|
||||
key229();
|
||||
document.execCommand('insertText', false, 'rompt ');
|
||||
};
|
||||
try {
|
||||
app.activeSessionId = 'cod541-not-the-tile';
|
||||
app._sendInputAsync = (sessionId: string, chunk: string) => sent.push([sessionId, chunk]);
|
||||
if (scenario === 'selfRescued') {
|
||||
// Counts xterm's own canonical emissions: the tile's onData reads the property at
|
||||
// call time, and the controller object itself is frozen.
|
||||
tile._keyCode229Recovery = {
|
||||
handleKeyEvent: (e: any) => rec.handleKeyEvent(e),
|
||||
notifyCanonicalData: () => {
|
||||
xtermEmitted += 1;
|
||||
return rec.notifyCanonicalData();
|
||||
},
|
||||
destroy: () => rec.destroy(),
|
||||
};
|
||||
}
|
||||
textarea.value = '';
|
||||
textarea.focus();
|
||||
|
||||
if (scenario === 'autocorrect') {
|
||||
await typeKeys('testing the peompt');
|
||||
autocorrectEdit(); // both edits in one task, before any timer runs
|
||||
} else if (scenario === 'lastCharThenEnter') {
|
||||
await typeKeys('hell');
|
||||
key229();
|
||||
document.execCommand('insertText', false, 'o');
|
||||
enter(); // same task
|
||||
} else if (scenario === 'autocorrectThenEnter') {
|
||||
await typeKeys('testing the peompt');
|
||||
autocorrectEdit();
|
||||
enter(); // same task
|
||||
} else if (scenario === 'orphanThenEnter') {
|
||||
// #441's batched shape: a keydown, the composed insertText xterm refuses, Enter.
|
||||
keydown({ key: 'Unidentified' }, 65);
|
||||
textarea.value = 'o';
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'o', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
enter();
|
||||
} else {
|
||||
key229();
|
||||
textarea.value = 'y';
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'y', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
|
||||
const raw = sent.map(([, chunk]) => chunk).join('');
|
||||
const line: string[] = [];
|
||||
for (const ch of raw) {
|
||||
if (ch === '\x7f') line.pop();
|
||||
else line.push(ch);
|
||||
}
|
||||
return {
|
||||
raw,
|
||||
line: line.join(''),
|
||||
textarea: textarea.value,
|
||||
sessions: [...new Set(sent.map(([sessionId]) => sessionId))],
|
||||
xtermEmitted,
|
||||
tileId,
|
||||
};
|
||||
} finally {
|
||||
app._sendInputAsync = originalSendInput;
|
||||
app.activeSessionId = originalSessionId;
|
||||
tile._keyCode229Recovery = rec;
|
||||
textarea.value = '';
|
||||
}
|
||||
},
|
||||
{ scenario, tileId: TILE_ID }
|
||||
);
|
||||
}
|
||||
|
||||
it('an autocorrect on space reaches the shell once, not duplicated', async () => {
|
||||
const r = await inTile('autocorrect');
|
||||
expect(r.textarea).toBe('testing the prompt ');
|
||||
expect(r.line).toBe('testing the prompt ');
|
||||
expect(r.sessions).toEqual([TILE_ID]);
|
||||
});
|
||||
|
||||
it('a 229 last character in the same task as Enter submits the whole line', async () => {
|
||||
const r = await inTile('lastCharThenEnter');
|
||||
expect(r.raw).not.toContain('\x7f');
|
||||
expect(r.line).toBe('hello\r');
|
||||
expect(r.sessions).toEqual([TILE_ID]);
|
||||
});
|
||||
|
||||
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
|
||||
const r = await inTile('autocorrectThenEnter');
|
||||
expect(r.line).toBe('testing the prompt \r');
|
||||
expect(r.sessions).toEqual([TILE_ID]);
|
||||
});
|
||||
|
||||
it('a refused insertText committed in the same task as Enter goes out BEFORE the carriage return', async () => {
|
||||
const r = await inTile('orphanThenEnter');
|
||||
expect(r.raw).toBe('o\r');
|
||||
expect(r.sessions).toEqual([TILE_ID]);
|
||||
});
|
||||
|
||||
it('a 229 keystroke xterm diffed itself is delivered once', async () => {
|
||||
const r = await inTile('selfRescued');
|
||||
expect(r.xtermEmitted).toBe(1);
|
||||
expect(r.raw).toBe('y');
|
||||
});
|
||||
|
||||
it("control: with the tile's controller destroyed, xterm alone duplicates the line", async () => {
|
||||
// Keep LAST in this block: it leaves the tile's controller off.
|
||||
await page.evaluate(() => (window as any).__tile229._keyCode229Recovery.destroy());
|
||||
const r = await inTile('autocorrect');
|
||||
expect(r.line).toBe('testing the peompttesting the prompt rompt ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,10 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
function loadTerminalUiHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const logs: string[] = [];
|
||||
// terminal-ui.js hangs CodemanTerminalInput off window; tests read it there.
|
||||
const windowRef: Record<string, any> = {};
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
window: windowRef,
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: (msg: string) => logs.push(msg) },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
@@ -43,12 +45,12 @@ function loadTerminalUiHarness() {
|
||||
|
||||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||
return { app: new (CodemanApp as any)(), logs };
|
||||
return { app: new (CodemanApp as any)(), logs, windowRef };
|
||||
}
|
||||
|
||||
/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */
|
||||
function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
||||
const { app, logs } = loadTerminalUiHarness();
|
||||
const { app, logs, windowRef } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([
|
||||
@@ -68,7 +70,7 @@ function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: numbe
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0, length: 36 } },
|
||||
};
|
||||
return { app, sent, logs };
|
||||
return { app, sent, logs, windowRef };
|
||||
}
|
||||
|
||||
describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
|
||||
@@ -255,6 +257,80 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
||||
});
|
||||
});
|
||||
|
||||
describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
||||
it('exports the paging math, and the primary pane runs on it', () => {
|
||||
const { app, sent, windowRef } = hollowApp();
|
||||
const { wheelDeltaLines, pageKeysForTravel } = windowRef.CodemanTerminalInput;
|
||||
|
||||
expect(wheelDeltaLines({ deltaY: -50, deltaMode: 0 }, 36)).toBe(-2); // pixels, 25 a line
|
||||
expect(wheelDeltaLines({ deltaY: 3, deltaMode: 1 }, 36)).toBe(3); // lines (Firefox)
|
||||
expect(wheelDeltaLines({ deltaY: 1, deltaMode: 2 }, 36)).toBe(36); // pages: the given rows
|
||||
expect(wheelDeltaLines({ deltaY: 0, deltaX: -75, shiftKey: true, deltaMode: 0 }, 36)).toBe(-3); // Shift axis
|
||||
expect(pageKeysForTravel(0, -10, 36)).toEqual({ pending: -10, keys: '' });
|
||||
expect(pageKeysForTravel(-10, -8, 36)).toEqual({ pending: 0, keys: '\x1b[5~' });
|
||||
expect(pageKeysForTravel(0, -1000, 36).keys).toBe('\x1b[5~'.repeat(3));
|
||||
expect(pageKeysForTravel(0, 40, 36)).toEqual({ pending: 4, keys: '\x1b[6~'.repeat(2) });
|
||||
|
||||
// The primary pane's own methods agree with them.
|
||||
const ev = { deltaY: -250, deltaMode: 2 };
|
||||
expect(app._wheelScrollLinesFloat(ev)).toBe(wheelDeltaLines(ev, 36));
|
||||
let pending = 0;
|
||||
let expected = '';
|
||||
for (const lines of [-10, -10, 30, -1000, 7]) {
|
||||
const step = pageKeysForTravel(pending, lines, 36);
|
||||
pending = step.pending;
|
||||
expected += step.keys;
|
||||
app._maybePageCliTranscript({ shiftKey: false }, lines);
|
||||
}
|
||||
app._flushWheelSgrQueue();
|
||||
expect(app._pageKeyPending).toBe(pending);
|
||||
expect(expected).not.toBe('');
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
|
||||
});
|
||||
|
||||
it("_localScrollbackIsHollow reads the target's session, buffer and rows, never the active ones", () => {
|
||||
const { app } = hollowApp({ mode: 'shell' }); // the ACTIVE session is a shell
|
||||
app.sessions.set('tile-1', { mode: 'opencode' });
|
||||
const tileBuffer = { type: 'normal', viewportY: 16, baseY: 16 };
|
||||
const tileTerminal = { rows: 24, buffer: { active: tileBuffer } };
|
||||
|
||||
expect(app._localScrollbackIsHollow()).toBe(false); // the primary's own answer
|
||||
// 16 rows above the tile's screen, all of them its own overflow: hollow.
|
||||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(true);
|
||||
// Real history in the tile: not hollow, whatever the primary holds.
|
||||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 3 })).toBe(false);
|
||||
// No localRows: the tile's own baseY decides.
|
||||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal })).toBe(false);
|
||||
tileBuffer.type = 'alternate';
|
||||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
|
||||
tileBuffer.type = 'normal';
|
||||
app.sessions.set('tile-1', { mode: 'codex' });
|
||||
expect(app._localScrollbackIsHollow({ sessionId: 'tile-1', terminal: tileTerminal, localRows: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("_shouldForwardWheelToApp reads the target's session and the target terminal's tracking mode", () => {
|
||||
const { app } = hollowApp({ mode: 'opencode' }); // the ACTIVE session would never forward
|
||||
app.sessions.set('tile-1', { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true });
|
||||
const tileTerminal = { rows: 24, modes: { mouseTrackingMode: 'none' } };
|
||||
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||||
true
|
||||
);
|
||||
// The tile's own xterm encoder owns the wheel while its tracking is on.
|
||||
tileTerminal.modes.mouseTrackingMode = 'any';
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||||
false
|
||||
);
|
||||
// And the primary's tracking mode does not leak into the tile's answer.
|
||||
tileTerminal.modes.mouseTrackingMode = 'none';
|
||||
app.terminal.modes.mouseTrackingMode = 'any';
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile-1', terminal: tileTerminal })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
||||
it('prints the decision and its inputs once per session, and again when it changes', () => {
|
||||
const { app, logs } = hollowApp({ cliVersion: '2.1.100' });
|
||||
|
||||
@@ -16,10 +16,16 @@
|
||||
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
|
||||
* destroy() cancels a pending reconnect.
|
||||
*
|
||||
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
|
||||
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
|
||||
* fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts);
|
||||
* `connect()` runs for real.
|
||||
* The last blocks pin the soft-keyboard controller every tile wires
|
||||
* (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an
|
||||
* Android autocorrect is sent as an edit, not a duplicated line, a character
|
||||
* committed in the same task as Enter goes out ahead of the \r, and the
|
||||
* controller is bound to THIS tile's textarea, composition helper and session.
|
||||
*
|
||||
* Real code under test: constants.js + terminal-keycode229-recovery.js +
|
||||
* app.js (the queue) + terminal-ui.js (the shared input predicates) +
|
||||
* terminal-tile.js, in one `vm` context. xterm, the fit addon and WebSocket are
|
||||
* fakes (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
@@ -36,6 +42,12 @@ function loadContext() {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
CodemanBase: { base: '' },
|
||||
// The keyCode-229 controller defaults its timers to window's. Late-bound,
|
||||
// like the context's own, so vi.useFakeTimers() reaches it; without them
|
||||
// its create() throws into the tile's catch and every controller test
|
||||
// would run against no controller at all.
|
||||
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||
@@ -63,7 +75,8 @@ function loadContext() {
|
||||
},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||
`${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` +
|
||||
`${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||
'globalThis.__CodemanApp = CodemanApp;',
|
||||
context
|
||||
);
|
||||
@@ -138,6 +151,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
FakeTerminal.coreFactory = null;
|
||||
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||
FakeSocket.instances = [];
|
||||
fetchMock.mockReset();
|
||||
@@ -684,3 +698,398 @@ describe('the server coming back kicks Pane B', () => {
|
||||
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* xterm's CompositionHelper, reduced to what the keyCode-229 controller touches.
|
||||
* Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped
|
||||
* (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a
|
||||
* control without the controller reproduces the device-log duplicate, and its
|
||||
* `triggerDataEvent` feeds the tile's onData, as xterm's core service does.
|
||||
*/
|
||||
type Helper = {
|
||||
_isComposing: boolean;
|
||||
_isSendingComposition: boolean;
|
||||
_dataAlreadySent: string;
|
||||
_coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void };
|
||||
_handleAnyTextareaChanges: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives every FakeTerminal created from now on a composition helper. Returns
|
||||
* them in creation order, with xterm's own diff each one started with.
|
||||
*/
|
||||
function withCompositionHelpers() {
|
||||
const helpers: Helper[] = [];
|
||||
const originals: Array<Helper['_handleAnyTextareaChanges']> = [];
|
||||
FakeTerminal.coreFactory = (term) => {
|
||||
const helper: Helper = {
|
||||
_isComposing: false,
|
||||
_isSendingComposition: false,
|
||||
_dataAlreadySent: '',
|
||||
_coreService: { triggerDataEvent: (data: string) => term.type(data) },
|
||||
_handleAnyTextareaChanges(this: Helper) {
|
||||
const oldValue = term.textarea.value;
|
||||
setTimeout(() => {
|
||||
if (this._isComposing) return;
|
||||
const newValue = term.textarea.value;
|
||||
const diff = newValue.replace(oldValue, '');
|
||||
this._dataAlreadySent = diff;
|
||||
if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true);
|
||||
else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true);
|
||||
else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true);
|
||||
}, 0);
|
||||
},
|
||||
};
|
||||
helpers.push(helper);
|
||||
originals.push(helper._handleAnyTextareaChanges);
|
||||
return { _compositionHelper: helper };
|
||||
};
|
||||
return Object.assign(helpers, { originals });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a tile the way an Android soft keyboard drives xterm. The fake xterm
|
||||
* runs no CompositionHelper.keydown of its own, so `key229()` does what xterm
|
||||
* does, in xterm's order: the custom key handler first, then (keyCode 229, no
|
||||
* composition) the helper's `_handleAnyTextareaChanges()`, read off the helper
|
||||
* at call time so the controller's patch is what runs.
|
||||
*/
|
||||
function softKeyboard(term: FakeTerminal, helper: Helper) {
|
||||
const textarea = term.textarea;
|
||||
const key229 = () => {
|
||||
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
|
||||
helper._handleAnyTextareaChanges();
|
||||
};
|
||||
return {
|
||||
key229,
|
||||
/** One appended character, settled on its own timer before the next key. */
|
||||
typeKeys(text: string) {
|
||||
for (const ch of text) {
|
||||
key229();
|
||||
textarea.value += ch;
|
||||
vi.advanceTimersByTime(1);
|
||||
}
|
||||
},
|
||||
/** The textarea now reads `value` (what the keyboard's input event left there). */
|
||||
edit(value: string) {
|
||||
textarea.value = value;
|
||||
},
|
||||
/** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */
|
||||
enter() {
|
||||
const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||
if (passed) term.type('\r');
|
||||
textarea.value = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every byte the tile sent as input, in order. */
|
||||
const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join('');
|
||||
const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames());
|
||||
|
||||
/** The line a shell ends up with: every DEL erases the character before it. */
|
||||
function lineOf(frames: Array<{ d?: string }>) {
|
||||
const out: string[] = [];
|
||||
for (const ch of joinFrames(frames)) {
|
||||
if (ch === '\x7f') out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
|
||||
|
||||
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
|
||||
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
|
||||
const helpers = withCompositionHelpers();
|
||||
const { tile, term } = await connectTile(makeApp());
|
||||
const helper = helpers[0];
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
|
||||
|
||||
// The controller patched this tile's helper (xterm's diff is no longer the one that runs) and
|
||||
// listens on this tile's textarea in the CAPTURE phase (see the module's measured table).
|
||||
expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]);
|
||||
const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type);
|
||||
expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']);
|
||||
|
||||
tile.destroy();
|
||||
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
|
||||
expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]);
|
||||
expect(term.textareaListeners).toEqual([]);
|
||||
});
|
||||
|
||||
it('an autocorrect on space is sent as an edit, not a duplicated line', async () => {
|
||||
vi.useFakeTimers();
|
||||
const helpers = withCompositionHelpers();
|
||||
const app = makeApp();
|
||||
app.activeSessionId = 'some-other-session';
|
||||
const { tile, ws, term } = await connectTile(app);
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
|
||||
ws.open();
|
||||
const kb = softKeyboard(term, helpers[0]);
|
||||
|
||||
kb.typeKeys('testing the peompt');
|
||||
// The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `,
|
||||
// both before any timer runs.
|
||||
kb.key229();
|
||||
kb.edit('testing the p');
|
||||
kb.key229();
|
||||
kb.edit('testing the prompt ');
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const frames = ws.inputFrames();
|
||||
expect(lineOf(frames)).toBe('testing the prompt ');
|
||||
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
|
||||
// Every byte went to THIS tile's session through the exactly-once queue, never the active one.
|
||||
expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true);
|
||||
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d));
|
||||
expect(app._pendingDeliveries.has('some-other-session')).toBe(false);
|
||||
});
|
||||
|
||||
it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => {
|
||||
vi.useFakeTimers();
|
||||
const helpers = withCompositionHelpers();
|
||||
const saved = windowStub.CodemanKeyCode229Recovery;
|
||||
delete windowStub.CodemanKeyCode229Recovery;
|
||||
try {
|
||||
const { tile, ws, term } = await connectTile(makeApp());
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
|
||||
ws.open();
|
||||
const kb = softKeyboard(term, helpers[0]);
|
||||
|
||||
kb.typeKeys('testing the peompt');
|
||||
kb.key229();
|
||||
kb.edit('testing the p');
|
||||
kb.key229();
|
||||
kb.edit('testing the prompt ');
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt ');
|
||||
} finally {
|
||||
windowStub.CodemanKeyCode229Recovery = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const helpers = withCompositionHelpers();
|
||||
const { ws, term } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
const kb = softKeyboard(term, helpers[0]);
|
||||
|
||||
kb.typeKeys('hell');
|
||||
// One task: the last character's keydown and edit, then Enter, no timer in between.
|
||||
kb.key229();
|
||||
kb.edit('hello');
|
||||
kb.enter();
|
||||
// Settled synchronously at the Enter keydown, not by its timer.
|
||||
expect(wireOf(ws)).toBe('hello\r');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
const wire = wireOf(ws);
|
||||
expect(wire).toBe('hello\r');
|
||||
expect(wire).not.toContain('\x7f');
|
||||
});
|
||||
|
||||
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
|
||||
vi.useFakeTimers();
|
||||
const helpers = withCompositionHelpers();
|
||||
const { ws, term } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
const kb = softKeyboard(term, helpers[0]);
|
||||
|
||||
kb.typeKeys('testing the peompt');
|
||||
kb.key229();
|
||||
kb.edit('testing the p');
|
||||
kb.key229();
|
||||
kb.edit('testing the prompt ');
|
||||
kb.enter();
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const frames = ws.inputFrames();
|
||||
expect(lineOf(frames)).toBe('testing the prompt \r');
|
||||
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => {
|
||||
vi.useFakeTimers();
|
||||
const helpers = withCompositionHelpers();
|
||||
const { tile, ws, term } = await connectTile(makeApp());
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
|
||||
ws.open();
|
||||
const kb = softKeyboard(term, helpers[0]);
|
||||
|
||||
kb.key229();
|
||||
kb.edit('y');
|
||||
term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false });
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tile's onData tells the controller only about what a human typed", () => {
|
||||
/** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */
|
||||
const orphan = (term: FakeTerminal, data: string) => {
|
||||
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
|
||||
term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false });
|
||||
};
|
||||
|
||||
it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => {
|
||||
vi.useFakeTimers();
|
||||
const app = makeApp();
|
||||
const { tile, ws, term } = await connectTile(app);
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
|
||||
ws.open();
|
||||
|
||||
orphan(term, 'x');
|
||||
term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke"
|
||||
term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const frames = ws.inputFrames();
|
||||
expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']);
|
||||
const recovered = frames.find((f) => f.d === 'x')!;
|
||||
expect(Number.isInteger(recovered.seq)).toBe(true);
|
||||
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']);
|
||||
});
|
||||
|
||||
it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => {
|
||||
// Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the
|
||||
// recovered bytes also go through. Counted there, the first recovery would read as "xterm
|
||||
// spoke" for the second candidate, which shares its keydown snapshot, and drop it.
|
||||
vi.useFakeTimers();
|
||||
const { ws, term } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
|
||||
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
|
||||
term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false });
|
||||
term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false });
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('stands down when xterm really did deliver the keystroke', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { ws, term } = await connectTile(makeApp());
|
||||
ws.open();
|
||||
|
||||
orphan(term, 'x');
|
||||
term.type('x'); // xterm's own canonical emission for this keystroke
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the controller can never break a tile', () => {
|
||||
type FakeController = {
|
||||
handleKeyEvent: ReturnType<typeof vi.fn>;
|
||||
notifyCanonicalData: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let saved: unknown;
|
||||
beforeEach(() => {
|
||||
saved = windowStub.CodemanKeyCode229Recovery;
|
||||
});
|
||||
afterEach(() => {
|
||||
windowStub.CodemanKeyCode229Recovery = saved;
|
||||
});
|
||||
|
||||
const fakeController = (overrides: Partial<FakeController> = {}): FakeController => ({
|
||||
handleKeyEvent: vi.fn(),
|
||||
notifyCanonicalData: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('a create() that throws leaves the tile connected and typing', async () => {
|
||||
windowStub.CodemanKeyCode229Recovery = {
|
||||
create: () => {
|
||||
throw new Error('broken');
|
||||
},
|
||||
};
|
||||
const { tile, ws, term } = await connectTile(makeApp());
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
|
||||
ws.open();
|
||||
|
||||
term.type('a');
|
||||
|
||||
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => {
|
||||
const controller = fakeController({
|
||||
handleKeyEvent: vi.fn(() => {
|
||||
throw new Error('broken');
|
||||
}),
|
||||
});
|
||||
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
|
||||
const { term } = await connectTile(makeApp());
|
||||
|
||||
expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false);
|
||||
expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false);
|
||||
expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true);
|
||||
expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true);
|
||||
// It still ran first, for every one of them.
|
||||
expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('a destroy() that throws still lets the tile dispose its xterm', async () => {
|
||||
const controller = fakeController({
|
||||
destroy: vi.fn(() => {
|
||||
throw new Error('broken');
|
||||
}),
|
||||
});
|
||||
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
|
||||
const { tile, term } = await connectTile(makeApp());
|
||||
const dispose = vi.spyOn(term, 'dispose');
|
||||
|
||||
tile.destroy();
|
||||
|
||||
expect(controller.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
|
||||
});
|
||||
|
||||
it('each tile gets its own controller on its own textarea, and destroys only its own', async () => {
|
||||
const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = [];
|
||||
windowStub.CodemanKeyCode229Recovery = {
|
||||
create: (options: { textarea: unknown }) => {
|
||||
const controller = fakeController();
|
||||
made.push({ options, controller });
|
||||
return controller;
|
||||
},
|
||||
};
|
||||
const app = makeApp();
|
||||
const a = await connectTile(app);
|
||||
const b = await connectTile(app);
|
||||
|
||||
expect(made).toHaveLength(2);
|
||||
expect(made[0].options.textarea).toBe(a.term.textarea);
|
||||
expect(made[1].options.textarea).toBe(b.term.textarea);
|
||||
|
||||
a.tile.destroy();
|
||||
|
||||
expect(made[0].controller.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(made[1].controller.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal-tile.js keeps the controller call where it works (source pin)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
|
||||
|
||||
it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => {
|
||||
const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)');
|
||||
const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true");
|
||||
expect(call).toBeGreaterThan(-1);
|
||||
expect(earlyReturn).toBeGreaterThan(-1);
|
||||
expect(call).toBeLessThan(earlyReturn);
|
||||
});
|
||||
|
||||
it("hands the controller this tile's own composition helper", () => {
|
||||
expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @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.
|
||||
* - a viewport left up in those discounted rows gets its wheel back: a real
|
||||
* wheel-down scrolls xterm home instead of being paged, and paging resumes
|
||||
* from the live screen.
|
||||
*
|
||||
* 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 of `rows` lines over the tile (negative = up), then time for the 40 ms flush and a frame. */
|
||||
async function wheelBy(rows: number) {
|
||||
await page.mouse.move(200, 60);
|
||||
await page.mouse.wheel(0, rows * 25);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
const wheelUp = (rows: number) => wheelBy(-rows);
|
||||
|
||||
it('pages a hollow tile, keeps xterm still, survives a shrink, returns an off-bottom wheel, and stops at real lines', 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);
|
||||
|
||||
// A viewport left up in those rows (Shift+PageUp, a scrollbar drag): the
|
||||
// wheel is xterm's again, so a wheel-down really scrolls it home and sends
|
||||
// no page key, and from the live screen the wheel pages once more.
|
||||
await page.evaluate(() => (window as any).__tileProbe.tile.terminal.scrollLines(-5));
|
||||
const scrolledUp = await snap();
|
||||
expect(scrolledUp.viewportY).toBe(afterShrink.baseY - 5);
|
||||
expect(scrolledUp.localRows).toBe(0);
|
||||
// xterm scrolls a few lines per real wheel event, so wheel down until home
|
||||
// (bounded), each step really moving it and none of them paged.
|
||||
let backHome = scrolledUp;
|
||||
for (let i = 0; i < 10 && backHome.viewportY < afterShrink.baseY; i++) {
|
||||
const before = backHome.viewportY;
|
||||
await wheelBy(afterShrink.rows);
|
||||
backHome = await snap();
|
||||
expect(backHome.viewportY).toBeGreaterThan(before);
|
||||
}
|
||||
expect(backHome.viewportY).toBe(afterShrink.baseY);
|
||||
expect(backHome.sent.length).toBe(afterSecondWheel.sent.length);
|
||||
await wheelUp(afterShrink.rows);
|
||||
const pagedAgain = await snap();
|
||||
expect(pagedAgain.sent.length).toBeGreaterThan(backHome.sent.length);
|
||||
expect(pagedAgain.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(pagedAgain.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,608 @@
|
||||
/**
|
||||
* @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('leaves the wheel to xterm while the viewport sits in those rows, and pages again from the bottom', async () => {
|
||||
// Hollow by the discount alone: baseY > 0, every row above the screen the
|
||||
// tile's own. Shift+PageUp, a scrollbar drag or a wheel during the replay
|
||||
// can leave the viewport up there; a primary hollow buffer (baseY 0) never
|
||||
// can. Paging from there would swallow every wheel and keep the stale rows
|
||||
// on screen, so xterm gets the wheel and a wheel-down brings it home.
|
||||
serveCapture(lines(40), 40);
|
||||
const { tile, ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
|
||||
const baseY = term.buffer.active.baseY;
|
||||
expect(baseY).toBe(16);
|
||||
expect((tile as unknown as { _localRows(): number })._localRows()).toBe(0);
|
||||
|
||||
term.buffer.active.viewportY = baseY - 5;
|
||||
for (const ev of [wheelLines(-12), wheelLines(12), wheelLines(-5), wheelLines(-5)]) {
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
|
||||
// Back on the live screen, only travel made there counts: two quarter-screen
|
||||
// wheels are one PageUp, with nothing carried over from the wheels xterm had
|
||||
// (the gate sits before the pending travel is touched).
|
||||
term.buffer.active.viewportY = baseY;
|
||||
const first = wheelLines(-6);
|
||||
mount.fire('wheel', first);
|
||||
expect(first.preventDefault).toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
mount.fire('wheel', wheelLines(-6));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
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 ephemeral 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());
|
||||
|
||||
// No seq: like every mouse report from a tile, it never enters the
|
||||
// persisted exactly-once queue, so a reload cannot replay it.
|
||||
expect(ws.inputFrames().map((f) => [f.d, typeof f.seq])).toEqual([[TAP, 'undefined']]);
|
||||
expect((app._pendingDeliveries as Map<string, unknown[]>).get('s-tile')?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -577,6 +577,106 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('desktop click: a target aims the report at another pane (a TerminalTile)', () => {
|
||||
// The tile's own terminal decides the geometry, the selection and the
|
||||
// scroll position, and its own session decides the tracking flag; the
|
||||
// primary pane's terminal and active session are not consulted.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
// A tile's report must stay out of the persisted queue: it asks for the
|
||||
// ephemeral path, and only a target that says so gets it.
|
||||
const durable: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([
|
||||
['sess-1', { mode: 'claude', cliMouseTracking: false }],
|
||||
['s2', { mode: 'opencode', cliMouseTracking: true }],
|
||||
]);
|
||||
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||
app._sendInputAsync = (id: string, data: string) => durable.push({ id, data });
|
||||
app._linkHovered = true; // the PRIMARY pane's hover: must not block the tile
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => true, // the PRIMARY pane's selection: must not block the tile
|
||||
buffer: { active: { viewportY: 0, baseY: 50 } }, // primary scrolled up: must not block either
|
||||
element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) },
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
let otherSelected = false;
|
||||
const other = {
|
||||
cols: 40,
|
||||
rows: 12,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => otherSelected,
|
||||
buffer: { active: { viewportY: 5, baseY: 5 } },
|
||||
element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }) },
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
const click = {
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 171,
|
||||
clientY: 101,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
};
|
||||
|
||||
const tile = { terminal: other, sessionId: 's2', linkHovered: false, ephemeral: true };
|
||||
app._handleDesktopTerminalClick(click, tile);
|
||||
expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
||||
expect(durable).toEqual([]);
|
||||
|
||||
// The tile's own selection and its own link hover do block it.
|
||||
otherSelected = true;
|
||||
app._handleDesktopTerminalClick(click, tile);
|
||||
otherSelected = false;
|
||||
app._handleDesktopTerminalClick(click, { ...tile, linkHovered: true });
|
||||
expect(sent).toHaveLength(1);
|
||||
|
||||
// With no target the primary pane answers for itself, exactly as before.
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
expect(app._shouldReportMouseToCli('s2')).toBe(true);
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toHaveLength(1);
|
||||
|
||||
// And the primary pane's own reports stay on the durable queue: an
|
||||
// untargeted click and an untargeted touch tap.
|
||||
app.sessions.set('sess-1', { mode: 'claude', cliMouseTracking: true });
|
||||
app.terminal = { ...app.terminal, hasSelection: () => false, buffer: { active: { viewportY: 50, baseY: 50 } } };
|
||||
app._linkHovered = false;
|
||||
app._handleDesktopTerminalClick(click);
|
||||
app._sendSyntheticSgrTap(50, 50);
|
||||
expect(durable).toEqual([
|
||||
{ id: 'sess-1', data: '\x1b[<0;22;7M\x1b[<0;22;7m' }, // the primary's own origin (0, 0)
|
||||
{ id: 'sess-1', data: '\x1b[<0;7;4M\x1b[<0;7;4m' },
|
||||
]);
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tap: a target uses that pane's geometry and scroll position", () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = null; // the primary pane need not even exist
|
||||
const other = {
|
||||
cols: 40,
|
||||
rows: 12,
|
||||
buffer: { active: { viewportY: 0, baseY: 5 } }, // scrolled up
|
||||
element: { querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) },
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' });
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
other.buffer.active.viewportY = 5; // back at the bottom
|
||||
app._sendSyntheticSgrTap(50, 9999, { terminal: other, sessionId: 's2' });
|
||||
// Row clamped to the TARGET's 12 rows, not the primary's.
|
||||
expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;7;12M\x1b[<0;7;12m' }]);
|
||||
});
|
||||
|
||||
it('tap: does nothing while the viewport is scrolled up into local scrollback', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user