diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c6e9fc41d1..177f9508b5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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 ; + // 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 + ? 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/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx index 01fa6aa3a8..99bab66400 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'; @@ -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 = ({ 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 = ({ [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 = ({ 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/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index 88cd1b8ee3..2b1093f317 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; -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/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index a1424fb964..7e988cd727 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 372ee86821..c3a51ea676 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -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( // 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(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. diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 1dec241c21..986fd3005f 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 ( - + 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 }; +} diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index be1fdbbba3..4ca213e01f 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 */