mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
Addresses the review on #244. BLOCKING (item 1). selectSession() ends with scrollToLastNonEmptyLine(), which parks the viewport above the bottom for any session taller than the screen, so after a tab switch every tap classified as 'history' — touchstart ran preventDefault() + blur, and touchend's early return skipped focus. Both routes to focus closed on one gesture, the same mechanism as #173. Suppressing the mouse REPORT while scrolled up is right and is kept; suppressing FOCUS is not. touchstart now only preventDefaults 'content' taps (a scrolled-up viewport sends nothing, so there is no compatibility click worth cancelling), and the 'history' branch focuses instead of blurring. Verified against the maintainer's own test, which was already on master and red: `keeps the terminal input focusable after a tab switch parks the viewport off-bottom` fails without this change and passes with it. Item 2: dropped both `terminal-action-pending` guards. The class exists nowhere in the repo, so both branches were permanently false and the comment promised coverage that did not exist. Item 3: removed the `Working` literals. Live claude 2.1.226 prints "Cooked for 2m 6s" with a different bullet and a randomised verb, so they were dead code. The status row is matched by its affordance ("esc to interrupt") instead, which is what makes it actionable. The affordance regex is also tightened to require a key or gesture name, so prose like "click here to open the file" no longer dismisses the keyboard. Item 4: removed _shouldForwardTouchScrollToApp and its test. It was never called, and wiring it as written would have restricted forwarding to claude only, dropping gemini from the path #205 established — a behaviour change this PR has no reason to make. Smaller items: the touchstart classification is cached and reused for the touchend of the same gesture (keyed on exact coordinates, so a moved finger re-classifies), removing two of the three full-viewport scans per gesture; the duplicated touchLastX assignment is gone; and the no-touch bail-out returns null rather than claiming 'history'. test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same 5 pre-existing failures as master, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
667 lines
26 KiB
TypeScript
667 lines
26 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
function loadTerminalUiHarness() {
|
||
const CodemanApp = function CodemanApp(this: any) {};
|
||
let now = 1_000;
|
||
let keyboardVisible = false;
|
||
let activeElement: unknown = null;
|
||
const context = vm.createContext({
|
||
window: {},
|
||
document: {
|
||
body: { classList: { contains: () => false } },
|
||
get activeElement() {
|
||
return activeElement;
|
||
},
|
||
getElementById: () => null,
|
||
},
|
||
CodemanApp,
|
||
console: { warn: vi.fn(), log: vi.fn() },
|
||
_crashDiag: { log: vi.fn() },
|
||
performance: { now: () => now },
|
||
requestAnimationFrame: (_fn: () => void) => 1,
|
||
setTimeout: (_fn: () => void) => 1,
|
||
Blob: function Blob() {},
|
||
URL: {
|
||
createObjectURL: () => 'blob:yield',
|
||
revokeObjectURL: () => {},
|
||
},
|
||
Worker: function Worker(this: any) {
|
||
this.postMessage = () => {};
|
||
},
|
||
MobileDetection: {
|
||
isTouchDevice: () => true,
|
||
},
|
||
KeyboardHandler: {
|
||
get keyboardVisible() {
|
||
return keyboardVisible;
|
||
},
|
||
},
|
||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||
});
|
||
|
||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||
|
||
const app = new (CodemanApp as any)();
|
||
return {
|
||
app,
|
||
setNow: (value: number) => {
|
||
now = value;
|
||
},
|
||
setKeyboardVisible: (visible: boolean) => {
|
||
keyboardVisible = visible;
|
||
},
|
||
setActiveElement: (element: unknown) => {
|
||
activeElement = element;
|
||
},
|
||
};
|
||
}
|
||
|
||
function createElementHarness() {
|
||
const listeners = new Map<string, (ev: any) => void>();
|
||
return {
|
||
element: {
|
||
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
|
||
listeners.set(type, listener);
|
||
}),
|
||
},
|
||
dispatch(type: string, event: any) {
|
||
listeners.get(type)?.(event);
|
||
},
|
||
};
|
||
}
|
||
|
||
function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set<number>()) {
|
||
const textarea = {
|
||
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
|
||
blur: vi.fn(),
|
||
};
|
||
return {
|
||
cols: 80,
|
||
rows: lines.length,
|
||
modes: { mouseTrackingMode: 'none' },
|
||
buffer: {
|
||
active: {
|
||
viewportY: 0,
|
||
baseY: 0,
|
||
cursorY,
|
||
getLine: (row: number) =>
|
||
row >= 0 && row < lines.length
|
||
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
|
||
: undefined,
|
||
},
|
||
},
|
||
element: {
|
||
querySelector: (selector: string) =>
|
||
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
||
},
|
||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||
textarea,
|
||
focus: vi.fn(),
|
||
};
|
||
}
|
||
|
||
describe('terminal touch tap mouse guard', () => {
|
||
it('recognizes focus only when a terminal input owns the active element', () => {
|
||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||
const textarea = { classList: { contains: () => true } };
|
||
app.terminal = { textarea };
|
||
|
||
setActiveElement(null);
|
||
expect(app._isMobileTerminalInputFocused()).toBe(false);
|
||
|
||
setActiveElement(textarea);
|
||
expect(app._isMobileTerminalInputFocused()).toBe(true);
|
||
});
|
||
|
||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||
app.terminal = createTerminalGrid(
|
||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||
4
|
||
);
|
||
|
||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content'); // inline marker is not a prompt
|
||
expect(app._classifyMobileTerminalTap(9, 17)).toBe('content'); // row 2: readback
|
||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('input'); // row 5: prompt
|
||
expect(app._classifyMobileTerminalTap(9, 81)).toBe('content'); // row 6: status
|
||
});
|
||
|
||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||
app.terminal = createTerminalGrid(
|
||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||
4,
|
||
new Set([4])
|
||
);
|
||
|
||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('content');
|
||
});
|
||
|
||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||
|
||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content');
|
||
});
|
||
|
||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||
|
||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||
expect(app._classifyMobileTerminalTap(9, 49)).toBe('content');
|
||
});
|
||
|
||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||
app.terminal = createTerminalGrid(
|
||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||
4
|
||
);
|
||
app._sendInputAsync = vi.fn();
|
||
setActiveElement(app.terminal.textarea);
|
||
|
||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 17 }, true)).toBe('content');
|
||
expect(app._sendInputAsync).toHaveBeenCalledWith('sess-1', '\x1b[<0;2;2M\x1b[<0;2;2m');
|
||
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
|
||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('keeps the first prompt tap focus-only so it cannot activate a CLI row', () => {
|
||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||
app.terminal = createTerminalGrid(
|
||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||
4
|
||
);
|
||
app._sendInputAsync = vi.fn();
|
||
setActiveElement(null);
|
||
|
||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, false)).toBe('input');
|
||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
const { element, dispatch } = createElementHarness();
|
||
app.terminal = { element };
|
||
|
||
app._installMobileTapMouseGuard();
|
||
app._suppressTrustedTapMouseEvents();
|
||
|
||
const event = {
|
||
isTrusted: true,
|
||
preventDefault: vi.fn(),
|
||
stopImmediatePropagation: vi.fn(),
|
||
};
|
||
dispatch('mousedown', event);
|
||
|
||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('allows the app synthetic mouse event through the tap window', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
const { element, dispatch } = createElementHarness();
|
||
app.terminal = { element };
|
||
|
||
app._installMobileTapMouseGuard();
|
||
app._suppressTrustedTapMouseEvents();
|
||
|
||
const event = {
|
||
isTrusted: false,
|
||
preventDefault: vi.fn(),
|
||
stopImmediatePropagation: vi.fn(),
|
||
};
|
||
dispatch('mousedown', event);
|
||
|
||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||
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 for verified sessions without Shift, at ANY scroll position', () => {
|
||
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
|
||
|
||
// Scrolled up into local scrollback still forwards. Gating this on the
|
||
// viewport being at the bottom is what let a repaint-mode CLI's own prompt
|
||
// box scroll off the screen: scrollToLastNonEmptyLine() parks the viewport
|
||
// above the bottom, so a tab switch silently pinned the wheel to local
|
||
// scrollback full of stale replayed frames. The wheel handler snaps the
|
||
// viewport back to the bottom before encoding the report instead.
|
||
app.terminal.buffer.active.viewportY = 10;
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||
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: converts deltaMode line/page units instead of assuming pixels', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.terminal = { rows: 40 };
|
||
|
||
// DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch.
|
||
expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4);
|
||
// DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as
|
||
// pixels this rounded to 0 and fell through to the ±1 fallback.
|
||
expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
|
||
expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
|
||
// DOM_DELTA_PAGE: one page is one screenful.
|
||
expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
|
||
// A pure horizontal swipe must not fall through to a phantom -1.
|
||
expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
|
||
// Shift + macOS trackpad reports the magnitude on deltaX (issue #154).
|
||
expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
|
||
});
|
||
|
||
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: only claude forwards — codex and gemini keep the local wheel', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
app.activeSessionId = 'sess-1';
|
||
app.terminal = {
|
||
modes: { mouseTrackingMode: 'none' },
|
||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||
};
|
||
|
||
// Codex used to forward unconditionally, which is PR #227's regression: measured
|
||
// on codex-cli 0.147.0, it never enables mouse tracking and ignores SGR wheel
|
||
// reports outright, so forwarding ate every tick while its real local scrollback
|
||
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
|
||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
|
||
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
});
|
||
|
||
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
|
||
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 } },
|
||
};
|
||
|
||
// Default (setting absent) forwards the plain wheel to the CLI transcript.
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||
|
||
// Opt-out ON → the plain wheel stays on xterm's own scrollback (pre-#144).
|
||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||
|
||
// OFF again → forwarding resumes.
|
||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||
});
|
||
|
||
it('wheel: reads the dominant axis under Shift so a macOS trackpad can page scrollback (issue #154)', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
|
||
// Plain vertical wheel: unchanged, driven by deltaY.
|
||
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
|
||
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
|
||
|
||
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
|
||
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
|
||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
|
||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
|
||
|
||
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
|
||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
|
||
|
||
// Sub-25px delta still nudges one line in the gesture's direction.
|
||
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
|
||
});
|
||
|
||
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' }]]);
|
||
// Wheel reports flush via the ephemeral (fire-and-forget) path, not the
|
||
// durable queue — so they never show in the pending-bytes indicator (#154).
|
||
app._sendInputEphemeral = (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('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => {
|
||
const { app } = loadTerminalUiHarness();
|
||
const sent: Array<{ id: string; data: string }> = [];
|
||
app.activeSessionId = 'sess-1';
|
||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||
const scrolledToBottom: boolean[] = [];
|
||
app.terminal = {
|
||
cols: 80,
|
||
rows: 24,
|
||
// Scrolled up into local scrollback: SGR coordinates address the LIVE
|
||
// screen, so the report would hit-test the wrong row without the snap.
|
||
buffer: { active: { viewportY: 10, baseY: 50 } },
|
||
scrollToBottom: () => scrolledToBottom.push(true),
|
||
element: {
|
||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||
},
|
||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||
};
|
||
|
||
app._forwardScrollToApp(50, 50, -3);
|
||
expect(scrolledToBottom).toEqual([true]);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(3) }]);
|
||
|
||
// Already at the bottom: no snap, just the report.
|
||
app.terminal.buffer.active.viewportY = 50;
|
||
app._forwardScrollToApp(50, 50, 2);
|
||
expect(scrolledToBottom).toHaveLength(1);
|
||
app._flushWheelSgrQueue();
|
||
expect(sent).toHaveLength(2);
|
||
});
|
||
|
||
it('allows trusted mouse events after the tap window expires', () => {
|
||
const { app, setNow } = loadTerminalUiHarness();
|
||
const { element, dispatch } = createElementHarness();
|
||
app.terminal = { element };
|
||
|
||
app._installMobileTapMouseGuard();
|
||
app._suppressTrustedTapMouseEvents();
|
||
setNow(2_000);
|
||
|
||
const event = {
|
||
isTrusted: true,
|
||
preventDefault: vi.fn(),
|
||
stopImmediatePropagation: vi.fn(),
|
||
};
|
||
dispatch('mousedown', event);
|
||
|
||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||
});
|
||
});
|