mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 09:49:41 +02:00
fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to xterm for a session running Claude's fullscreen renderer (claude 2.1.187+ with mouse tracking on, cliMouseTracking). That renderer scrolls its own transcript on SGR wheel reports, which the primary pane sends it, while the tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no history for such a pane). So in a grid of fullscreen Claude sessions the wheel either scrolled nothing or dragged stale frames, Claude's pinned input box with them, up the tile, and Claude's transcript never moved. This was tile-grid-plan follow-up 4. The tile now forwards the wheel the way the primary pane does (TerminalTile._maybeForwardWheelToCli): the primary pane's own gate, _shouldForwardWheelToApp(ev, target), asked for the tile's terminal and session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up viewport snapped to the live screen first; and the reports flushed through the tile's own 40 ms coalescer to the tile's session (the primary queue flushes to the active session). The encoding moved into pure helpers in terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and sgrWheelReports, which the primary pane's _wheelScrollLines and _sendSyntheticSgrWheel now call too, so the two panes send identical bytes. Shift+wheel, the explicit local-scrollback gesture, was dead in every tile off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the dominant axis, sub-line travel carried over, a shell tile's history pull still asked on the way up), as the primary pane's capture-phase handler does. Unchanged: inline Claude, opencode and older Claude still take the PageUp/PageDown route (#555), shells and other modes keep xterm's own plain wheel, and a tracking xterm or the alternate buffer stays xterm's. Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick cap, coalescing, viewport snap, the tile's session rather than the active one, Shift/tracking/alternate exclusions, byte equality with the primary pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case with trusted page.mouse.wheel() events. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -199,6 +199,19 @@ export class FakeTerminal {
|
||||
}
|
||||
scrollToLine() {}
|
||||
scrollToTop() {}
|
||||
/** Every scrollLines() amount; the viewport moves within [0, baseY], as in xterm. */
|
||||
scrolledLines: number[] = [];
|
||||
scrollLines(amount: number) {
|
||||
this.scrolledLines.push(amount);
|
||||
const active = this.buffer.active;
|
||||
active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount));
|
||||
}
|
||||
/** Times scrollToBottom() was called; the viewport moves to the live screen, as in xterm. */
|
||||
scrolledToBottom = 0;
|
||||
scrollToBottom() {
|
||||
this.scrolledToBottom++;
|
||||
this.buffer.active.viewportY = this.buffer.active.baseY;
|
||||
}
|
||||
dispose() {}
|
||||
type(data: string) {
|
||||
this.dataCb?.(data);
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
* wheel-down scrolls xterm home instead of being paged, and paging resumes
|
||||
* from the live screen.
|
||||
*
|
||||
* - a fullscreen Claude tile sends the wheel to the CLI as SGR wheel reports
|
||||
* at the pointer's cell in the TILE (Claude scrolls its own transcript on
|
||||
* them), and xterm's viewport stays on the live screen instead of
|
||||
* scrolling the stale replayed frames above it. Shift+wheel scrolls the
|
||||
* local scrollback, which xterm alone turns into a horizontal no-op.
|
||||
*
|
||||
* The wheel is a real one (`page.mouse.wheel()`), and the last step proves it
|
||||
* reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so
|
||||
* "xterm did not scroll" on a paged wheel means something. What keeps xterm
|
||||
@@ -200,4 +206,95 @@ describe('TerminalTile wheel paging in a real browser', () => {
|
||||
}, PROBE_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards a fullscreen Claude tile's wheel as SGR reports from its own cells, and keeps xterm on the live screen", async () => {
|
||||
await page.evaluate(async (id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const CAPTURE_ROWS = 40;
|
||||
const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `frame ${i}`).join('\r\n');
|
||||
|
||||
// The tile's session: Claude in its fullscreen renderer (mouse tracking on).
|
||||
app.sessions.set(id, { id, mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true });
|
||||
const sent: Array<[string, string]> = [];
|
||||
const realEphemeral = app._sendInputEphemeral;
|
||||
app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]);
|
||||
const realFetch = w.fetch;
|
||||
w.fetch = async (url: string, init?: unknown) =>
|
||||
String(url).includes(`/api/sessions/${id}/terminal`)
|
||||
? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } }))
|
||||
: realFetch(url, init);
|
||||
const RealWebSocket = w.WebSocket;
|
||||
w.WebSocket = class {
|
||||
static OPEN = 1;
|
||||
readyState = 0;
|
||||
send() {}
|
||||
close() {}
|
||||
};
|
||||
|
||||
// Offset from the page corner, so a cell computed against the wrong
|
||||
// element (the primary pane's) would come out different.
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText =
|
||||
'position:fixed;left:100px;top:120px;width:640px;height:300px;z-index:99999;background:#000';
|
||||
document.body.appendChild(mount);
|
||||
const tile = new w.TerminalTile(id, mount, { mode: 'claude' });
|
||||
try {
|
||||
await tile.connect();
|
||||
} finally {
|
||||
w.fetch = realFetch;
|
||||
w.WebSocket = RealWebSocket;
|
||||
}
|
||||
w.__tileProbe = { tile, mount, sent, realEphemeral };
|
||||
}, PROBE_ID);
|
||||
|
||||
try {
|
||||
// Where the pointer will be, as the tile's own screen maps it (1-based).
|
||||
const expected = await page.evaluate(() => {
|
||||
const term = (window as any).__tileProbe.tile.terminal;
|
||||
const rect = term.element.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const cell = term._core._renderService.dimensions.css.cell;
|
||||
const x = rect.left + cell.width * 7.5;
|
||||
const y = rect.top + cell.height * 3.5;
|
||||
return { x, y, col: 8, row: 4 };
|
||||
});
|
||||
// The stale frames sit above the live screen: something xterm COULD scroll.
|
||||
const before = await snap();
|
||||
expect(before.baseY).toBeGreaterThan(0);
|
||||
expect(before.viewportY).toBe(before.baseY);
|
||||
|
||||
await page.mouse.move(expected.x, expected.y);
|
||||
await page.mouse.wheel(0, -100); // one Chrome-on-Windows notch up
|
||||
await page.waitForTimeout(150);
|
||||
await page.mouse.wheel(0, 50); // half a notch down
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const after = await snap();
|
||||
expect(after.viewportY).toBe(before.baseY); // xterm never scrolled the stale frames
|
||||
expect(after.sent.every(([sid]) => sid === PROBE_ID)).toBe(true);
|
||||
const up = `\x1b[<64;${expected.col};${expected.row}M`;
|
||||
const down = `\x1b[<65;${expected.col};${expected.row}M`;
|
||||
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
|
||||
|
||||
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
|
||||
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.wheel(0, -100);
|
||||
await page.keyboard.up('Shift');
|
||||
await page.waitForTimeout(150);
|
||||
const shifted = await snap();
|
||||
expect(shifted.sent.length).toBe(after.sent.length);
|
||||
expect(shifted.viewportY).toBeLessThan(before.baseY);
|
||||
} finally {
|
||||
await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const probe = w.__tileProbe;
|
||||
probe.tile.destroy();
|
||||
probe.mount.remove();
|
||||
w.app.sessions.delete(id);
|
||||
w.app._sendInputEphemeral = probe.realEphemeral;
|
||||
delete w.__tileProbe;
|
||||
}, PROBE_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
* history, so the tile discounts them (`_localRows`); the overflow cases below
|
||||
* fail without that discount.
|
||||
*
|
||||
* Claude's fullscreen renderer is the other half: it scrolls its own
|
||||
* transcript on SGR wheel reports, which the primary pane sends it, while a
|
||||
* tile's xterm holds only replayed repaint frames. A tile left that wheel to
|
||||
* xterm too, so in a grid of fullscreen Claude sessions the wheel dragged
|
||||
* stale frames around or did nothing; it now forwards the reports to its own
|
||||
* session, from its own geometry (`_maybeForwardWheelToCli`).
|
||||
*
|
||||
* Real code under test: constants.js + app.js + terminal-ui.js +
|
||||
* terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts.
|
||||
* xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts),
|
||||
@@ -260,7 +267,8 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
|
||||
// The explicit "local scrollback" gesture: scrolled locally, never paged.
|
||||
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-12));
|
||||
@@ -280,18 +288,207 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
||||
it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
||||
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
||||
describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => {
|
||||
const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true };
|
||||
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
|
||||
const AT = { clientX: 171, clientY: 101 };
|
||||
const UP = '\x1b[<64;21;6M';
|
||||
const DOWN = '\x1b[<65;21;6M';
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
const up = wheelLines(-3, AT);
|
||||
mount.fire('wheel', up);
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
|
||||
|
||||
mount.fire('wheel', wheelLines(2, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
|
||||
});
|
||||
|
||||
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-100, AT));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
|
||||
mount.fire('wheel', wheelLines(-1000, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
|
||||
});
|
||||
|
||||
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
const sideways = wheel(0, { ...AT, deltaX: 120 });
|
||||
mount.fire('wheel', sideways);
|
||||
expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames
|
||||
expect(flushed(ws)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
vi.advanceTimersByTime(20);
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
});
|
||||
|
||||
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
term.buffer.active.baseY = 10;
|
||||
term.buffer.active.viewportY = 0;
|
||||
|
||||
mount.fire('wheel', wheelLines(-1, AT));
|
||||
|
||||
expect(term.scrolledToBottom).toBe(1);
|
||||
expect(term.buffer.active.viewportY).toBe(10);
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
});
|
||||
|
||||
it("follows the tile session's gate, never the active session's", async () => {
|
||||
// Active session is a shell; the tile shows fullscreen Claude: the tile forwards.
|
||||
const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), {
|
||||
mode: 'claude',
|
||||
});
|
||||
forwarded.mount.fire('wheel', wheelLines(-1, AT));
|
||||
expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
// Active session is fullscreen Claude; the tile shows inline Claude (no
|
||||
// tracking): no reports, the hollow buffer is paged instead.
|
||||
const inline = await connectTile(
|
||||
makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'),
|
||||
{ mode: 'claude' }
|
||||
);
|
||||
inline.mount.fire('wheel', wheelLines(-12, AT));
|
||||
expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture
|
||||
mount.fire('wheel', shifted);
|
||||
expect(shifted.preventDefault).toHaveBeenCalled();
|
||||
expect(term.scrolledLines).toEqual([-3]);
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-3, AT));
|
||||
term.modes.mouseTrackingMode = 'none';
|
||||
|
||||
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
|
||||
fireAndCheck(wheelLines(-3, AT));
|
||||
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('pages, never reports, a Claude older than 2.1.187', async () => {
|
||||
const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } });
|
||||
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-12, AT));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
|
||||
const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT));
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
const tileBytes: string[] = [];
|
||||
for (const ev of sequence) {
|
||||
mount.fire('wheel', ev);
|
||||
tileBytes.push(
|
||||
...flushed(ws)
|
||||
.splice(tileBytes.length)
|
||||
.map((f) => f.d as string)
|
||||
);
|
||||
}
|
||||
|
||||
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
|
||||
_wheelScrollLines(ev: unknown): number;
|
||||
_flushWheelSgrQueue(): void;
|
||||
};
|
||||
const sent: string[] = [];
|
||||
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
primary.terminal = new FakeTerminal({});
|
||||
for (const ev of sequence) {
|
||||
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
|
||||
primary._flushWheelSgrQueue();
|
||||
}
|
||||
|
||||
expect(tileBytes.join('')).not.toBe('');
|
||||
expect(tileBytes).toEqual(sent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shift+wheel scrolls the tile's local scrollback itself", () => {
|
||||
// xterm's own scroller turns a Shift+vertical wheel into a horizontal one off
|
||||
// macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so
|
||||
// left to xterm the gesture moved nothing.
|
||||
it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode });
|
||||
term.write(lines(40));
|
||||
const bottom = term.buffer.active.baseY;
|
||||
expect(bottom).toBeGreaterThan(5);
|
||||
|
||||
const up = wheelLines(-3, { shiftKey: true });
|
||||
mount.fire('wheel', up);
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(term.buffer.active.viewportY).toBe(bottom - 3);
|
||||
mount.fire('wheel', wheelLines(3, { shiftKey: true }));
|
||||
expect(term.buffer.active.viewportY).toBe(bottom);
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => {
|
||||
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true }));
|
||||
|
||||
expect(term.scrolledLines).toEqual([-4]);
|
||||
});
|
||||
|
||||
it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => {
|
||||
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
||||
expect(term.scrolledLines).toEqual([]);
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
||||
|
||||
expect(term.scrolledLines).toEqual([-1]);
|
||||
});
|
||||
|
||||
it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => {
|
||||
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
|
||||
|
||||
mount.fire('wheel', wheelLines(-3, { shiftKey: true }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
||||
it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||
|
||||
Reference in New Issue
Block a user