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;