fix(tiles): page a hollow tile's CLI transcript and report its clicks (#555 parity)

#555 made the primary pane page opencode's transcript with PageUp/PageDown
from the wheel, because opencode draws in place on the alternate screen and
leaves the browser's buffer with no scrollback. A TerminalTile (a grid tile,
the split's Pane B) left every wheel to xterm, so in an opencode tile the
wheel scrolled nothing, or only stale rows.

The tile now runs the primary pane's own gates aimed at itself (its terminal,
its session, never the active one): xterm's tracking mode, the Claude
forwarding gate, then the hollow-buffer test. A wheel that passes them is
consumed in the capture phase and turned into PageUp/PageDown through the
shared pageKeysForTravel math, coalesced per tile (40 ms, 512 bytes, the twin
of the primary pane's queue) and sent ephemeral on the tile's own socket.
Every other wheel stays with xterm as before, the shell history pull
included. The file names no CLI: the mode rules stay in terminal-ui.js, and
terminal-tile.js joins the frontend no-id-branching guard.

A plain port of the primary's baseY === 0 test would almost never fire in a
grid. A tile's first capture is taken at the PTY's previous size (usually the
taller primary pane's) and written into a shorter xterm, and its own
row-shrinking fits (zoom-out, divider drags, tile count changes) push more
rows above the screen. The tile counts those rows as its own overflow: all of
them after a load whose capture held a single screen (the server's
captureRows), plus whatever a local fit or a PTY geometry report pushes up,
reset by a clear and clamped to baseY. The paging gate gets baseY minus that
count. Output that scrolls real lines still counts as history, so the tile
stops paging there.

#555's other half, stripping opencode's mouse DECSETs so a drag selects text,
is server-side and already reached tile sockets. It also left the tile's
xterm unable to encode opencode's clicks, so the tile now installs the
primary pane's desktop click report (bubble phase, gated on the session's
cliMouseTracking, the tile's own link hover and selection). Both listeners,
the flush timer and the page-key state are torn down in destroy().

Still out of scope, as the fileoverview now says: touch paging (tiles have
no touch path) and SGR wheel forwarding to Claude's fullscreen renderer
(tile-grid-plan follow-up 4), so a fullscreen Claude tile keeps leaving the
wheel to xterm.

Tests: test/terminal-tile-scroll.test.ts drives a real tile in the vm
harness (session targeting, every no-page case, accumulation, the cap,
coalescing, byte parity with the primary pane, the overflow discount through
a load, a fit, a geometry report and a clear, the click report and destroy);
the discount cases fail with it removed. The fake xterm gains opt-in row
emulation. test/terminal-tile-scroll.browser.test.ts checks the same model
against a real xterm with trusted wheel events (browser suite, not the gate).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 07:35:29 +02:00
parent 155372f7a8
commit 4fe843a94e
12 changed files with 1033 additions and 19 deletions
+43 -1
View File
@@ -61,15 +61,45 @@ export class FakeFit {
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
export class FakeTerminal {
static last: FakeTerminal | null = null;
/**
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
* beyond the screen, a clear leaves one line, and a resize recomputes it
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
* back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it.
*/
static emulateScroll = false;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } };
/** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */
modes = { mouseTrackingMode: 'none' };
/** Lines in the buffer while emulating (the cursor line counts). */
lineCount = 1;
emulate = FakeTerminal.emulateScroll;
/** Called after an emulated resize, so a test can stand in for a reflow. */
afterResize: ((cols: number, rows: number) => void) | null = null;
/** Where the screen sits and how big a cell renders, for the click-to-cell math. */
screenRect = { left: 10, top: 20 };
element = {
querySelector: (sel: string) =>
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
};
_core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } };
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
/** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */
settleRows() {
const active = this.buffer.active;
active.baseY = Math.max(0, this.lineCount - this.rows);
active.viewportY = active.baseY;
active.length = Math.max(this.lineCount, this.rows);
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
@@ -101,16 +131,28 @@ export class FakeTerminal {
// An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
if (data && this.emulate) {
this.lineCount += data.split('\n').length - 1;
this.settleRows();
}
if (!this.holdParse) cb?.();
}
clear() {
this.writes.push('<CLEAR>');
if (this.emulate) {
this.lineCount = 1;
this.settleRows();
}
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
if (this.emulate) {
this.settleRows();
this.afterResize?.(cols, rows);
}
}
scrollToLine() {}
scrollToTop() {}