Skip to content
15 changes: 13 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2685,12 +2685,23 @@ export const AppContainer = (props: AppContainerProps) => {
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
// `staticExtraHeight` + `MAIN_CONTENT_HEIGHT_RESERVATION` only cap how tall an
// *inline* streaming/pending message may grow before it commits to <Static>;
// they do NOT reserve blank rows under the composer. In legacy mode completed
// history lives in <Static> (terminal scrollback) and the composer flows to
// the very bottom of the output. VP mode owns the whole viewport in the React
// tree, so to match that bottom spacing the composer must reach the bottom
// too — reserve nothing. (controlsHeight is measured one frame late, so a
// composer that grows can briefly overshoot by a row before the re-measure
// corrects, the same way legacy mode lets the terminal scroll on growth.)
const mainContentHeightReservation = useTerminalBuffer
? 0
: staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION;
const availableTerminalHeight = Math.max(
0,
terminalHeight -
controlsHeight -
staticExtraHeight -
MAIN_CONTENT_HEIGHT_RESERVATION -
mainContentHeightReservation -
tabBarHeight,
);

Expand Down
38 changes: 32 additions & 6 deletions packages/cli/src/ui/components/ThinkingViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
*/

import type { FC } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Box, Text } from 'ink';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useFrameCoalescedFlush } from '../hooks/use-frame-coalesced-flush.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import type { MouseEvent } from '../utils/mouse.js';
Expand All @@ -17,6 +18,7 @@ import { t } from '../../i18n/index.js';
import { AlternateScreen } from './AlternateScreen.js';
import type { ThinkingViewerData } from '../contexts/ThinkingViewerContext.js';
import { THINKING_ICON } from './messages/ConversationMessages.js';
import { wrapToVisualLines } from '../utils/textUtils.js';
import { formatDuration } from '../utils/displayUtils.js';

