mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
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:
@@ -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' });
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user