mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
e2b85559a3
commit
f11bb31df2
8 changed files with 279 additions and 76 deletions
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
55
packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts
Normal file
55
packages/cli/src/ui/hooks/use-frame-coalesced-flush.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue