fix(copilot): keep a drag a local selection so Auto Copy works

Copilot's TUI turns mouse tracking on itself. Under `strip-mux-only` those DECSETs reached
xterm, which then reported a plain drag to the TUI instead of selecting (only Shift+drag
selected), so Auto Copy and copy-on-select did nothing. This is the failure opencode had and
`strip-mux-and-mouse` was added for: the DECSETs are stripped, a drag is a local selection and
clicks still reach the CLI through the browser's hand-encoded tap (`cliMouseTracking`).

Stripping them also stops xterm from encoding the wheel for the CLI, so
`_shouldForwardWheelToApp` now forwards it as SGR reports while Copilot is tracking the mouse
(the same bytes xterm sent natively, which is what scrolling was verified against). Shift+wheel
stays on local scrollback and a stale flag falls back to PageUp/PageDown.

Tests: the strip flavor for copilot (stripped DECSETs, 3J kept, cliMouseTracking, direct-PTY
untouched) and the wheel gate (forwards while tracking, no other non-claude mode changes).
This commit is contained in:
Devvyn committed 2026-10-18 00:47:47 +08:00
1 parent dc87ef724c
commit d732b46797
7 files changed
+72 -5

No files matched your search

+33 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -366,3 +366,35 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
}
}
});
/**
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
});
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
});
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY copilot pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
+23
View File
@@ -707,6 +707,29 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
});
});
describe('GitHub Copilot wheel forwarding', () => {
// Copilot's mouse DECSETs are stripped so a drag selects locally, which stops xterm from
// encoding the wheel for it; the gate must therefore forward by hand, but only while tracking.
it('forwards the wheel while the CLI tracks the mouse, with no version gate', () => {
const { app } = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel on local scrollback and falls back to paging when not tracking', () => {
const tracking = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(tracking.app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
const idle = hollowApp({ mode: 'copilot', cliMouseTracking: false });
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('does not change what other non-claude modes do', () => {
for (const mode of ['codex', 'opencode', 'grok', 'gemini']) {
const { app } = hollowApp({ mode, cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
}
});
});
describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();