Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] ThinkingViewer.test.tsx does not exist. The core bug fix — pre-wrapping thought text to visual lines instead of splitting on \n — has no regression test. The motivating bug (a single long paragraph collapsing to one ellipsised row) could silently reappear if wrapToVisualLines or the contentWidth formula changes.

Consider adding tests covering:

  • A single long paragraph with no newlines (the motivating bug)
  • Narrow terminals where columns - 4 yields small widths
  • Empty data.text
  • Terminal resize (changing columns between renders)

— qwen3.7-max via Qwen Code /review

// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] contentWidth hardcodes the chrome offset as columns - 4 (border 1+1, paddingX 1+1). This is the only link between the pre-wrap width passed to wrapToVisualLines and the actual inner width that <Text wrap="truncate-end"> renders into. If anyone later changes paddingX, borderStyle, or adds an inner wrapper, the pre-wrap width silently diverges from the render width — lines get truncated with ellipsis or waste right-margin space, with no compile-time or runtime signal.

Consider extracting a named constant (e.g., THINKING_VIEWER_CHROME_WIDTH = 4) co-located with the render JSX so both stay in sync.

— qwen3.7-max via Qwen Code /review

// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] left-release sets isDraggingScrollbar.current = false but doesn't call cancelPendingScroll(). If a drag move event set pendingDragRow and scheduled a 16ms flush timer, and the user releases before the timer fires, the timer still executes applyPendingScroll() which reads the stale dragRow and calls scrollToScrollbarRow() after the user has already released — the viewport jumps to the last drag position.

The fix is one line:

Suggested change
virtualizedListRef.current.hitTestScrollbar(event);
if (event.name === 'left-release') {
isDraggingScrollbar.current = false;
cancelPendingScroll();
return;
}

— qwen3.7-max via Qwen Code /review

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