mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #325 from Ark0N/feat/auto-copy-selection
feat(terminal): Auto Copy, put a finished selection on the clipboard
This commit is contained in:
@@ -147,3 +147,94 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whatever the strip removes, the server has to remember, because after it runs
|
||||
* nothing downstream can ever see it. The browser hand-encodes click reports for
|
||||
* these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that
|
||||
* on EVERY click, delivering mouse reports to a CLI that never asked for them.
|
||||
*/
|
||||
describe('stripped mouse-tracking state', () => {
|
||||
const trackingOf = (session: Session) => session.toState().cliMouseTracking;
|
||||
|
||||
it('starts off, and stays off for output that never enables tracking', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
|
||||
handleOutput(session, 'plain output\x1b[?1049h\x1b[3J');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows the CLI enabling and disabling a tracking mode', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002hdialog');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002ldismissed');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => {
|
||||
// 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put
|
||||
// the stray reports straight back: a CLI can select SGR encoding without ever
|
||||
// asking to be told where the user clicked.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on until the LAST tracking mode goes away', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
// A TUI may disable a mode it never enabled; that must not clear the rest.
|
||||
handleOutput(session, '\x1b[?1003l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1000l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002l');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits only on a real transition, so a repainting TUI costs nothing', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h');
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
});
|
||||
|
||||
it('sees a sequence split across PTY chunks, like the strip that carries it', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, 'before\x1b[?100');
|
||||
handleOutput(session, '2h after');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before after');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks nothing for a mode whose DECSETs are never stripped', () => {
|
||||
// shell keeps its mouse DECSETs, so xterm sees them and owns the reporting.
|
||||
// A flag set here would mean a SECOND, hand-encoded report on every click.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[?1002hhtop');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Auto Copy (copy-on-select) guards.
|
||||
*
|
||||
* The feature is invisible when it works, so every bug in it is silent: a
|
||||
* clipboard that quietly stops updating, or one that quietly overwrites itself
|
||||
* on an unrelated click. These tests drive the SHIPPED decision helper
|
||||
* (constants.js) and the SHIPPED flush (terminal-ui.js), plus the settings
|
||||
* wiring that decides whether the toggle reaches the code at all.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
function loadHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const windowRef: Record<string, any> = {};
|
||||
const documentListeners = new Map<string, ((ev: any) => void)[]>();
|
||||
let activeElement: any = null;
|
||||
let now = 1_000;
|
||||
|
||||
const RealDate = Date;
|
||||
const DateStub: any = function DateStub(this: any, ...args: any[]) {
|
||||
return new (RealDate as any)(...args);
|
||||
};
|
||||
DateStub.now = () => now;
|
||||
|
||||
const context = vm.createContext({
|
||||
window: windowRef,
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
getElementById: () => null,
|
||||
addEventListener: (type: string, listener: (ev: any) => void) => {
|
||||
const list = documentListeners.get(type) ?? [];
|
||||
list.push(listener);
|
||||
documentListeners.set(type, list);
|
||||
},
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
Date: DateStub,
|
||||
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: () => false },
|
||||
KeyboardHandler: { keyboardVisible: false },
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
const toasts: { message: string; type: string }[] = [];
|
||||
app.showToast = (message: string, type: string) => toasts.push({ message, type });
|
||||
app._copyText = vi.fn(async () => true);
|
||||
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
|
||||
app.terminal = {
|
||||
hasSelection: () => true,
|
||||
getSelection: () => 'copied text',
|
||||
clearSelection: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
windowRef,
|
||||
toasts,
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
setActiveElement: (element: any) => {
|
||||
activeElement = element;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('decideAutoCopy', () => {
|
||||
const decide = (params: Record<string, unknown>) => loadHarness().windowRef.CodemanAutoCopy.decide(params);
|
||||
|
||||
it('does nothing while the setting is off', () => {
|
||||
expect(decide({ enabled: false, text: 'hello', pending: true })).toBe('skip');
|
||||
});
|
||||
|
||||
it('ignores a blank or whitespace-only selection', () => {
|
||||
// A drag across empty cells; a wall of spaces is never what the gesture meant.
|
||||
expect(decide({ enabled: true, text: ' \n ', pending: true })).toBe('skip');
|
||||
expect(decide({ enabled: true, text: '', pending: true })).toBe('skip');
|
||||
});
|
||||
|
||||
it('copies a genuine selection change even when the text repeats', () => {
|
||||
// Re-selecting the same text after copying something else in between is a
|
||||
// deliberate act, so `pending` outranks the text dedupe.
|
||||
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: true })).toBe('copy');
|
||||
});
|
||||
|
||||
it('copies changed text when onSelectionChange has not fired yet', () => {
|
||||
// xterm fires onSelectionChange from its OWN document mouseup handler, and
|
||||
// listener order between the two is not something the app controls, so the
|
||||
// first copy of a drag must not depend on `pending`.
|
||||
expect(decide({ enabled: true, text: 'fresh', lastCopied: 'stale', pending: false })).toBe('copy');
|
||||
});
|
||||
|
||||
it('leaves an unchanged selection alone on an unrelated mouseup', () => {
|
||||
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: false })).toBe('skip');
|
||||
});
|
||||
|
||||
it('refuses a selection past the size cap instead of truncating it', () => {
|
||||
const { windowRef } = loadHarness();
|
||||
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||
expect(windowRef.CodemanAutoCopy.decide({ enabled: true, text: huge, pending: true })).toBe('too-large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_flushAutoCopySelection', () => {
|
||||
it('copies the selection without clearing it or stealing focus', async () => {
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledWith('copied text');
|
||||
// The two things copyTerminalSelection does and this path must not: clearing
|
||||
// makes the text vanish under the cursor that highlighted it, and focusing
|
||||
// opens the on-screen keyboard over it on a phone.
|
||||
expect(app.terminal.clearSelection).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the copy synchronously, inside the gesture that triggered it', () => {
|
||||
// Both clipboard paths need user activation: Firefox gates
|
||||
// navigator.clipboard.writeText on it, and execCommand('copy') (the
|
||||
// plain-HTTP fallback) must run in the gesture's own task.
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
void app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays silent while the setting is off', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
expect(toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a missing setting as off', async () => {
|
||||
const { app } = loadHarness();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces itself once per page load, then goes quiet', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
let text = 'first';
|
||||
app.terminal.getSelection = () => text;
|
||||
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
text = 'second';
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||
expect(toasts).toEqual([{ message: 'Auto Copy: selection copied', type: 'success' }]);
|
||||
});
|
||||
|
||||
it('disarms the pending flag so the next unrelated mouseup copies nothing', async () => {
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._autoCopyPending).toBe(false);
|
||||
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands focus back when the execCommand fallback took it', async () => {
|
||||
const { app, setActiveElement } = loadHarness();
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
setActiveElement(terminalTextarea);
|
||||
// The fallback appends a temp textarea, selects it, then removes it, which
|
||||
// leaves the document with no focused element at all.
|
||||
app._copyText = vi.fn(async () => {
|
||||
setActiveElement(null);
|
||||
return true;
|
||||
});
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves focus alone when the clipboard API never moved it', async () => {
|
||||
const { app, setActiveElement } = loadHarness();
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
setActiveElement(terminalTextarea);
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a blocked clipboard and lets the next gesture retry the same text', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
app._copyText = vi.fn(async () => false);
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toEqual([{ message: 'Auto Copy failed: the browser blocked clipboard access', type: 'error' }]);
|
||||
|
||||
// Without the reset, the dedupe would swallow every retry of the same text.
|
||||
app._autoCopyPending = false;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throttles the failure toast so a blocked clipboard cannot spam every drag', async () => {
|
||||
const { app, toasts, setNow } = loadHarness();
|
||||
app._copyText = vi.fn(async () => false);
|
||||
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
setNow(2_000);
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toHaveLength(1);
|
||||
|
||||
setNow(1_000 + 10_001);
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses an oversized selection and says how to copy it anyway', async () => {
|
||||
const { app, toasts, windowRef } = loadHarness();
|
||||
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||
app.terminal.getSelection = () => huge;
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
expect(toasts).toEqual([{ message: 'Selection too large to copy automatically. Press Ctrl+C.', type: 'warning' }]);
|
||||
});
|
||||
|
||||
it('does nothing when the selection was already dropped', async () => {
|
||||
const { app } = loadHarness();
|
||||
app.terminal.hasSelection = () => false;
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Copy wiring', () => {
|
||||
const terminalUi = read('terminal-ui.js');
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const html = read('index.html');
|
||||
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
|
||||
|
||||
it('arms on a selection change and disarms when the selection is dropped', () => {
|
||||
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = terminalUi.slice(start, start + 1200);
|
||||
expect(body).toContain('this._autoCopyPending = false;');
|
||||
expect(body).toContain('this._autoCopyPending = true;');
|
||||
});
|
||||
|
||||
it('flushes from the document mouseup, not from the selection change', () => {
|
||||
// Copying inside onSelectionChange would be one clipboard write per cell the
|
||||
// drag crosses.
|
||||
expect(terminalUi).toContain("document.addEventListener('mouseup', () => this._flushAutoCopySelection());");
|
||||
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||
const body = terminalUi.slice(start, terminalUi.indexOf('_autoCopyListenerInstalled', start));
|
||||
expect(body).not.toContain('_flushAutoCopySelection');
|
||||
});
|
||||
|
||||
it('flushes from the touch gesture end, which never produces a mouseup', () => {
|
||||
// The touch path preventDefaults its touchend to stop the compat mouse pair
|
||||
// from stealing the selection back, so phones need their own call.
|
||||
const start = terminalUi.indexOf('_endTouchSelectionGesture() {');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(terminalUi.slice(start, start + 900)).toContain('this._flushAutoCopySelection();');
|
||||
});
|
||||
|
||||
it('keeps the toggle per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'autoCopySelection'");
|
||||
// SettingsUpdateSchema is .strict(), so a key it does not declare 400s the
|
||||
// whole settings PUT if the client sends it.
|
||||
expect(settingsUi).toContain('autoCopySelection: _acs,');
|
||||
expect(schemas).not.toContain('autoCopySelection');
|
||||
});
|
||||
|
||||
it('keeps the control loadable and savable by id', () => {
|
||||
expect(html).toContain('id="appSettingsAutoCopySelection"');
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;"
|
||||
);
|
||||
expect(settingsUi).toContain("autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,");
|
||||
});
|
||||
});
|
||||
@@ -140,7 +140,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -155,7 +155,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||
4,
|
||||
@@ -168,7 +168,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||
@@ -178,7 +178,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||
@@ -193,7 +193,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
// commits an answer) as the only thing a phone could do.
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
||||
2
|
||||
@@ -219,7 +219,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -252,7 +252,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
||||
app._sendInputAsync = vi.fn();
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -339,7 +339,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
||||
app._sendSyntheticSgrTap(171, 101);
|
||||
|
||||
@@ -366,19 +366,60 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||
});
|
||||
|
||||
it('does not treat shell sessions as server-mouse-strip mode', () => {
|
||||
it('never hand-reports for a shell session, even with tracking somehow set', () => {
|
||||
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns
|
||||
// the mouse there and a second, hand-encoded report would double-report.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
});
|
||||
|
||||
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
||||
// The server strips the DECSETs, so xterm can never see them and the browser
|
||||
// reported EVERY click. A claude pane sitting at its composer, or one that
|
||||
// fell back to a shell prompt, was receiving mouse reports it never asked
|
||||
// for; a shell prints those as literal text and they garble the next line.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: false }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
});
|
||||
|
||||
it('desktop click: sends nothing while the CLI has no mouse mode on', () => {
|
||||
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 = createTerminalGrid(['some output', '', '\u276f '], 2);
|
||||
app._linkHovered = false;
|
||||
|
||||
app._handleDesktopTerminalClick({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 40,
|
||||
clientY: 10,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
});
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
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.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -453,7 +494,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -487,7 +528,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -771,7 +812,7 @@ describe('terminal link tap', () => {
|
||||
const { app, windowRef } = harness;
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = createTerminalGrid(lines, cursorY);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
@@ -802,7 +843,7 @@ describe('terminal link tap', () => {
|
||||
const line = 'see https://example.com/x for more';
|
||||
const { app, sent } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user