diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 5c367cab8ac..efa3c97121f 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -29,6 +29,8 @@ vi.mock('../hooks/useMouseEvents.js', () => ({ useMouseEvents: vi.fn(), })); +import { useMouseEvents } from '../hooks/useMouseEvents.js'; + import { toggleKeyHint } from './messages/ConversationMessages.js'; describe('', () => { @@ -539,4 +541,40 @@ describe('', () => { expect(lastFrame()).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); }); }); + + describe('thinking-block mouse tracking is VP-gated (non-VP scroll fix)', () => { + // A collapsed thinking block arms a click-to-expand mouse handler. The + // VP-gating itself lives in useMouseEvents (covered by its own test); here + // we pin the contract that the thinking block subscribes WITHOUT + // `bypassVpGate`, i.e. it is subject to the gate — so in non-VP it never + // turns on SGR mouse tracking and native terminal scrollback survives. + // (Alt+T still expands the block in non-VP.) + const thoughtItem: HistoryItem = { + id: 1, + type: 'gemini_thought', + text: 'Inspecting the repository', + durationMs: 1200, + }; + + it('subscribes the click handler without bypassVpGate (stays VP-gated)', () => { + vi.mocked(useMouseEvents).mockClear(); + renderWithProviders( + + + , + ); + expect(vi.mocked(useMouseEvents)).toHaveBeenCalled(); + const opts = vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]; + // Collapsed thought → the handler is "active", but it must NOT bypass the + // VP gate, so useMouseEvents only arms it in VP mode. + expect(opts?.isActive).toBe(true); + expect(opts?.bypassVpGate ?? false).toBe(false); + }); + }); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 710558e99ef..1b7b04a64f5 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -108,6 +108,10 @@ const ClickableThinkMessage: React.FC<{ }) => { const ref = useRef(null); const { openThinkingViewer } = useThinkingViewer(); + // Click-to-expand needs SGR mouse tracking. We do NOT pass `bypassVpGate`, so + // useMouseEvents enables it only in VP mode; in non-VP the click handler + // stays dormant and native terminal scrollback is preserved (the block still + // expands via Alt+T — the "option+t to expand" affordance it already shows). const isActive = !isPending && !expanded; const sanitizedViewerText = useMemo( () => escapeAnsiCtrlCodes(viewerText), diff --git a/packages/cli/src/ui/components/ThinkingViewer.test.tsx b/packages/cli/src/ui/components/ThinkingViewer.test.tsx new file mode 100644 index 00000000000..4b45d415ffd --- /dev/null +++ b/packages/cli/src/ui/components/ThinkingViewer.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { ThinkingViewer } from './ThinkingViewer.js'; +import { useMouseEvents } from './../hooks/useMouseEvents.js'; + +// The modal viewer owns the wheel for its own scrolling and renders on the +// alternate screen in non-VP mode (no native scrollback to protect), so it must +// subscribe WITH `bypassVpGate: true`. Mirror of the HistoryItemDisplay test +// that pins the OPPOSITE contract for the click-to-expand handler. The gating +// logic itself lives in useMouseEvents (covered by its own test); here we only +// pin the option ThinkingViewer passes. +vi.mock('./../hooks/useMouseEvents.js', () => ({ + useMouseEvents: vi.fn(), +})); + +// Avoid the real terminal-size / keypress / frame-flush wiring — they are not +// part of the contract under test and would otherwise require a TTY + context. +vi.mock('./../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ rows: 24, columns: 80 }), +})); +vi.mock('./../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); +vi.mock('./../hooks/use-frame-coalesced-flush.js', () => ({ + useFrameCoalescedFlush: () => ({ schedule: vi.fn(), cancel: vi.fn() }), +})); + +describe('ThinkingViewer mouse tracking', () => { + beforeEach(() => { + vi.mocked(useMouseEvents).mockClear(); + }); + + it('subscribes the wheel handler WITH bypassVpGate (works in non-VP)', () => { + render( + {}} + useAlternateScreen={false} + />, + ); + expect(vi.mocked(useMouseEvents)).toHaveBeenCalled(); + const opts = vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]; + expect(opts?.isActive).toBe(true); + // Must bypass the VP gate, or wheel scrolling in the modal silently breaks + // in non-VP mode. + expect(opts?.bypassVpGate).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/components/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx index 99bab664002..1eb35b9bf61 100644 --- a/packages/cli/src/ui/components/ThinkingViewer.tsx +++ b/packages/cli/src/ui/components/ThinkingViewer.tsx @@ -118,7 +118,10 @@ export const ThinkingViewer: FC = ({ }, [scheduleWheelFlush], ), - { isActive: true }, + // Modal viewer renders on the alternate screen in non-VP mode (no native + // scrollback to protect), and owns the wheel for its own scrolling — opt + // out of the VP gate so it works in both VP and non-VP. + { isActive: true, bypassVpGate: true }, ); const title = diff --git a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.test.tsx b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.test.tsx index a415a66b0fa..84334a66677 100644 --- a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.test.tsx @@ -260,4 +260,107 @@ describe('', () => { // Tally counts only the agent. expect(frame).toContain('0/1 done'); }); + + describe('height backstop (availableTerminalHeight)', () => { + const manyAgents = (n: number): IndividualToolCallDisplay[] => + Array.from({ length: n }, (_, i) => + agentToolCall({ + callId: `cap-${i}`, + subagentName: 'general-purpose', + taskDescription: `CapAgent ${i}`, + status: 'completed', + }), + ); + + const renderCapped = ( + toolCalls: IndividualToolCallDisplay[], + availableTerminalHeight?: number, + ) => + render( + + + , + ); + + it('without a budget, renders every agent (no cap)', () => { + const { lastFrame } = renderCapped(manyAgents(10)); + const frame = lastFrame() ?? ''; + // header + 10 rows, no overflow indicator. + expect(frame.split('\n').length).toBe(11); + expect(frame).not.toContain('more agent'); + }); + + it('at the exact-fit budget, renders every agent (backstop is a no-op)', () => { + // Budget = rows.length + 1 = 11 pins the `>` boundary precisely: + // 11 > 11 = false → correct, no windowing (renders all 10 + header) + // 11 >= 11 = true → a regression to `>=` would fire windowing and emit + // a "+1 more" indicator even though everything fits. + // The `not.toContain('more agent')` assertion is what catches that flip + // (the line count alone coincides at 11 in both branches). + const { lastFrame } = renderCapped(manyAgents(10), 11); + const frame = lastFrame() ?? ''; + expect(frame.split('\n').length).toBe(11); + expect(frame).not.toContain('more agent'); + }); + + it('with a budget, windows to the most recent rows + "+N more" and never exceeds the budget', () => { + const budget = 6; + const { lastFrame } = renderCapped(manyAgents(10), budget); + const frame = lastFrame() ?? ''; + // The whole non-Static frame must fit the budget, else ink clears the + // terminal every repaint (the scroll snap-back this guards against). + expect(frame.split('\n').length).toBeLessThanOrEqual(budget); + // rowBudget = budget - 2 = 4 visible rows → 6 hidden. + expect(frame).toContain('+6 more agents'); + // Windows from the END (most recent), so the last agent shows and the + // first does not. + expect(frame).toContain('CapAgent 9'); + expect(frame).not.toContain('CapAgent 0'); + // Header tally still reflects the full count. + expect(frame).toContain('10/10 done'); + }); + + it('at a budget of 3, shows exactly one (most-recent) row + indicator', () => { + // The transition point: budget 2 shows zero data rows, budget 3 shows the + // first one (rowsFit = budget - 2 = 1). Pins the off-by-one in + // `rowsFit = availableTerminalHeight - 2` and in `rows.slice(...)`. + const budget = 3; + const { lastFrame } = renderCapped(manyAgents(10), budget); + const frame = lastFrame() ?? ''; + expect(frame.split('\n').length).toBe(budget); + expect(frame).toContain('+9 more agents'); + // The single visible row is the most recent agent, not an older one. + expect(frame).toContain('CapAgent 9'); + expect(frame).not.toContain('CapAgent 0'); + expect(frame).toContain('10/10 done'); + }); + + it('at a budget of 2, keeps the header + indicator only (drops every row)', () => { + const budget = 2; + const { lastFrame } = renderCapped(manyAgents(10), budget); + const frame = lastFrame() ?? ''; + // header (1) + "+N more" indicator (1) = 2 rows, exactly the budget — no + // data row may slip in, or the frame overflows and snaps back. + expect(frame.split('\n').length).toBeLessThanOrEqual(budget); + expect(frame).toContain('+10 more agents'); + expect(frame).not.toContain('CapAgent'); + expect(frame).toContain('10/10 done'); + }); + + it('at a budget of 1, keeps only the header (drops rows and the indicator)', () => { + const budget = 1; + const { lastFrame } = renderCapped(manyAgents(10), budget); + const frame = lastFrame() ?? ''; + // Only the header fits; even the overflow indicator would overflow. + expect(frame.split('\n').length).toBeLessThanOrEqual(budget); + expect(frame).not.toContain('more agent'); + expect(frame).not.toContain('CapAgent'); + // The header label still carries the full tally. + expect(frame).toContain('10/10 done'); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx index 4ee0d4af4bf..e47cf84537e 100644 --- a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx +++ b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx @@ -12,11 +12,17 @@ * progress information into a count. * * Each row shows: status glyph · agent name · elapsed · tokens. - * Rendered in the committed phase only; during the live phase - * `LiveAgentPanel` below the composer owns the per-agent roster. - * Elapsed and token data fall back to - * `AgentResultDisplay.executionSummary` when the registry entry has - * been unregistered. + * Rendered in BOTH phases via `ToolGroupMessage`'s `inlineToolCalls` + * hand-off. During the live phase only terminal (completed / failed / + * cancelled) agents render here — running / background ones are owned by + * `LiveAgentPanel` below the composer — and an `availableTerminalHeight` + * windowing backstop caps the panel height so the non-`` live + * frame can't overflow and trigger ink's scroll snap-back. In the + * committed phase the full roster renders with no cap, as the persistent + * scrollback record. `totalAgentCount` keeps the header tally honest when + * the rendered rows are a live-phase subset. Elapsed and token data fall + * back to `AgentResultDisplay.executionSummary` when the registry entry + * has been unregistered. */ import type React from 'react'; @@ -43,6 +49,16 @@ interface InlineParallelAgentsDisplayProps { * defaults to the number of agent entries in `toolCalls`. */ totalAgentCount?: number; + /** + * Hard cap on the panel's rendered height (rows). The panel renders + * inside the non-`` live frame; if that frame exceeds the + * terminal height, ink clears the whole screen on every repaint + * (scroll snap-back / flicker — see ink `shouldClearTerminalForFrame`). + * When set, the agent list windows to the most recent rows that fit, + * leaving a "+N more" indicator. Omitted → no cap (committed phase, + * where the row already lives in ``). + */ + availableTerminalHeight?: number; } /** @@ -173,7 +189,12 @@ function truncateMiddle(input: string, max: number): string { export const InlineParallelAgentsDisplay: React.FC< InlineParallelAgentsDisplayProps -> = ({ toolCalls, contentWidth, totalAgentCount }) => { +> = ({ + toolCalls, + contentWidth, + totalAgentCount, + availableTerminalHeight, +}) => { const config = useContext(ConfigContext); // Static slice of agent calls for this group. The caller already @@ -263,20 +284,69 @@ export const InlineParallelAgentsDisplay: React.FC< const total = totalAgentCount ?? rows.length; const headerLabel = `Parallel agents · ${total} · ${doneCount}/${total} done`; + // Height backstop: the panel lives in the non-`` live frame, so its + // total height must stay within budget or ink clears the whole terminal on + // every repaint (scroll snap-back). The header always costs 1 row; when rows + // overflow, the "+N more" indicator costs another. Window to the most recent + // rows that still fit AFTER reserving those lines, so the rendered height + // (header + optional indicator + visibleRows) never exceeds the budget — even + // at degenerate budgets ≤ 2, where we drop all rows (and at a budget of 1 the + // indicator too, leaving just the header whose label still states the total). + // A budget ≤ 0 / undefined means "no cap" (committed phase — already in + // ``). + const hasBudget = + availableTerminalHeight != null && availableTerminalHeight > 0; + let overflowCount = 0; + let visibleRows = rows; + if (hasBudget && rows.length + 1 > availableTerminalHeight) { + // header (1) + all rows would exceed the budget → window. + if (availableTerminalHeight >= 2) { + // Reserve 1 row for the header and 1 for the "+N more" indicator; the + // remainder is for rows. + const rowsFit = availableTerminalHeight - 2; + overflowCount = rows.length - rowsFit; + visibleRows = rowsFit > 0 ? rows.slice(rows.length - rowsFit) : []; + } else { + // Budget of 1: only the header fits — drop every row and the indicator + // too. The header label still states the total agent count. + visibleRows = []; + } + } + return ( - + {/* truncate-end keeps the header to 1 line — the backstop budgets it as + one row; without it a narrow terminal would wrap it and overflow. */} + {headerLabel} - {rows.map((row) => ( + {overflowCount > 0 && ( + + {/* Likewise 1 line: the backstop reserves a single row for this. */} + + … +{overflowCount} more {overflowCount === 1 ? 'agent' : 'agents'} + + + )} + {/* INVARIANT: header (1) + optional overflow indicator (1) + each AgentRow + (1) must each render exactly 1 terminal line. The height backstop above + (rowsFit = budget - 2) counts one line apiece; if any grows to multiple + lines the frame would exceed the budget and re-trigger the + shouldClearTerminalForFrame snap-back this guards against. Enforced by + wrap="truncate-end" on every Text here and in AgentRow. */} + {visibleRows.map((row) => ( ))} ); }; +// INVARIANT: must render exactly ONE terminal line. The parent's height +// backstop (rowsFit = availableTerminalHeight - 2) assumes one line per row; +// all Text elements below use wrap="truncate-end" to hold that. Do not add +// wrapping/multi-line content here without revisiting that windowing math. const AgentRow: React.FC<{ row: RowData; now: number }> = ({ row, now }) => { const { glyph, color } = statusGlyph(row.status); const safeName = escapeAnsiCtrlCodes(row.name); @@ -301,7 +371,9 @@ const AgentRow: React.FC<{ row: RowData; now: number }> = ({ row, now }) => { return ( - {glyph} + + {glyph} + {displayName} @@ -312,7 +384,9 @@ const AgentRow: React.FC<{ row: RowData; now: number }> = ({ row, now }) => { - {trailing} + + {trailing} + ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 77ca16768e5..0d702d5c4f9 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -1143,4 +1143,150 @@ describe('', () => { expect(frame).not.toContain('Delegate task to subagent'); }); }); + + describe('Pure parallel agent group: LiveAgentPanel hand-off (dedup)', () => { + // Regression for the non-VP scroll snap-back (#5798): during the live + // phase the pure-parallel inline panel used the UNFILTERED toolCalls, + // so running subagents were rendered inline AND in LiveAgentPanel below + // the composer. Two full rosters inflate the non-`` live frame + // past the terminal height, at which point ink clears the whole screen + // (incl. scrollback) on every repaint. The branch now routes through the + // same `inlineToolCalls` hand-off as every other group. + // InlineParallelAgentsDisplay reads the registry off config; give it a + // real stub (the bare `{}` mockConfig would make `getBackgroundTaskRegistry` + // throw). Empty registry → rows fall back to the tool-result data. + const registryConfig = { + getBackgroundTaskRegistry: () => ({ get: () => undefined }), + } as unknown as Config; + const renderParallel = (component: React.ReactElement) => + render( + + {component} + , + ); + + const parallelAgent = ( + callId: string, + taskDescription: string, + status: AgentResultDisplay['status'], + ): IndividualToolCallDisplay => + createToolCall({ + callId, + name: 'task', + description: taskDescription, + status: + status === 'running' + ? ToolCallStatus.Executing + : status === 'completed' + ? ToolCallStatus.Success + : ToolCallStatus.Error, + resultDisplay: { + type: 'task_execution', + subagentName: 'reviewer', + taskDescription, + taskPrompt: 'review', + status, + } as AgentResultDisplay, + }); + + it('live phase: an all-running parallel group renders nothing inline (panel owns the roster)', () => { + const { lastFrame } = renderParallel( + , + ); + const frame = lastFrame() ?? ''; + // No duplicate inline roster: header absent, neither running agent shown. + expect(frame).not.toContain('Parallel agents'); + expect(frame).not.toContain('RUNALPHA'); + expect(frame).not.toContain('RUNBETA'); + }); + + it('live phase: a mixed group shows only terminal agents inline, hides panel-owned running ones, keeps the full total', () => { + const { lastFrame } = renderParallel( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Parallel agents'); + // The completed agent surfaces inline (en route to scrollback). + expect(frame).toContain('DONEONE'); + // Running agents are owned by the panel — NOT duplicated inline. + expect(frame).not.toContain('RUNTWO'); + expect(frame).not.toContain('RUNTHREE'); + // Header total stays honest (3 agents, 1 done) even though 1 row renders. + expect(frame).toContain('1/3 done'); + }); + + it('committed phase: renders every agent inline (the persistent record)', () => { + const { lastFrame } = renderParallel( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('Parallel agents'); + expect(frame).toContain('COMMITA'); + expect(frame).toContain('COMMITB'); + expect(frame).toContain('2/2 done'); + }); + + it('forwards the height cap only in the live phase (committed phase = no cap)', () => { + // The InlineParallelAgentsDisplay height backstop guards ONLY the live, + // non-`` frame. ToolGroupMessage forwards + // `isPending ? availableTerminalHeight : undefined`, so a tight budget + // windows the live phase but never the committed scrollback record (where + // MainContent passes staticAreaMaxItemHeight >= 100). Without the + // conditional, completed agents would be permanently hidden behind a + // static "+N more" in scrollback. The contrast pins that load-bearing + // conditional — a regression that always forwards (or always drops) the + // budget breaks exactly one of these two assertions. + const manyCompleted = Array.from({ length: 8 }, (_, i) => + parallelAgent(`cap-${i}`, `CAPAGENT${i}`, 'completed'), + ); + + // Live phase + tight budget → windowing kicks in, overflow indicator shows. + const live = renderParallel( + , + ); + expect(live.lastFrame() ?? '').toContain('more agent'); + + // Committed phase, SAME tight budget → cap suppressed, every agent renders. + const committed = renderParallel( + , + ); + const committedFrame = committed.lastFrame() ?? ''; + expect(committedFrame).not.toContain('more agent'); + expect(committedFrame).toContain('CAPAGENT0'); + expect(committedFrame).toContain('CAPAGENT7'); + expect(committedFrame).toContain('8/8 done'); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 6bc962d0b72..5e93adf9696 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -258,14 +258,38 @@ export const ToolGroupMessage: React.FC = ({ const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0; // Pure parallel agent group (≥2 agents, nothing else). - // Dense panel in both phases with all agents. During live phase - // LiveAgentPanel below also shows running agents (brief overlap - // that resolves as agents complete and expire from the panel). + // + // Render through the SAME `inlineToolCalls` hand-off as every other group: + // during the live phase, running / background subagents are owned by + // LiveAgentPanel below the composer, so rendering them here too duplicated a + // full agent roster inside the non-`` live frame. Once that frame + // exceeds the terminal height, ink clears the whole screen (incl. scrollback) + // on every repaint — the per-second elapsed/token ticks then make it fire + // continuously, so scroll-up snaps straight back to the bottom (#5798, the + // `shouldClearTerminalForFrame` path in ink). Showing only the agents the + // panel is NOT displaying (terminal rows en route to ``) halves the + // live frame and keeps it under the viewport. `totalAgentCount` keeps the + // header's "N · done/N" honest, and `availableTerminalHeight` is a hard cap + // backstop for degenerate cases (many agents finishing at once). if (isPureParallelAgentGroup(toolCalls) && !hasSubagentPendingConfirmation) { + // `isPureParallelAgentGroup` already guarantees every entry is a subagent, + // so `inlineToolCalls` (a subset) and `toolCalls.length` need no further + // `isSubagentToolEntry` filtering here. + if (inlineToolCalls.length === 0) { + return null; + } return ( ` frame. Once + // committed (`isPending=false`) the rows live in `` with no + // snap-back risk, and MainContent passes `staticAreaMaxItemHeight` + // (>=100) here — forwarding that would let the cap fire on scrollback + // and permanently hide completed agents behind "+N more". Pass + // undefined (no cap) when committed, per the component's contract. + availableTerminalHeight={isPending ? availableTerminalHeight : undefined} /> ); } diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index c3a51ea6764..9f37e47968e 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -180,7 +180,9 @@ function ScrollableList( [scheduleScrollFlush, cancelPendingScroll], ); - useMouseEvents(handleMouseEvent, { isActive: hasFocus }); + // The VP viewport owns the wheel (this IS the in-app scroller), so opt out + // of the VP gate — though in practice ScrollableList only mounts in VP mode. + useMouseEvents(handleMouseEvent, { isActive: hasFocus, bypassVpGate: true }); // ScrollableList is a thin keyboard / mouse wrapper around VirtualizedList. // The previous outer wrapper carried a never-read diff --git a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx index 4223f6616bd..e88bccb4e29 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx +++ b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx @@ -10,6 +10,8 @@ import { renderHook } from '@testing-library/react'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { useStdin, useStdout } from 'ink'; import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; import { useMouseEvents } from './useMouseEvents.js'; vi.mock('ink', async (importOriginal) => { @@ -31,9 +33,26 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); +const vpWrapper = (useTerminalBuffer: boolean) => { + const VpWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + return VpWrapper; +}; + +// Mechanism tests exercise enable/disable/ref-counting independent of the VP +// gate, so they opt out via bypassVpGate. function useTwoMouseSubscribers(firstActive: boolean, secondActive: boolean) { - useMouseEvents(() => {}, { isActive: firstActive }); - useMouseEvents(() => {}, { isActive: secondActive }); + useMouseEvents(() => {}, { isActive: firstActive, bypassVpGate: true }); + useMouseEvents(() => {}, { isActive: secondActive, bypassVpGate: true }); } describe('useMouseEvents', () => { @@ -93,4 +112,28 @@ describe('useMouseEvents', () => { unmount(); expect(stdout.write).not.toHaveBeenCalled(); }); + + describe('VP gate', () => { + it('non-VP without bypass: does NOT enable mouse mode (native scrollback preserved)', () => { + renderHook(() => useMouseEvents(() => {}, { isActive: true }), { + wrapper: vpWrapper(false), + }); + expect(stdout.write).not.toHaveBeenCalledWith(ENABLE_MOUSE); + }); + + it('VP without bypass: enables mouse mode', () => { + renderHook(() => useMouseEvents(() => {}, { isActive: true }), { + wrapper: vpWrapper(true), + }); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE); + }); + + it('bypassVpGate: enables mouse mode even in non-VP (modal / VP viewport)', () => { + renderHook( + () => useMouseEvents(() => {}, { isActive: true, bypassVpGate: true }), + { wrapper: vpWrapper(false) }, + ); + expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts index 22e20fd9aae..8989d6615d3 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.ts +++ b/packages/cli/src/ui/hooks/useMouseEvents.ts @@ -8,7 +8,7 @@ * out of the KeypressContext pipeline, call each handler, restore on cleanup. */ -import { useEffect, useRef, useCallback } from 'react'; +import { useContext, useEffect, useRef, useCallback } from 'react'; import { useStdin, useStdout } from 'ink'; import { enableMouseEvents, @@ -16,9 +16,23 @@ import { type MouseEvent, } from '../utils/mouse.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; export type MouseHandler = (event: MouseEvent) => void; +export interface MouseEventsOptions { + /** Subscribe + enable SGR mouse mode only while this is true. */ + isActive: boolean; + /** + * Opt out of the VP gate. By default mouse tracking is enabled only in VP + * mode (`ui.useTerminalBuffer`), so non-VP keeps native terminal scrollback. + * Set true for surfaces that own the wheel regardless — the VP viewport + * (ScrollableList) and alternate-screen modals (ThinkingViewer) — where + * there is no main-screen native scrollback to protect. + */ + bypassVpGate?: boolean; +} + type MouseModeEntry = { refs: number; }; @@ -80,16 +94,29 @@ function releaseMouseMode(stdout: NodeJS.WriteStream): void { */ export function useMouseEvents( handler: MouseHandler, - { isActive }: { isActive: boolean }, + { isActive, bypassVpGate = false }: MouseEventsOptions, ): void { const { isRawModeSupported } = useStdin(); const { stdout } = useStdout(); const { subscribeMouse, unsubscribeMouse } = useKeypressContext(); + // VP gate: enabling SGR mouse tracking (?1002h) makes the host terminal stop + // doing native scrollback on the wheel. That is only acceptable when the app + // itself owns the wheel — i.e. in VP mode (ScrollableList) or on an + // alternate-screen surface (a modal) that has no native scrollback to begin + // with. On the non-VP main screen, holding mouse tracking just hijacks the + // wheel (Terminal.app diverts it away from scrollback), so by DEFAULT mouse + // tracking is denied outside VP. Surfaces that legitimately consume the wheel + // pass `bypassVpGate` to opt in. This keeps the non-VP transcript scrollable + // no matter how many click/hover subscribers are added later. + const settings = useContext(SettingsContext); + const isVpMode = settings?.merged.ui?.useTerminalBuffer ?? false; + const vpGateOpen = isVpMode || bypassVpGate; + const handlerRef = useRef(handler); handlerRef.current = handler; - const enabled = isActive && isRawModeSupported; + const enabled = isActive && isRawModeSupported && vpGateOpen; useEffect(() => { if (!enabled) return;