refactor(terminal): let the wheel paging and click-report gates answer for another pane

The primary pane's hollow-buffer paging (#555) and its desktop click report
read this.terminal and this.activeSessionId throughout, so a second pane (a
grid tile, the split's Pane B) could only get them by copying the gates and
their CLI rules. They now take an optional trailing target instead, the
pattern registerFilePathLinkProvider, copyTerminalSelection and
_handleImagePaste already use for tiles:

- _shouldForwardWheelToApp(ev, { terminal, sessionId })
- _localScrollbackIsHollow({ terminal, sessionId, localRows }), where
  localRows stands in for baseY so a tile can discount rows it pushed above
  the screen itself
- _handleDesktopTerminalClick(ev, { terminal, sessionId, linkHovered }),
  _sendSyntheticSgrTap(x, y, target), _shouldReportMouseToCli(sessionId),
  _terminalViewportAtBottom(terminal) and _clientPointToCell(x, y, terminal)

Every field left out means the primary pane's, and every existing caller
passes none, so the primary pane behaves exactly as before and its
grep-pinned call sites are unchanged. The mode list for hollow buffers and
the claude >= 2.1.187 forwarding gate stay in terminal-ui.js alone.

The stateless math moves into two pure exports on CodemanTerminalInput,
wheelDeltaLines and pageKeysForTravel, which _wheelScrollLinesFloat and
_maybePageCliTranscript now delegate to. Comments on both sides name the
tile's twins (the page-key pager and the 40 ms coalescer).

Tests: the exports agree with the primary pane's methods and bytes, the gates
read the target's session, buffer, rows and tracking mode rather than the
active ones, and a targeted click uses the target's geometry, selection,
scroll position and link hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 07:18:47 +02:00
parent f855b5d274
commit 155372f7a8
3 changed files with 263 additions and 51 deletions
+81
View File
@@ -577,6 +577,87 @@ 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 }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([
['sess-1', { mode: 'claude', cliMouseTracking: false }],
['s2', { mode: 'opencode', cliMouseTracking: true }],
]);
app._sendInputAsync = (id: string, data: string) => sent.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) },
};
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false });
expect(sent).toEqual([{ id: 's2', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
// The tile's own selection and its own link hover do block it.
otherSelected = true;
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', linkHovered: false });
otherSelected = false;
app._handleDesktopTerminalClick(click, { terminal: other, sessionId: 's2', 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);
});
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[] = [];