Files
Codeman/test/terminal-shift-drag-selection.test.ts
d fei b3a6ba2eb6 feat(terminal): make Shift+drag select, and right-click copy the selection
In a native terminal running a TUI with mouse tracking on (claude, codex), Shift
is the "let me select text" modifier: it bypasses the application's mouse
reporting so the emulator selects locally. Users bring that habit here, where it
did nothing — measured, `hasSelection` was already false during a Shift+drag and
no clearSelection call ran at all, because there was never a selection to clear.

The mismatch is that the two Shifts mean different things. xterm reads Shift as
"force selection", but that path is only taken when the application really has
mouse tracking on. The server strips the mouse DECSETs for claude/codex/gemini
(isAltScreenStripMode), so xterm's mouseTrackingMode is permanently `none`, that
branch is unreachable, and Shift instead lands in _onIncrementalClick — which
EXTENDS an existing selection. Extension is a no-op while selectionStart is
empty, so the drag had no anchor.

So plant the anchor xterm is missing. The listener sits on the capture phase of
the `.xterm` root, an ancestor of the `.xterm-screen` that SelectionService binds
to, and therefore runs before xterm's own mousedown; xterm then extends from our
anchor and the drag behaves like any other. Length is 0 so a Shift+click without
a drag does not select a stray character. An existing selection is left alone —
that is a genuine extend gesture, and xterm handles it correctly.

Right-click copies the selection (the mintty/PuTTY convention), completing the
gesture: until now there was nowhere for a finished selection to go. With no
selection the native menu is not hijacked — taking it away while offering
nothing in return is a pure loss.

(cherry picked from commit 7ab5015737)
2026-09-14 23:56:19 +02:00

169 lines
6.9 KiB
TypeScript

/**
* Shift+drag must START a terminal selection, and right-click must copy it.
*
* Both halves come from one habit users bring from a native terminal running a
* mouse-tracking TUI (claude, codex): hold Shift to select locally, then
* right-click to copy. In Codeman both halves were broken, for two unrelated
* reasons, and the pair dead-ended the whole gesture.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadHarness() {
const CodemanApp = function CodemanApp(this: unknown) {};
const windowRef: Record<string, unknown> = {};
const context = vm.createContext({
window: windowRef,
document: { body: { classList: { contains: () => false } }, getElementById: () => null, activeElement: null },
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1000 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
Worker: function Worker(this: { postMessage: () => void }) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => false },
KeyboardHandler: {},
});
vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'), context);
vm.runInContext(readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'), context);
return new (CodemanApp as unknown as new () => Record<string, (...a: unknown[]) => unknown>)();
}
/** A terminal whose element records the capture-phase mousedown listener we install. */
function makeTerminal(opts: { hasSelection: boolean; viewportY: number }) {
const selectCalls: Array<[number, number, number]> = [];
let listener: ((ev: Record<string, unknown>) => void) | null = null;
const terminal = {
element: {
addEventListener: (type: string, fn: (ev: Record<string, unknown>) => void, capture: boolean) => {
// Capture on the `.xterm` root is what puts us ahead of xterm's own
// mousedown on the descendant `.xterm-screen`; a bubble-phase listener
// would run after SelectionService had already decided.
expect(type).toBe('mousedown');
expect(capture).toBe(true);
listener = fn;
},
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
},
cols: 80,
rows: 24,
buffer: { active: { viewportY: opts.viewportY } },
hasSelection: () => opts.hasSelection,
select: (c: number, r: number, l: number) => selectCalls.push([c, r, l]),
_core: { _renderService: { dimensions: { css: { cell: { width: 10, height: 20 } } } } },
};
return { terminal, selectCalls, fire: (ev: Record<string, unknown>) => listener?.(ev) };
}
const mousedown = (over: Record<string, unknown> = {}) => ({
isTrusted: true,
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
clientX: 35,
clientY: 50,
...over,
});
describe('Shift+drag starts a selection', () => {
it('plants the anchor xterm never sets, in 0-based ABSOLUTE buffer coordinates', () => {
// xterm reads Shift as "force selection" only while the app has mouse
// tracking on, and the server strips those DECSETs for claude/codex/gemini,
// so Shift instead reaches `_onIncrementalClick` — extend — which is a no-op
// with no selectionStart. The drag then anchors nothing and selects NOTHING.
const app = loadHarness();
const { terminal, selectCalls, fire } = makeTerminal({ hasSelection: false, viewportY: 120 });
app.terminal = terminal as never;
app._installShiftDragSelection();
fire(mousedown());
// clientX 35 / 10px cells -> viewport col 4 (1-based) -> column 3 (0-based).
// clientY 50 / 20px cells -> viewport row 3 (1-based) -> row 2, + viewportY.
expect(selectCalls).toEqual([[3, 2 + 120, 0]]);
});
it('uses length 0 so a Shift+CLICK that never drags selects nothing', () => {
const app = loadHarness();
const { terminal, selectCalls, fire } = makeTerminal({ hasSelection: false, viewportY: 0 });
app.terminal = terminal as never;
app._installShiftDragSelection();
fire(mousedown());
expect(selectCalls[0][2]).toBe(0);
});
it('leaves a real EXTEND gesture to xterm when a selection is already up', () => {
const app = loadHarness();
const { terminal, selectCalls, fire } = makeTerminal({ hasSelection: true, viewportY: 0 });
app.terminal = terminal as never;
app._installShiftDragSelection();
fire(mousedown());
expect(selectCalls).toEqual([]);
});
it('ignores everything that is not a trusted plain-Shift left press', () => {
const app = loadHarness();
const { terminal, selectCalls, fire } = makeTerminal({ hasSelection: false, viewportY: 0 });
app.terminal = terminal as never;
app._installShiftDragSelection();
for (const ev of [
mousedown({ shiftKey: false }),
mousedown({ button: 2 }),
mousedown({ isTrusted: false }),
mousedown({ ctrlKey: true }),
mousedown({ altKey: true }),
mousedown({ metaKey: true }),
]) {
fire(ev);
}
expect(selectCalls).toEqual([]);
});
it('installs exactly once per terminal element', () => {
const app = loadHarness();
const { terminal } = makeTerminal({ hasSelection: false, viewportY: 0 });
let installs = 0;
terminal.element.addEventListener = () => {
installs += 1;
};
app.terminal = terminal as never;
app._installShiftDragSelection();
app._installShiftDragSelection();
expect(installs).toBe(1);
});
});
describe('right-click copies the terminal selection', () => {
// The handler is created inline inside initTerminal (it closes over the
// long-press timer it shares with the touch path), so this pins the ordering
// in the shipped source: gesture suppression first, then the no-selection
// early return, and only then the copy.
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const handler = source.slice(
source.indexOf("container.addEventListener('contextmenu'"),
source.indexOf("container.addEventListener('touchcancel'")
);
it('suppresses the menu during a touch selection gesture and stops there', () => {
expect(handler).toMatch(/longPressTimer !== null[\s\S]*?ev\.preventDefault\(\);\s*return;/);
});
it('returns BEFORE preventDefault when nothing is selected, keeping the native menu', () => {
const noSelection = handler.indexOf('hasSelection');
const prevent = handler.lastIndexOf('ev.preventDefault()');
expect(noSelection).toBeGreaterThan(-1);
expect(noSelection).toBeLessThan(prevent);
});
it('copies through copyTerminalSelection rather than a second clipboard path', () => {
expect(handler).toContain('this.copyTerminalSelection(selection)');
});
});