interface ThinkingViewerProps {
Expand All @@ -33,14 +35,25 @@ export const ThinkingViewer: FC<ThinkingViewerProps> = ({
onClose,
useAlternateScreen = true,
}) => {
const { rows } = useTerminalSize();
const { rows, columns } = useTerminalSize();
const [scrollOffset, setScrollOffset] = useState(0);

const headerHeight = 2;
const footerHeight = 2;
const contentHeight = Math.max(rows - headerHeight - footerHeight, 1);

const lines = useMemo(() => data.text.split('\n'), [data.text]);
// The thought text is frequently a single long paragraph with no explicit
// newlines. Splitting on '\n' alone yields one logical line that, rendered
// with `wrap="truncate-end"`, collapsed to a single ellipsised row above an
// empty box (and `maxScroll` stayed 0, so it could not scroll). Pre-wrap to
// visual rows at the inner content width — border (1 each side) + paddingX
// (1 each side) = 4 columns — so scrolling and rendering operate on the same
// rows the user actually sees.
const contentWidth = Math.max(1, columns - 4);
const lines = useMemo(
() => wrapToVisualLines(data.text, contentWidth),
[data.text, contentWidth],
);
const maxScroll = Math.max(0, lines.length - contentHeight);

useEffect(() => {
Expand All @@ -54,6 +67,17 @@ export const ThinkingViewer: FC<ThinkingViewerProps> = ({
[maxScroll],
);

// Coalesce wheel bursts to one update per frame, mirroring ScrollableList —
// each wheel event re-renders the modal, so an un-batched brisk spin stutters.
const pendingWheelDelta = useRef(0);
const { schedule: scheduleWheelFlush } = useFrameCoalescedFlush(
useCallback(() => {
const delta = pendingWheelDelta.current;
pendingWheelDelta.current = 0;
if (delta !== 0) scrollBy(delta);
}, [scrollBy]),
);

useKeypress(
useCallback(
(key: Key) => {
Expand Down Expand Up @@ -85,12 +109,14 @@ export const ThinkingViewer: FC<ThinkingViewerProps> = ({
useCallback(
(event: MouseEvent) => {
if (event.name === 'scroll-up') {
scrollBy(-WHEEL_LINES);
pendingWheelDelta.current -= WHEEL_LINES;
scheduleWheelFlush();
} else if (event.name === 'scroll-down') {
scrollBy(WHEEL_LINES);
pendingWheelDelta.current += WHEEL_LINES;
scheduleWheelFlush();
}
},
[scrollBy],
[scheduleWheelFlush],
),
{ isActive: true },
);
Expand Down
34 changes: 1 addition & 33 deletions packages/cli/src/ui/components/messages/ConversationMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
SCREEN_READER_USER_PREFIX,
} from '../../textConstants.js';
import { t } from '../../../i18n/index.js';
import { getCachedStringWidth } from '../../utils/textUtils.js';
import { wrapToVisualLines } from '../../utils/textUtils.js';
import { formatDuration } from '../../utils/displayUtils.js';

export const THINKING_ICON = '∴ ';
Expand Down Expand Up @@ -264,38 +264,6 @@ export const AssistantMessageContent: React.FC<

const MAX_STREAMING_THINKING_VISUAL_LINES = 4;

function wrapToVisualLines(text: string, width: number): string[] {
if (width <= 0) {
return [''];
}
const visualLines: string[] = [];
for (const logicalLine of text.split('\n')) {
if (logicalLine === '') {
visualLines.push('');
continue;
}
let currentLine = '';
let currentWidth = 0;
for (const char of logicalLine) {
const charWidth = getCachedStringWidth(char);
if (currentWidth + charWidth > width && currentWidth > 0) {
visualLines.push(currentLine);
currentLine = '';
currentWidth = 0;
}
currentLine += char;
currentWidth += charWidth;
}
if (currentLine) {
visualLines.push(currentLine);
}
}
if (visualLines.length === 0) {
visualLines.push('');
}
return visualLines;
}

function tailVisualLines(
text: string,
width: number,
Expand Down
60 changes: 53 additions & 7 deletions packages/cli/src/ui/components/shared/ScrollableList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ const withKeypress = (children: React.ReactNode) => (
const makeItems = (n: number): Item[] =>
Array.from({ length: n }, (_, i) => ({ id: i, label: `item-${i}` }));

// Mouse wheel/drag scrolling is coalesced to one viewport update per ~16ms
// frame (useFrameCoalescedFlush). Wait past that window so the real timer
// fires before asserting — this exercises the production batching path.
const flushScrollFrame = () =>
act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});

const keyExtractor = (item: Item) => `k-${item.id}`;
const estimatedItemHeight = () => 1;

Expand Down Expand Up @@ -68,15 +76,15 @@ describe('<ScrollableList /> mouse scrolling', () => {
await act(async () => {
for (let i = 0; i < 5; i++) stdin.write(wheelDown(5, 5));
});
await act(async () => {});
await flushScrollFrame();
// After scrolling down, item-0 should no longer be in the window.
expect(lastFrame()).not.toContain('item-0');

// Wheel back up to bring item-0 back into view.
await act(async () => {
for (let i = 0; i < 10; i++) stdin.write(wheelUp(5, 5));
});
await act(async () => {});
await flushScrollFrame();
expect(lastFrame()).toContain('item-0');
});

Expand Down Expand Up @@ -128,7 +136,7 @@ describe('<ScrollableList /> mouse scrolling', () => {
stdin.write(leftDrag(5, 6));
stdin.write(leftRelease(5, 6));
});
await act(async () => {});
await flushScrollFrame();
expect(lastFrame()).toBe(before);
});

Expand Down Expand Up @@ -158,7 +166,7 @@ describe('<ScrollableList /> mouse scrolling', () => {
stdin.write(leftDrag(40, 5));
stdin.write(leftRelease(40, 5));
});
await act(async () => {});
await flushScrollFrame();

expect(lastFrame()).not.toContain('item-0');
expect(lastFrame()).toContain('item-49');
Expand Down Expand Up @@ -190,7 +198,7 @@ describe('<ScrollableList /> mouse scrolling', () => {
stdin.write(leftDrag(40, 3));
stdin.write(leftRelease(40, 3));
});
await act(async () => {});
await flushScrollFrame();

expect(lastFrame()).not.toContain('item-0');
expect(lastFrame()).toContain('item-23');
Expand Down Expand Up @@ -223,12 +231,50 @@ describe('<ScrollableList /> mouse scrolling', () => {
stdin.write(leftDrag(35, 5));
stdin.write(leftRelease(35, 5));
});
await act(async () => {});
await flushScrollFrame();

expect(lastFrame()).not.toContain('item-0');
expect(lastFrame()).toContain('item-49');
});

it('a scrollbar press cancels a still-pending wheel flush', async () => {
// Regression: a wheel burst schedules a coalesced flush; clicking the
// scrollbar within that window must drop the queued wheel delta so the
// timer can't yank the view off the just-clicked row.
const renderItem = ({ item }: { item: Item }) => <Text>{item.label}</Text>;
const Wrapper = () => (
<ScrollableList<Item>
hasFocus
data={makeItems(50)}
renderItem={renderItem}
estimatedItemHeight={estimatedItemHeight}
keyExtractor={keyExtractor}
initialScrollIndex={0}
containerHeight={5}
width={40}
showScrollbar
/>
);

const { stdin, lastFrame, rerender } = render(withKeypress(<Wrapper />));
rerender(withKeypress(<Wrapper />));
await act(async () => {});
expect(lastFrame()).toContain('item-0');

// Wheel down (queues a flush), then immediately click the top of the
// scrollbar — all before the frame timer fires.
await act(async () => {
stdin.write(wheelDown(5, 5));
stdin.write(wheelDown(5, 5));
stdin.write(leftPress(40, 1));
stdin.write(leftRelease(40, 1));
});
await flushScrollFrame();

// The press pinned the top; the canceled wheel must not have scrolled away.
expect(lastFrame()).toContain('item-0');
});

it('does not start a scrollbar drag when content fits the viewport', async () => {
const renderItem = ({ item }: { item: Item }) => <Text>{item.label}</Text>;
const Wrapper = () => (
Expand All @@ -255,7 +301,7 @@ describe('<ScrollableList /> mouse scrolling', () => {
stdin.write(leftDrag(40, 5));
stdin.write(leftRelease(40, 5));
});
await act(async () => {});
await flushScrollFrame();

expect(lastFrame()).toBe(before);
});
Expand Down
91 changes: 71 additions & 20 deletions packages/cli/src/ui/components/shared/ScrollableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type VirtualizedListRef,
type VirtualizedListProps,
} from './VirtualizedList.js';
import { useFrameCoalescedFlush } from '../../hooks/use-frame-coalesced-flush.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { useMouseEvents } from '../../hooks/useMouseEvents.js';
Expand Down Expand Up @@ -104,31 +105,81 @@ function ScrollableList<T>(
// native scrollback. In VP mode the list owns the visible region, so route
// wheel ticks and scrollbar drags to the virtualized viewport.
const WHEEL_LINES_PER_TICK = 3;
const handleMouseEvent = useCallback((event: MouseEvent) => {
if (!virtualizedListRef.current) return;
if (event.name === 'left-release') {
isDraggingScrollbar.current = false;
return;
}
if (event.name === 'left-press') {
isDraggingScrollbar.current =
virtualizedListRef.current.hitTestScrollbar(event);
if (isDraggingScrollbar.current) {
virtualizedListRef.current.scrollToScrollbarRow(event.row);
}
return;
}
if (event.name === 'move' && isDraggingScrollbar.current) {
virtualizedListRef.current.scrollToScrollbarRow(event.row);

// Terminal mouse reporting emits one event per row the pointer crosses, so a
// brisk wheel spin or scrollbar drag fires a rapid burst. Applying each event
// synchronously forced one Ink reflow + terminal flush per event — the source
// of the "一顿一顿" stutter. Accumulate the intent in refs and let
// useFrameCoalescedFlush apply the latest at most once per frame. A drag is
// absolute (snap to the newest row); a wheel burst is relative (sum the
// ticks); a drag in the same window wins.
const pendingWheelDelta = useRef(0);
const pendingDragRow = useRef<number | null>(null);

const applyPendingScroll = useCallback(() => {
const list = virtualizedListRef.current;
const dragRow = pendingDragRow.current;
const wheelDelta = pendingWheelDelta.current;
pendingDragRow.current = null;
pendingWheelDelta.current = 0;
if (!list) return;
if (dragRow !== null) {
list.scrollToScrollbarRow(dragRow);
return;
}
if (event.name === 'scroll-up') {
virtualizedListRef.current.scrollBy(-WHEEL_LINES_PER_TICK);
} else if (event.name === 'scroll-down') {
virtualizedListRef.current.scrollBy(WHEEL_LINES_PER_TICK);
if (wheelDelta !== 0) {
list.scrollBy(wheelDelta);
}
}, []);

const { schedule: scheduleScrollFlush, cancel: cancelScrollFlush } =
useFrameCoalescedFlush(applyPendingScroll);

// Discard any queued wheel/drag intent and cancel an in-flight flush. Used
// when a scrollbar press takes over: without it, a wheel burst scheduled
// moments earlier would still fire and `scrollBy` the view away from the row
// the user just clicked.
const cancelPendingScroll = useCallback(() => {
pendingWheelDelta.current = 0;
pendingDragRow.current = null;
cancelScrollFlush();
}, [cancelScrollFlush]);

const handleMouseEvent = useCallback(
(event: MouseEvent) => {
if (!virtualizedListRef.current) return;
if (event.name === 'left-release') {
isDraggingScrollbar.current = false;
return;
}
if (event.name === 'left-press') {
isDraggingScrollbar.current =
virtualizedListRef.current.hitTestScrollbar(event);
if (isDraggingScrollbar.current) {
// A press should feel instant — apply now and drop any queued
// wheel/drag intent (and its timer) so a flush scheduled moments
// earlier can't yank the view off the clicked row.
cancelPendingScroll();
virtualizedListRef.current.scrollToScrollbarRow(event.row);
}
return;
}
if (event.name === 'move' && isDraggingScrollbar.current) {
pendingDragRow.current = event.row;
scheduleScrollFlush();
return;
}
if (event.name === 'scroll-up') {
pendingWheelDelta.current -= WHEEL_LINES_PER_TICK;
scheduleScrollFlush();
} else if (event.name === 'scroll-down') {
pendingWheelDelta.current += WHEEL_LINES_PER_TICK;
scheduleScrollFlush();
}
},
[scheduleScrollFlush, cancelPendingScroll],
);

useMouseEvents(handleMouseEvent, { isActive: hasFocus });

// ScrollableList is a thin keyboard / mouse wrapper around VirtualizedList.
Expand Down
Loading
Loading