From 61deedf77c773c3145895965559de75653dd1baa Mon Sep 17 00:00:00 2001 From: ChiGao Date: Wed, 15 Jul 2026 08:19:20 +0800 Subject: [PATCH] feat(cli): VP mode UX improvements (#6885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): make VP mode banner scroll with content instead of pinning at top The banner (logo + model info + tips) was fixed at the viewport top, wasting ~7 lines on small terminals. Inject it as the first item in the virtual scroll list so it scrolls away as content grows, reclaiming space for actual conversation history. Generated with AI Co-authored-by: Qwen-Coder * fix(cli): use layoutRowForEvent in ClickableThinkMessage hit-test The click-to-expand handler on collapsed thought blocks used a bare `event.row - 1` for coordinate mapping. When the ink frame exceeds the terminal height (e.g. ShowMoreLines renders an extra row), the frame anchor becomes negative and the naive mapping always misses the 1-row-tall collapsed thought. Adopt the same layoutRowForEvent helper that RowMouseController uses, which correctly accounts for the frame anchor offset. Generated with AI Co-authored-by: Qwen-Coder * fix(cli): use type discriminant for banner sentinel + top-anchor empty session - Use `item.type === 'vp-banner'` uniformly in virtualKeyExtractor and virtualIsStaticItem instead of comparing against VP_BANNER_ID - Add comment documenting that index 0 is the banner sentinel in virtualEstimatedItemHeight (positional check is an API limitation) - Anchor to top (index 0) when banner is the only item, preventing the logo from being clipped off in short terminals on fresh sessions Generated with AI Co-authored-by: Qwen-Coder --------- Co-authored-by: 秦奇 Co-authored-by: Qwen-Coder --- .../src/ui/components/HistoryItemDisplay.tsx | 17 ++-- .../cli/src/ui/components/MainContent.tsx | 82 +++++++++++-------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index c64f5372064..8acdf11dffa 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -63,7 +63,11 @@ import { useSettings } from '../contexts/SettingsContext.js'; import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js'; import { useMouseEvents } from '../hooks/useMouseEvents.js'; import type { MouseEvent } from '../utils/mouse.js'; -import { measureElementPosition } from '../utils/measure-element-position.js'; +import { + measureElementPosition, + layoutRowForEvent, +} from '../utils/measure-element-position.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -117,12 +121,7 @@ const ClickableThinkMessage: React.FC<{ onToggle, }) => { const ref = useRef(null); - // Click toggles the thought's inline expansion in place (it then scrolls - // with the conversation). Click needs SGR mouse tracking; useMouseEvents - // enables it only in VP mode (no `bypassVpGate`), so in non-VP the handler - // stays dormant and native scrollback is preserved — the block still toggles - // via Alt+T. Advertise "click" in the collapsed hint only in VP, where the - // click actually does something. + const { rows: terminalHeight } = useTerminalSize(); const settings = useSettings(); const clickable = !!settings.merged.ui?.useTerminalBuffer; const isActive = !isPending; @@ -133,7 +132,7 @@ const ClickableThinkMessage: React.FC<{ if (event.name !== 'left-press' || !ref.current) return; const metrics = measureElementPosition(ref.current); const col = event.col - 1; - const row = event.row - 1; + const row = layoutRowForEvent(ref.current, event.row, terminalHeight); if ( col >= metrics.x && col < metrics.x + metrics.width && @@ -143,7 +142,7 @@ const ClickableThinkMessage: React.FC<{ onToggle(); } }, - [onToggle], + [onToggle, terminalHeight], ), { isActive }, ); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index a0b25752be7..b37647f3b3d 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Static, type DOMElement, useBoxMetrics } from 'ink'; +import { Box, Static } from 'ink'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { isHistoryItemVisibleAfterRestore } from '../types.js'; @@ -83,12 +83,26 @@ function initialReplayCount(length: number): number { // stable completed items when unrelated UIState fields change during streaming. const VirtualHistoryItem = memo(HistoryItemDisplay); +// Banner sentinel injected as the first virtual-scroll item so it scrolls with +// content instead of being pinned at the top (saves vertical space on small +// terminals). +type VpBannerItem = { type: 'vp-banner'; id: number }; +type VpItem = HistoryItem | VpBannerItem; +const VP_BANNER_ID = Number.MIN_SAFE_INTEGER; +const VP_BANNER_ITEM: VpBannerItem = { type: 'vp-banner', id: VP_BANNER_ID }; + // Pure functions with no closure deps — defined outside the component so they // are stable references and never trigger useMemo/useCallback invalidation. -const virtualEstimatedItemHeight = () => 3; -const virtualKeyExtractor = (item: HistoryItem) => - item.id >= 0 ? `h-${item.id}` : `p-${-item.id - 1}`; -const virtualIsStaticItem = (item: HistoryItem) => item.id > 0; +// index 0 is always the banner sentinel (VP_BANNER_ITEM is prepended first). +const virtualEstimatedItemHeight = (index: number) => (index === 0 ? 10 : 3); +const virtualKeyExtractor = (item: VpItem) => + item.type === 'vp-banner' + ? 'vp-banner' + : item.id >= 0 + ? `h-${item.id}` + : `p-${-item.id - 1}`; +const virtualIsStaticItem = (item: VpItem) => + item.type === 'vp-banner' || item.id > 0; export const MainContent = () => { const { version } = useAppContext(); @@ -243,9 +257,11 @@ export const MainContent = () => { : historyItemsWithSourceCopyOffsets.slice(0, replayCount); // Combine completed history + live pending items for the virtualized list. + // The banner sentinel is prepended so it scrolls with content (not pinned). // Pending items get negative IDs (-(i+1)) so renderItem can tell them apart. const allVirtualItems = useMemo( - (): HistoryItem[] => [ + (): VpItem[] => [ + VP_BANNER_ITEM, ...visibleHistory, ...pendingHistoryItems.map((item, i) => ({ ...item, id: -(i + 1) })), ], @@ -327,7 +343,16 @@ export const MainContent = () => { // Streaming-only state — including pending source-copy offsets — is read // from refs so callback identity is stable. const renderVirtualItem = useCallback( - ({ item }: { item: HistoryItem }) => { + ({ item }: { item: VpItem }) => { + if (item.type === 'vp-banner') { + return ( + + + + + + ); + } const isPending = item.id < 0; const sourceCopyIndexOffsets = isPending ? pendingSourceCopyOffsetsRef.current[-item.id - 1] @@ -366,6 +391,7 @@ export const MainContent = () => { ); }, [ + version, terminalWidth, mainAreaWidth, staticAreaMaxItemHeight, @@ -374,37 +400,29 @@ export const MainContent = () => { ], ); - const vpHeaderRef = useRef(null); - const { height: vpHeaderHeight } = useBoxMetrics(vpHeaderRef); - if (useVirtualScroll) { const scrollContainerHeight = Math.max( 0, - (uiState.availableTerminalHeight ?? 0) - vpHeaderHeight, + uiState.availableTerminalHeight ?? 0, ); return ( - <> - - - - - - - - - - + + + + ); }