fix(cli): fix thought viewer truncation, layout gaps, and choppy scrolling in VP mode (#6002)

* fix(cli): wrap thought viewer text to visual lines

The full-screen ThinkingViewer split `data.text` on '\n' and rendered each
logical line with `wrap="truncate-end"`. A thought is usually a single long
paragraph with no newlines, so it collapsed to one ellipsised row above an
empty box and `maxScroll` stayed 0 (could not scroll).

Pre-wrap the text to visual rows at the inner content width (border + paddingX
= 4 cols) before slicing, reusing the existing `wrapToVisualLines` helper (now
exported from ConversationMessages). Scrolling and rendering now operate on the
same rows the user sees.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): collapse VP viewport to content and sink the composer

In terminal-buffer (VP) mode the conversation wasted vertical space:

- VirtualizedList pinned its root box to the full `containerHeight`, so short
  content left a tall blank gap and pushed the composer far down. Collapse the
  box to `min(containerHeight, totalHeight)` so it grows with its content like
  the legacy <Static> path; the scroll math still uses the full height, so
  overflow scrolling is unchanged.
- `availableTerminalHeight` subtracted `staticExtraHeight` +
  `MAIN_CONTENT_HEIGHT_RESERVATION`, the <Static> overflow-flicker guards. VP
  clips natively and does not need them, so they stranded ~5 blank rows below
  the composer (input never reached the bottom). Drop the reservation in VP;
  non-VP keeps it unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* perf(cli): coalesce VP scroll events per frame

Terminal mouse reporting emits one event per row crossed, so a brisk wheel
spin or scrollbar drag fired a rapid burst, each applied synchronously with a
full Ink reflow + terminal flush — the source of the choppy scroll. Accumulate
wheel deltas / the latest drag row in refs and flush at most once per ~16ms
frame. A press still applies instantly; under NODE_ENV==='test' updates apply
synchronously so the existing timer-free tests keep passing.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): cancel queued scroll on scrollbar press

A wheel burst schedules a 16ms coalescing flush. If the user clicked the
scrollbar within that window, the press applied its row immediately but the
still-armed timer then fired `scrollBy` with the leftover wheel delta, yanking
the view off the clicked row. Clear the pending wheel/drag intent and cancel
the timer when a scrollbar press takes over.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): keep a small height slack in VP mode

Zeroing the VP layout reservation removed all slack, so when the composer grew
(multi-line input) the one-frame-late controlsHeight measurement briefly
oversized the list and overflowed the terminal — the exact jitter the layout
change aimed to remove. Drop only the Static-specific staticExtraHeight (3) and
keep MAIN_CONTENT_HEIGHT_RESERVATION (2) as a transient-measurement buffer; the
composer still sits far closer to the bottom than before (was ~5 stranded rows).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(cli): move wrapToVisualLines to textUtils

It lived in the 1000-line ConversationMessages component and ThinkingViewer
imported it across components. Co-locate it with its sibling visual-wrapping
helper (sliceTextByVisualHeight) in utils/textUtils.ts and import from there.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(cli): share frame-coalesced scroll hook and test it

Extract the per-frame scroll coalescing into useFrameCoalescedFlush and use it
from both ScrollableList and ThinkingViewer (whose wheel handler was still
un-batched, so a brisk spin in the expanded thought viewer stuttered).

Drop the NODE_ENV==='test' escape hatch that made the production timer path
unreachable in tests: the mouse-scroll tests now advance a real frame before
asserting, exercising the batching/accumulation/precedence logic. Adds a
regression test for a scrollbar press canceling a still-pending wheel flush.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): match legacy bottom spacing in VP mode (no reservation)

The static/<Static> path reserves no blank rows under the composer — the
staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION budget only caps inline
streaming-message height, while the composer flows to the very bottom of the
terminal. Reserve nothing in VP so its composer reaches the bottom the same
way, instead of leaving a 2-row gap. The one-frame controlsHeight measurement
lag on composer growth mirrors legacy mode letting the terminal scroll.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ChiGao 2026-06-29 20:41:55 +08:00 committed by GitHub
parent e2b85559a3
commit f11bb31df2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 279 additions and 76 deletions

View file

@ -2703,12 +2703,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,
);

View file

@ -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';
@ -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 {
@ -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(() => {
@ -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) => {
@ -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 },
);

View file

@ -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 = '∴ ';
@ -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,

View file

@ -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;
@ -68,7 +76,7 @@ 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');
@ -76,7 +84,7 @@ describe('<ScrollableList /> mouse scrolling', () => {
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');
});
@ -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);
});
@ -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');
@ -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');
@ -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 = () => (
@ -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);
});

View file

@ -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';
@ -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;
// 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 === '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);
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.

View file

@ -854,15 +854,22 @@ function VirtualizedList<T>(
scrollbarThumbActive,
]);
// The host passes `containerHeight` as the *maximum* viewport height (the
// room available between the header and the composer). Pinning the root box
// to that height unconditionally left a tall empty gap below short content
// and pushed the composer far down the screen — the legacy <Static> path
// instead grows with its content. Collapse to `totalHeight` whenever the
// content fits so the composer sits right beneath the conversation; only
// when the content overflows do we clamp to `containerHeight` and let the
// viewport scroll. `scrollableContainerHeight` (the scroll math) still uses
// the full `containerHeight`, so scrolling is unaffected.
const rootHeight =
props.containerHeight !== undefined
? Math.min(props.containerHeight, totalHeight)
: '100%';
return (
<Box
ref={rootRef}
width="100%"
height={
props.containerHeight !== undefined ? props.containerHeight : '100%'
}
flexDirection="row"
>
<Box ref={rootRef} width="100%" height={rootHeight} flexDirection="row">
<Box
ref={containerRef}
overflowY="hidden"

View file

@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef } from 'react';
/** One 60Hz frame — the coalescing window for burst scroll input. */
export const SCROLL_FRAME_MS = 16;
/**
* Coalesce a burst of imperative updates into at most one `flush` per frame.
*
* Terminal mouse reporting emits one event per row the pointer crosses, so a
* brisk wheel spin or scrollbar drag fires many events in quick succession.
* Applying each synchronously forces one Ink reflow + terminal write per event
* the source of choppy scrolling. Callers accumulate their intent in their
* own ref(s) and call `schedule()`; the latest accumulated state is applied
* once when the timer fires. `cancel()` drops a pending flush (e.g. when a new
* gesture takes over). The timer is always cleared on unmount.
*
* The timer is real (not gated on NODE_ENV), so tests exercise the same path
* production does; they just need to advance ~`frameMs` before asserting.
*/
export function useFrameCoalescedFlush(
flush: () => void,
frameMs: number = SCROLL_FRAME_MS,
) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the latest flush without re-arming the timer on every render.
const flushRef = useRef(flush);
flushRef.current = flush;
const run = useCallback(() => {
timer.current = null;
flushRef.current();
}, []);
const schedule = useCallback(() => {
if (timer.current !== null) return;
timer.current = setTimeout(run, frameMs);
}, [run, frameMs]);
const cancel = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
useEffect(() => cancel, [cancel]);
return { schedule, cancel };
}

View file

@ -243,6 +243,45 @@ export function sliceTextByVisualHeight(
};
}
/**
* Wrap text into the visual rows it occupies at `width` columns, accounting
* for both explicit newlines and code-point-width-aware soft wrapping. Unlike
* `sliceTextByVisualHeight` (which keeps only a head/tail window), this returns
* every visual row, so callers that scroll an arbitrary offset (e.g. the
* ThinkingViewer) can slice the rows the user actually sees.
*/
export 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;
}
/**
* Clear the string width cache
*/