From 416bd9595f4863e1b9741d3a18b171637fcd4c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 17:49:12 +0800 Subject: [PATCH 1/8] 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 --- .../cli/src/ui/components/ThinkingViewer.tsx | 20 ++++++++++++++++--- .../messages/ConversationMessages.tsx | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/components/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx index 01fa6aa3a8b..de30fffeec3 100644 --- a/packages/cli/src/ui/components/ThinkingViewer.tsx +++ b/packages/cli/src/ui/components/ThinkingViewer.tsx @@ -16,7 +16,10 @@ import { theme } from '../semantic-colors.js'; 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 { + THINKING_ICON, + wrapToVisualLines, +} from './messages/ConversationMessages.js'; import { formatDuration } from '../utils/displayUtils.js'; interface ThinkingViewerProps { @@ -33,14 +36,25 @@ export const ThinkingViewer: FC = ({ 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(() => { diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index 88cd1b8ee35..429dbc04ac6 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -264,7 +264,7 @@ export const AssistantMessageContent: React.FC< const MAX_STREAMING_THINKING_VISUAL_LINES = 4; -function wrapToVisualLines(text: string, width: number): string[] { +export function wrapToVisualLines(text: string, width: number): string[] { if (width <= 0) { return ['']; } From 6b91301ee367aa6fda3806b190441b4bdbce97c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 17:49:18 +0800 Subject: [PATCH 2/8] 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 path; the scroll math still uses the full height, so overflow scrolling is unchanged. - `availableTerminalHeight` subtracted `staticExtraHeight` + `MAIN_CONTENT_HEIGHT_RESERVATION`, the 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 --- packages/cli/src/ui/AppContainer.tsx | 13 +++++++++-- .../ui/components/shared/VirtualizedList.tsx | 23 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3d2924d463d..a7a7864b053 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2685,12 +2685,21 @@ 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` are breathing room + // for the append-only region in legacy mode (they prevent the + // overflow flicker the non-VP path is prone to). VP mode owns the viewport + // through the React tree and clips natively, so reserving those rows just + // strands ~5 blank rows beneath the composer — the input never reaches the + // bottom of the terminal. Drop the reservation in VP so the composer sinks + // to the bottom; non-VP keeps it unchanged. + const mainContentHeightReservation = useTerminalBuffer + ? 0 + : staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION; const availableTerminalHeight = Math.max( 0, terminalHeight - controlsHeight - - staticExtraHeight - - MAIN_CONTENT_HEIGHT_RESERVATION - + mainContentHeightReservation - tabBarHeight, ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 1dec241c212..986fd3005fb 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -854,15 +854,22 @@ function VirtualizedList( 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 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 ( - + Date: Mon, 29 Jun 2026 17:49:24 +0800 Subject: [PATCH 3/8] perf(cli): coalesce VP scroll events per frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/components/shared/ScrollableList.tsx | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index 372ee868211..917f87253d8 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react'; +import { + useRef, + forwardRef, + useImperativeHandle, + useCallback, + useEffect, +} from 'react'; import type React from 'react'; import { VirtualizedList, @@ -104,30 +110,87 @@ function ScrollableList( // 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. Coalesce a burst into a single viewport update per + // frame: accumulate the intent in refs and flush on a short timer. A drag is + // absolute (snap to the newest row); a wheel burst is relative (sum the + // ticks); a drag in the same window wins. Tests drive real timers and only + // await microtasks, so apply synchronously under NODE_ENV==='test' (the same + // escape hatch VirtualizedList uses for its readiness gate). + const SCROLL_FRAME_MS = 16; + const pendingWheelDelta = useRef(0); + const pendingDragRow = useRef(null); + const flushTimer = useRef | null>(null); + + const applyPendingScroll = useCallback(() => { + flushTimer.current = null; + 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 (wheelDelta !== 0) { + list.scrollBy(wheelDelta); } - if (event.name === 'move' && isDraggingScrollbar.current) { - virtualizedListRef.current.scrollToScrollbarRow(event.row); + }, []); + + const scheduleScrollFlush = useCallback(() => { + if (process.env['NODE_ENV'] === 'test') { + applyPendingScroll(); 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 (flushTimer.current !== null) return; + flushTimer.current = setTimeout(applyPendingScroll, SCROLL_FRAME_MS); + }, [applyPendingScroll]); + + useEffect( + () => () => { + if (flushTimer.current !== null) clearTimeout(flushTimer.current); + }, + [], + ); + + 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 stale + // pending drag row from a previous gesture. + pendingDragRow.current = null; + 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], + ); useMouseEvents(handleMouseEvent, { isActive: hasFocus }); From 83366e2c0551a3136f2f4afcff2637d3f93e095c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 18:57:54 +0800 Subject: [PATCH 4/8] 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 --- .../ui/components/shared/ScrollableList.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index 917f87253d8..ec7616fa36c 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -151,6 +151,19 @@ function ScrollableList( flushTimer.current = setTimeout(applyPendingScroll, SCROLL_FRAME_MS); }, [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 its timer and `scrollBy` the view away + // from the row the user just clicked. + const cancelPendingScroll = useCallback(() => { + pendingWheelDelta.current = 0; + pendingDragRow.current = null; + if (flushTimer.current !== null) { + clearTimeout(flushTimer.current); + flushTimer.current = null; + } + }, []); + useEffect( () => () => { if (flushTimer.current !== null) clearTimeout(flushTimer.current); @@ -169,9 +182,10 @@ function ScrollableList( isDraggingScrollbar.current = virtualizedListRef.current.hitTestScrollbar(event); if (isDraggingScrollbar.current) { - // A press should feel instant — apply now and drop any stale - // pending drag row from a previous gesture. - pendingDragRow.current = null; + // 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; @@ -189,7 +203,7 @@ function ScrollableList( scheduleScrollFlush(); } }, - [scheduleScrollFlush], + [scheduleScrollFlush, cancelPendingScroll], ); useMouseEvents(handleMouseEvent, { isActive: hasFocus }); From 4fd13d3e90ffb8d5b387dd0d09d2bdcc1355d748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 18:57:59 +0800 Subject: [PATCH 5/8] fix(cli): keep a small height slack in VP mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/cli/src/ui/AppContainer.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a7a7864b053..ecec5eb9bd6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2685,15 +2685,16 @@ 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` are breathing room - // for the append-only region in legacy mode (they prevent the - // overflow flicker the non-VP path is prone to). VP mode owns the viewport - // through the React tree and clips natively, so reserving those rows just - // strands ~5 blank rows beneath the composer — the input never reaches the - // bottom of the terminal. Drop the reservation in VP so the composer sinks - // to the bottom; non-VP keeps it unchanged. + // `staticExtraHeight` (3) is pure -region overhead — meaningless in + // VP mode, which owns the viewport through the React tree and clips natively, + // so dropping it lets the composer sink to (near) the bottom instead of + // stranding ~5 blank rows beneath it. `MAIN_CONTENT_HEIGHT_RESERVATION` (2) + // stays in VP as a small slack: `controlsHeight` is measured one frame late + // (useLayoutEffect), so when the composer grows (multi-line input) the prior, + // smaller height would briefly oversize the list and overflow the terminal — + // the exact jitter this change set out to remove. Non-VP keeps both. const mainContentHeightReservation = useTerminalBuffer - ? 0 + ? MAIN_CONTENT_HEIGHT_RESERVATION : staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION; const availableTerminalHeight = Math.max( 0, From e44654aa7cd6e31cfa11014443f81dfccd754226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 18:58:03 +0800 Subject: [PATCH 6/8] 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 --- .../cli/src/ui/components/ThinkingViewer.tsx | 6 +-- .../messages/ConversationMessages.tsx | 34 +--------------- packages/cli/src/ui/utils/textUtils.ts | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/ui/components/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx index de30fffeec3..4ba8540a3c6 100644 --- a/packages/cli/src/ui/components/ThinkingViewer.tsx +++ b/packages/cli/src/ui/components/ThinkingViewer.tsx @@ -16,10 +16,8 @@ import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; import { AlternateScreen } from './AlternateScreen.js'; import type { ThinkingViewerData } from '../contexts/ThinkingViewerContext.js'; -import { - THINKING_ICON, - wrapToVisualLines, -} from './messages/ConversationMessages.js'; +import { THINKING_ICON } from './messages/ConversationMessages.js'; +import { wrapToVisualLines } from '../utils/textUtils.js'; import { formatDuration } from '../utils/displayUtils.js'; interface ThinkingViewerProps { diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index 429dbc04ac6..2b1093f3178 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -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; -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; -} - function tailVisualLines( text: string, width: number, diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index be1fdbbba33..4ca213e01ff 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -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 */ From 36d1f6bab41af5f249098e9d1769a767ec56a2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 19:24:19 +0800 Subject: [PATCH 7/8] 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 --- .../cli/src/ui/components/ThinkingViewer.tsx | 22 +++++-- .../components/shared/ScrollableList.test.tsx | 60 ++++++++++++++++--- .../ui/components/shared/ScrollableList.tsx | 48 ++++----------- .../src/ui/hooks/use-frame-coalesced-flush.ts | 55 +++++++++++++++++ 4 files changed, 137 insertions(+), 48 deletions(-) create mode 100644 packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts diff --git a/packages/cli/src/ui/components/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx index 4ba8540a3c6..99bab664002 100644 --- a/packages/cli/src/ui/components/ThinkingViewer.tsx +++ b/packages/cli/src/ui/components/ThinkingViewer.tsx @@ -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'; @@ -66,6 +67,17 @@ export const ThinkingViewer: FC = ({ [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) => { @@ -97,12 +109,14 @@ export const ThinkingViewer: FC = ({ 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 }, ); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index a1424fb9642..7e988cd7273 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -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(' 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(' 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(' 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(' 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(' 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(' 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 }) => {item.label}; + const Wrapper = () => ( + + hasFocus + data={makeItems(50)} + renderItem={renderItem} + estimatedItemHeight={estimatedItemHeight} + keyExtractor={keyExtractor} + initialScrollIndex={0} + containerHeight={5} + width={40} + showScrollbar + /> + ); + + const { stdin, lastFrame, rerender } = render(withKeypress()); + rerender(withKeypress()); + 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 }) => {item.label}; const Wrapper = () => ( @@ -255,7 +301,7 @@ describe(' mouse scrolling', () => { stdin.write(leftDrag(40, 5)); stdin.write(leftRelease(40, 5)); }); - await act(async () => {}); + await flushScrollFrame(); expect(lastFrame()).toBe(before); }); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index ec7616fa36c..c3a51ea6764 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -4,19 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - useRef, - forwardRef, - useImperativeHandle, - useCallback, - useEffect, -} from 'react'; +import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react'; import type React from 'react'; import { VirtualizedList, 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'; @@ -114,19 +109,14 @@ function ScrollableList( // 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. Coalesce a burst into a single viewport update per - // frame: accumulate the intent in refs and flush on a short timer. A drag is + // 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. Tests drive real timers and only - // await microtasks, so apply synchronously under NODE_ENV==='test' (the same - // escape hatch VirtualizedList uses for its readiness gate). - const SCROLL_FRAME_MS = 16; + // ticks); a drag in the same window wins. const pendingWheelDelta = useRef(0); const pendingDragRow = useRef(null); - const flushTimer = useRef | null>(null); const applyPendingScroll = useCallback(() => { - flushTimer.current = null; const list = virtualizedListRef.current; const dragRow = pendingDragRow.current; const wheelDelta = pendingWheelDelta.current; @@ -142,34 +132,18 @@ function ScrollableList( } }, []); - const scheduleScrollFlush = useCallback(() => { - if (process.env['NODE_ENV'] === 'test') { - applyPendingScroll(); - return; - } - if (flushTimer.current !== null) return; - flushTimer.current = setTimeout(applyPendingScroll, SCROLL_FRAME_MS); - }, [applyPendingScroll]); + 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 its timer and `scrollBy` the view away - // from the row the user just clicked. + // 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; - if (flushTimer.current !== null) { - clearTimeout(flushTimer.current); - flushTimer.current = null; - } - }, []); - - useEffect( - () => () => { - if (flushTimer.current !== null) clearTimeout(flushTimer.current); - }, - [], - ); + cancelScrollFlush(); + }, [cancelScrollFlush]); const handleMouseEvent = useCallback( (event: MouseEvent) => { diff --git a/packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts b/packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts new file mode 100644 index 00000000000..8d1a4d4a044 --- /dev/null +++ b/packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts @@ -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 | 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 }; +} From a06a308a78dbd947e747d0caae4126105ca31d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 29 Jun 2026 19:31:24 +0800 Subject: [PATCH 8/8] fix(cli): match legacy bottom spacing in VP mode (no reservation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 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 --- packages/cli/src/ui/AppContainer.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ecec5eb9bd6..c75efd89dc2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2685,16 +2685,17 @@ 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` (3) is pure -region overhead — meaningless in - // VP mode, which owns the viewport through the React tree and clips natively, - // so dropping it lets the composer sink to (near) the bottom instead of - // stranding ~5 blank rows beneath it. `MAIN_CONTENT_HEIGHT_RESERVATION` (2) - // stays in VP as a small slack: `controlsHeight` is measured one frame late - // (useLayoutEffect), so when the composer grows (multi-line input) the prior, - // smaller height would briefly oversize the list and overflow the terminal — - // the exact jitter this change set out to remove. Non-VP keeps both. + // `staticExtraHeight` + `MAIN_CONTENT_HEIGHT_RESERVATION` only cap how tall an + // *inline* streaming/pending message may grow before it commits to ; + // they do NOT reserve blank rows under the composer. In legacy mode completed + // history lives in (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 - ? MAIN_CONTENT_HEIGHT_RESERVATION + ? 0 : staticExtraHeight + MAIN_CONTENT_HEIGHT_RESERVATION; const availableTerminalHeight = Math.max( 0,