Files
Codeman/test/terminal-touch-tap.test.ts
T
liorandClaude Opus 5 35f8f9d19f fix(mobile): let a second tap on inert transcript close the keyboard
Every terminal tap re-focuses the hidden textarea, so once the on-screen keyboard
is open the only way to close it is the accessory bar's dismiss chevron. Tapping
the transcript to get the screen back is the obvious gesture and it did nothing.

A tap on INERT content with the keyboard already up now dismisses it. Nothing
else claims that gesture: an inert row has no action to trigger, so by that point
the tap has already done its only other job (the mouse report).

Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
focus-then-position, so a second tap there still places the caret — that is real
capability and trading it away would be a worse deal than the bug. A separate
test pins it rather than leaving it to the reader.

Actionable rows are unchanged: readbacks, "esc to interrupt" status rows and menu
selections still blur via _isActionableMobileTerminalTap, which runs first.

`keeps the hidden keyboard input focused after an inert Claude transcript tap`
asserted the OLD behaviour and is renamed and inverted, since revising that
behaviour is the point of this change. Its setup already focused the terminal
before tapping, so it was always exercising the second-tap case.

test/terminal-touch-tap.test.ts: 28 tests. The two new ones fail on master —
`closes the keyboard on a second tap of INERT transcript content` behaviourally,
by asserting blur where master re-focuses.

test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same five
pre-existing failures as master, untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:31:38 +03:00

704 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('closes the keyboard on a second tap of INERT transcript content', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
app._sendInputAsync = vi.fn();
// Keyboard DOWN: the tap opens it.
setActiveElement(null);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
// Keyboard UP on the same inert row: the tap closes it.
app.terminal.focus.mockClear();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
expect(app.terminal.focus).not.toHaveBeenCalled();
});
it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
// must still position the cursor. This is the guarantee that makes the
// change safe to make, so it is pinned separately.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
app._sendInputAsync = vi.fn();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
expect(app.terminal.textarea.blur).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();
});
});