fix(pi): forward the wheel from cliMouseTracking so scrolling survives replay

pi 1.4.0 is an alt-screen TUI that enables mouse tracking once at start.
Codeman's replay (tab switch, reload) drops that mode in xterm
(mouseTrackingMode none, baseY 0), so the wheel hit an empty local buffer
and stayed dead until a resize made pi redraw.

Same shape as copilot: altScreen strip-mux-and-mouse (the server records
the stripped DECSETs as cliMouseTracking, which survives the replay),
wheelForward always, transcriptPageKeys (PageUp/PageDown fallback for a
stale flag, measured on pi 1.4.0). _shouldForwardWheelToApp treats pi like
copilot. Registry pins and docs updated.

Tests: 2 new in terminal-scroll-routing (red before). Full run: 12989
passed, 5 red from the de_DE locale / XDG env (custom-model-run-menu-ui,
git-worktrees, edit-history-sync), all green under LANG=C.UTF-8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 53e4136d9f4401e9250ac6a122599ffcb2a57e8a)
This commit is contained in:
Randalix authored and Codeman maintainer committed 2026-11-05 23:35:14 +01:00
1 parent 46b85fa6d0
commit 58dae1e182
8 files changed
+52 -24

No files matched your search

+4 -4
View File
@@ -633,22 +633,22 @@ describe('capability shapes', () => {
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
// forward without a version gate, and so does pi (1.4.0 measured). Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// claude (repaint mode), codex, opencode, copilot and pi (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode', 'pi']);
});
});
+20
View File
@@ -898,6 +898,26 @@ describe('GitHub Copilot wheel forwarding', () => {
});
});
describe('pi wheel forwarding (1.4.0: alt-screen TUI with mouse tracking)', () => {
// pi enables mouse tracking once at start. Codeman's replay (tab switch, reload) drops that
// mode in xterm (`mouseTrackingMode` none, baseY 0), so the wheel hit an empty local buffer
// and went dead until a redraw. Like Copilot, the server strips the DECSETs and publishes
// `cliMouseTracking`; the gate forwards from that flag, which survives the replay.
it('forwards the wheel while pi tracks the mouse', () => {
const { app } = hollowApp({ mode: 'pi', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel local and falls back to paging when the flag is stale', () => {
expect(hollowApp({ mode: 'pi', cliMouseTracking: true }).app._shouldForwardWheelToApp({ shiftKey: true })).toBe(
false
);
const { app } = hollowApp({ mode: 'pi', cliMouseTracking: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._localScrollbackIsHollow()).toBe(true);
});
});
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();