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

+1 -1
View File
@@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
+1 -1
View File
@@ -350,6 +350,6 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin
## GitHub Copilot CLI
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume goes through `copilotConfig.resumeSessionId` / `continueSession`. The tab names the model it reads from the footer (`modelDetect`). It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist.
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume goes through `copilotConfig.resumeSessionId` / `continueSession`. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist.
Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`.
+7 -1
View File
@@ -1749,7 +1749,13 @@ const COPILOT: CliEntry = {
},
capabilities: {
...agentDefaults(),
altScreen: 'strip-mux-only',
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
// selection and clicks still reach the CLI through the browser's hand-encoded tap
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
+1 -1
View File
@@ -525,7 +525,7 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
+6
View File
@@ -5852,6 +5852,12 @@ Object.assign(CodemanApp.prototype, {
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
const sessionMode = session?.mode || 'claude';
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude') return false;
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude is actually listening for the mouse. In its default
+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();