mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #144 from TeigenZhang/pr/mouse-restore-decset-strip
feat(web): restore tap/click/wheel mouse interaction when server strips mouse DECSETs
This commit is contained in:
@@ -560,6 +560,7 @@
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
|
||||
|
||||
@@ -353,13 +353,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Register link provider for clickable file paths in Bash tool output
|
||||
this.registerFilePathLinkProvider();
|
||||
|
||||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||
// wheel reports (codex, and claude 2.1.187+ — see _shouldForwardWheelToApp),
|
||||
// local scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||
// live and stay clickable — and its select menus no longer capture wheel
|
||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||
// ignores wheel reports); older versions DO capture wheel as option
|
||||
// navigation, so they keep the local wheel.
|
||||
// Shift+wheel always scrolls xterm's local scrollback (Codeman's restored
|
||||
// history lives there), and once the viewport left the bottom the wheel
|
||||
// stays local until the user scrolls back down — so both scrollbacks stay
|
||||
// reachable without a mode switch.
|
||||
container.addEventListener(
|
||||
'wheel',
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||
if (this._shouldForwardWheelToApp(ev)) {
|
||||
this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines);
|
||||
return;
|
||||
}
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
},
|
||||
@@ -482,6 +496,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (touch && mouseTrackingOn) {
|
||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||
} else if (touch && this._sessionUsesServerMouseStrip()) {
|
||||
// The server strips mouse-tracking DECSETs from claude/codex/gemini
|
||||
// output (isAltScreenStripMode, session.ts) so the wheel keeps
|
||||
// scrolling scrollback — which leaves THIS xterm permanently at
|
||||
// mouseTrackingMode 'none' even though the TUI on the PTY side has
|
||||
// tracking ON and still understands SGR reports. Encode the report
|
||||
// ourselves and send it straight to the PTY: no DOM click is
|
||||
// dispatched, so xterm's local selection can't trigger either.
|
||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||
}
|
||||
this._syncMobileHelperTextareaToCursor();
|
||||
// Route subsequent typing to the right place: keep the CJK input
|
||||
@@ -508,6 +531,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Desktop click-to-position cursor ──────────────────────────────
|
||||
// A real mouse click normally reaches the PTY through xterm's own mouse
|
||||
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
|
||||
// the server strips the enabling DECSETs from claude/codex/gemini output
|
||||
// (isAltScreenStripMode, session.ts) so the wheel keeps scrolling
|
||||
// scrollback. Desktop clicks therefore stopped reporting entirely (the
|
||||
// same breakage the mobile touchend tap branch above works around).
|
||||
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
||||
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
|
||||
|
||||
// Welcome message
|
||||
this.showWelcome();
|
||||
|
||||
@@ -951,6 +984,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -989,6 +1028,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
activate(event, text) {
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2128,6 +2173,135 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Mirror of the server's isAltScreenStripMode (session.ts): session modes whose
|
||||
// output stream has mouse-tracking DECSET sequences stripped before reaching the
|
||||
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
|
||||
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
|
||||
_sessionUsesServerMouseStrip() {
|
||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||
return mode === 'claude' || mode === 'codex' || mode === 'gemini';
|
||||
},
|
||||
|
||||
// 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;
|
||||
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;
|
||||
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));
|
||||
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);
|
||||
if (!pos) return;
|
||||
this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
|
||||
},
|
||||
|
||||
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
|
||||
// server, delivered via session:cliInfo / SessionState.cliVersion) is known
|
||||
// AND >= the minimum. Unknown or unparseable versions return false so
|
||||
// callers keep the conservative behavior.
|
||||
_cliVersionAtLeast(version, minimum) {
|
||||
if (typeof version !== 'string') return false;
|
||||
const parts = version.trim().replace(/^v/, '').split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return false;
|
||||
const min = minimum.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (parts[i] !== min[i]) return parts[i] > min[i];
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports: codex, or claude 2.1.187+
|
||||
// (older Claude Code captures wheel as select-menu option navigation; an
|
||||
// unknown version is treated as older). Gemini is a strip mode too but its
|
||||
// wheel behavior is unverified, so it keeps the local wheel — taps/clicks
|
||||
// are still forwarded for it (harmless no-ops at worst).
|
||||
_shouldForwardWheelToApp(ev) {
|
||||
if (ev.shiftKey) return false;
|
||||
const mode = this.terminal?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode === 'claude') {
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
} else if (sessionMode !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
return this._terminalViewportAtBottom();
|
||||
},
|
||||
|
||||
// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
|
||||
// cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits
|
||||
// dozens of wheel events per second and each _sendInputAsync becomes a tmux
|
||||
// send-keys on the server — unbatched, a single flick would spawn a process
|
||||
// storm. Per-event tick count is capped (Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top), and the queue is bounded so a wild
|
||||
// scroll can't build a backlog that keeps scrolling after the finger stops.
|
||||
_sendSyntheticSgrWheel(clientX, clientY, lines) {
|
||||
if (!this.activeSessionId || !lines) return;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return;
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), 5);
|
||||
const queued = this._wheelSgrQueue || '';
|
||||
if (queued.length > 512) return;
|
||||
this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
if (this._wheelSgrFlushTimer) return;
|
||||
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
|
||||
},
|
||||
|
||||
_flushWheelSgrQueue() {
|
||||
this._wheelSgrFlushTimer = null;
|
||||
const data = this._wheelSgrQueue;
|
||||
this._wheelSgrQueue = '';
|
||||
if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data);
|
||||
},
|
||||
|
||||
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
||||
// for a plain left-click when the server strips mouse DECSETs (see
|
||||
// _sessionUsesServerMouseStrip). Every skip below is a click that already has
|
||||
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
|
||||
// reported already), modified clicks (shift keeps xterm's selection
|
||||
// override), double/triple clicks (word/line selection), drag-selections,
|
||||
// clicks on hovered links (activate() already handles the click — a second
|
||||
// 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;
|
||||
if (ev.button !== 0 || ev.detail !== 1) return;
|
||||
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const mode = this.terminal.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return;
|
||||
if (!this._sessionUsesServerMouseStrip()) return;
|
||||
if (this.terminal.hasSelection?.()) return;
|
||||
if (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);
|
||||
},
|
||||
|
||||
_installMobileTapMouseGuard() {
|
||||
const el = this.terminal?.element;
|
||||
if (!el || el._codemanTapMouseGuardInstalled) return;
|
||||
|
||||
@@ -94,6 +94,308 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encodes a tap as an SGR press+release when the server strips mouse DECSETs (claude mode)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
||||
app._sendSyntheticSgrTap(171, 101);
|
||||
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
||||
});
|
||||
|
||||
it('clamps SGR tap coordinates to the terminal grid', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrTap(-50, 99999);
|
||||
|
||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||
});
|
||||
|
||||
it('does not treat shell sessions as server-mouse-strip mode', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
||||
});
|
||||
|
||||
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._handleDesktopTerminalClick({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 171,
|
||||
clientY: 101,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
});
|
||||
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
||||
});
|
||||
|
||||
it('desktop click: skips clicks that already have a meaning elsewhere', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
const terminal = () => ({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' } as { mouseTrackingMode: string },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
});
|
||||
const click = (overrides: Record<string, unknown> = {}) => ({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
app.terminal = terminal();
|
||||
app._handleDesktopTerminalClick(click({ isTrusted: false })); // synthetic
|
||||
app._handleDesktopTerminalClick(click({ button: 1 })); // middle button
|
||||
app._handleDesktopTerminalClick(click({ detail: 2 })); // double-click word select
|
||||
app._handleDesktopTerminalClick(click({ shiftKey: true })); // selection override
|
||||
app._handleDesktopTerminalClick(click({ target: { closest: () => null } })); // outside grid
|
||||
|
||||
app.terminal = { ...terminal(), hasSelection: () => true }; // drag-selection just ended
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
app.terminal = { ...terminal(), modes: { mouseTrackingMode: 'vt200' } }; // xterm encoder live
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
app.terminal = terminal();
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('desktop click: skips the click while a terminal link is hovered (activate() handles it)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
const click = {
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
};
|
||||
|
||||
app._linkHovered = true; // link provider hover() fired — this click opens the link
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
app._linkHovered = false; // leave() fired — plain clicks report again
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('desktop click: skips the compat click that follows a touch tap', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
const click = {
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
};
|
||||
|
||||
app._suppressTrustedTapMouseEvents(); // touchend just handled the tap
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
setNow(10_000); // window expired — a genuine mouse click reports again
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('tap: does nothing while the viewport is scrolled up into local scrollback', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
buffer: { active: { viewportY: 10, baseY: 50 } }, // scrolled up
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrTap(50, 50);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
app.terminal.buffer.active.viewportY = 50; // back at the bottom
|
||||
app._sendSyntheticSgrTap(50, 50);
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('wheel: forwards to the app only for verified sessions at the buffer bottom without Shift', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback
|
||||
|
||||
app.terminal.buffer.active.viewportY = 10; // browsing local scrollback
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
app.terminal.buffer.active.viewportY = 50;
|
||||
|
||||
app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
app.terminal.modes.mouseTrackingMode = 'none';
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
const withVersion = (cliVersion?: string) => {
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion }]]);
|
||||
return app._shouldForwardWheelToApp({ shiftKey: false });
|
||||
};
|
||||
|
||||
expect(withVersion(undefined)).toBe(false); // banner not parsed yet → assume older
|
||||
expect(withVersion('2.1.186')).toBe(false); // last version whose menus capture wheel
|
||||
expect(withVersion('2.1.187')).toBe(true); // first version verified safe
|
||||
expect(withVersion('2.2.0')).toBe(true);
|
||||
expect(withVersion('3.0.0')).toBe(true);
|
||||
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
|
||||
});
|
||||
|
||||
it('wheel: codex forwards without a version; gemini never forwards', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]); // verified TUI, no version gate
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up
|
||||
app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down
|
||||
expect(sent).toEqual([]); // nothing until the flush timer fires
|
||||
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]);
|
||||
|
||||
app._flushWheelSgrQueue(); // queue drained — no duplicate send
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows trusted mouse events after the tap window expires', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
|
||||
Reference in New Issue
Block a user