diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 9c323850d3..864a6747d0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1027,6 +1027,16 @@ const SETTINGS_SCHEMA = { 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.', showInDialog: true, }, + showScrollbar: { + type: 'boolean', + label: 'Show Scrollbar (Virtualized History)', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely.', + showInDialog: true, + }, shellOutputMaxLines: { type: 'number', label: 'Shell Output Max Lines', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 03b1a51f8e..00f1d5cfce 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -204,11 +204,6 @@ import { type RenderMode, } from './contexts/RenderModeContext.js'; import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js'; -import { - ThinkingViewerProvider, - type ThinkingViewerData, -} from './contexts/ThinkingViewerContext.js'; -import { ThinkingViewer } from './components/ThinkingViewer.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { useBackgroundTaskViewState, @@ -565,18 +560,25 @@ export const AppContainer = (props: AppContainerProps) => { const [userMessages, setUserMessages] = useState([]); - // Thinking viewer overlay state - const [thinkingViewerData, setThinkingViewerData] = - useState(null); - const openThinkingViewer = useCallback((data: ThinkingViewerData) => { - setThinkingViewerData(data); - }, []); - const closeThinkingViewer = useCallback(() => { - setThinkingViewerData(null); - }, []); - - // Alt+T inline expansion toggle for thinking blocks + // Alt+T inline expansion toggle for thinking blocks (expands all at once). const [thoughtExpanded, setThoughtExpanded] = useState(false); + // Per-thought inline expansion: head ids the user expanded by clicking the + // collapsed thinking line (VP mode). Replaces the old full-screen viewer — + // the thought expands in place and scrolls with the conversation. + const [expandedThoughtHeadIds, setExpandedThoughtHeadIds] = useState< + ReadonlySet + >(() => new Set()); + const toggleThoughtExpanded = useCallback((headId: number) => { + setExpandedThoughtHeadIds((prev) => { + const next = new Set(prev); + if (next.has(headId)) { + next.delete(headId); + } else { + next.add(headId); + } + return next; + }); + }, []); // Terminal and layout hooks const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); @@ -1050,6 +1052,7 @@ export const AppContainer = (props: AppContainerProps) => { // re-reading `mergedHistory` / `allVirtualItems` on whatever state // change triggered refreshStatic (Ctrl+O, model change, etc.). const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false; + const showScrollbar = settings.merged.ui?.showScrollbar ?? true; const refreshStatic = useCallback(() => { if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); @@ -3414,16 +3417,6 @@ export const AppContainer = (props: AppContainerProps) => { debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key)); } - // ThinkingViewer owns all input while open. - // Ctrl+C / Ctrl+D close the viewer and fall through to quit/exit. - if (thinkingViewerData) { - if (keyMatchers[Command.QUIT](key) || keyMatchers[Command.EXIT](key)) { - closeThinkingViewer(); - } else { - return; - } - } - // Alt+T: toggle inline expansion of thinking blocks. if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) { setThoughtExpanded((prev) => !prev); @@ -3697,8 +3690,6 @@ export const AppContainer = (props: AppContainerProps) => { handleDoubleEscRewind, vimEnabled, vimMode, - thinkingViewerData, - closeThinkingViewer, setThoughtExpanded, ], ); @@ -3869,6 +3860,7 @@ export const AppContainer = (props: AppContainerProps) => { contextFileNames, availableTerminalHeight, useTerminalBuffer, + showScrollbar, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, @@ -4010,6 +4002,7 @@ export const AppContainer = (props: AppContainerProps) => { contextFileNames, availableTerminalHeight, useTerminalBuffer, + showScrollbar, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, @@ -4269,9 +4262,13 @@ export const AppContainer = (props: AppContainerProps) => { [renderMode, setRenderMode], ); - const thinkingViewerValue = useMemo( - () => ({ openThinkingViewer }), - [openThinkingViewer], + const thoughtExpandedValue = useMemo( + () => ({ + allExpanded: thoughtExpanded, + expandedHeadIds: expandedThoughtHeadIds, + toggle: toggleThoughtExpanded, + }), + [thoughtExpanded, expandedThoughtHeadIds, toggleThoughtExpanded], ); return ( @@ -4285,22 +4282,12 @@ export const AppContainer = (props: AppContainerProps) => { }} > - + - - - {thinkingViewerData ? ( - - ) : ( - - )} - - + + + diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 8ae260ca58..b7b29fd41b 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -440,7 +440,13 @@ describe('', () => { }; const { lastFrame } = renderWithProviders( - + (), + toggle: () => {}, + }} + > , ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 1b7b04a64f..99f7b57cbc 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -61,7 +61,6 @@ import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js'; import { GoalStatusMessage } from './messages/GoalStatusMessage.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js'; -import { useThinkingViewer } from '../contexts/ThinkingViewerContext.js'; import { useMouseEvents } from '../hooks/useMouseEvents.js'; import type { MouseEvent } from '../utils/mouse.js'; import { measureElementPosition } from '../utils/measure-element-position.js'; @@ -80,8 +79,12 @@ interface HistoryItemDisplayProps { sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; /** Force thinking blocks expanded (e.g. in SessionPreview). */ thoughtExpanded?: boolean; - /** Aggregated text from this thought + its continuation items. */ - thinkingFullText?: string; + /** + * Head id of the thought group this item belongs to (the `gemini_thought` + * head id for both the head and its `gemini_thought_content` continuations). + * Used to expand/collapse the whole group as a unit on click. + */ + thoughtHeadId?: number; } /** @@ -91,32 +94,31 @@ interface HistoryItemDisplayProps { */ const ClickableThinkMessage: React.FC<{ text: string; - viewerText: string; isPending: boolean; expanded: boolean; availableTerminalHeight?: number; contentWidth: number; durationMs?: number; + onToggle: () => void; }> = ({ text, - viewerText, isPending, expanded, availableTerminalHeight, contentWidth, durationMs, + onToggle, }) => { const ref = useRef(null); - const { openThinkingViewer } = useThinkingViewer(); - // Click-to-expand needs SGR mouse tracking. We do NOT pass `bypassVpGate`, so - // useMouseEvents enables it only in VP mode; in non-VP the click handler - // stays dormant and native terminal scrollback is preserved (the block still - // expands via Alt+T — the "option+t to expand" affordance it already shows). - const isActive = !isPending && !expanded; - const sanitizedViewerText = useMemo( - () => escapeAnsiCtrlCodes(viewerText), - [viewerText], - ); + // 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 settings = useSettings(); + const clickable = !!settings.merged.ui?.useTerminalBuffer; + const isActive = !isPending; useMouseEvents( useCallback( @@ -131,10 +133,10 @@ const ClickableThinkMessage: React.FC<{ row >= metrics.y && row < metrics.y + metrics.height ) { - openThinkingViewer({ text: sanitizedViewerText, durationMs }); + onToggle(); } }, - [openThinkingViewer, sanitizedViewerText, durationMs], + [onToggle], ), { isActive }, ); @@ -148,6 +150,7 @@ const ClickableThinkMessage: React.FC<{ availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} durationMs={durationMs} + clickable={clickable} /> ); @@ -199,12 +202,22 @@ const HistoryItemDisplayComponent: React.FC = ({ availableTerminalHeightGemini, sourceCopyIndexOffsets, thoughtExpanded, - thinkingFullText, + thoughtHeadId, }) => { const marginTop = getHistoryItemMarginTop(item); - const contextThoughtExpanded = useThoughtExpanded(); - const resolvedThoughtExpanded = thoughtExpanded ?? contextThoughtExpanded; + const { + allExpanded, + expandedHeadIds, + toggle: toggleThought, + } = useThoughtExpanded(); + // A thought spans the `gemini_thought` head plus its trailing + // `gemini_thought_content` items; all of them key off the head id so one + // click expands the whole group. Continuations receive the head id via + // `thoughtHeadId`; the head itself falls back to its own id. + const thoughtGroupHeadId = thoughtHeadId ?? item.id; + const resolvedThoughtExpanded = + thoughtExpanded ?? (allExpanded || expandedHeadIds.has(thoughtGroupHeadId)); const settings = useSettings(); const showTimestamps = settings.merged.output?.showTimestamps === true; @@ -269,7 +282,6 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'gemini_thought' && ( = ({ } contentWidth={contentWidth} durationMs={itemForDisplay.durationMs} + onToggle={() => toggleThought(thoughtGroupHeadId)} /> )} {itemForDisplay.type === 'gemini_thought_content' && ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index d2e0afd65a..5439517ee0 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -19,7 +19,7 @@ import { countMarkdownSourceBlocks, type MarkdownSourceCopyIndexOffsets, } from '../utils/MarkdownDisplay.js'; -import { buildThinkingFullTextMap } from '../utils/historyUtils.js'; +import { buildThoughtHeadIdMap } from '../utils/historyUtils.js'; import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js'; // Limit Gemini messages to a very high number of lines to mitigate performance @@ -92,6 +92,7 @@ const virtualIsStaticItem = (item: HistoryItem) => item.id > 0; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const showScrollbar = uiState.showScrollbar ?? true; const { pendingHistoryItems, terminalWidth, @@ -276,12 +277,12 @@ export const MainContent = () => { return map; }, [historyItemsWithSourceCopyOffsets]); - const thinkingFullTextByItem = useMemo( - () => buildThinkingFullTextMap(visibleHistory), + const thoughtHeadIdByItem = useMemo( + () => buildThoughtHeadIdMap(visibleHistory), [visibleHistory], ); - const thinkingFullTextByItemRef = useRef(thinkingFullTextByItem); - thinkingFullTextByItemRef.current = thinkingFullTextByItem; + const thoughtHeadIdByItemRef = useRef(thoughtHeadIdByItem); + thoughtHeadIdByItemRef.current = thoughtHeadIdByItem; const pendingSourceCopyOffsetsByIndex = useMemo( () => @@ -359,7 +360,7 @@ export const MainContent = () => { isPending={false} commands={uiState.slashCommands} sourceCopyIndexOffsets={sourceCopyIndexOffsets} - thinkingFullText={thinkingFullTextByItemRef.current.get(item)} + thoughtHeadId={thoughtHeadIdByItemRef.current.get(item)} /> ); }, @@ -398,6 +399,7 @@ export const MainContent = () => { initialScrollIndex={SCROLL_TO_ITEM_END} isStaticItem={virtualIsStaticItem} containerHeight={scrollContainerHeight} + showScrollbar={showScrollbar} /> @@ -430,7 +432,7 @@ export const MainContent = () => { isPending={false} commands={uiState.slashCommands} sourceCopyIndexOffsets={sourceCopyIndexOffsets} - thinkingFullText={thinkingFullTextByItem.get(h)} + thoughtHeadId={thoughtHeadIdByItem.get(h)} /> ), ), diff --git a/packages/cli/src/ui/components/ThinkingViewer.test.tsx b/packages/cli/src/ui/components/ThinkingViewer.test.tsx deleted file mode 100644 index 4b45d415ff..0000000000 --- a/packages/cli/src/ui/components/ThinkingViewer.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render } from 'ink-testing-library'; -import { ThinkingViewer } from './ThinkingViewer.js'; -import { useMouseEvents } from './../hooks/useMouseEvents.js'; - -// The modal viewer owns the wheel for its own scrolling and renders on the -// alternate screen in non-VP mode (no native scrollback to protect), so it must -// subscribe WITH `bypassVpGate: true`. Mirror of the HistoryItemDisplay test -// that pins the OPPOSITE contract for the click-to-expand handler. The gating -// logic itself lives in useMouseEvents (covered by its own test); here we only -// pin the option ThinkingViewer passes. -vi.mock('./../hooks/useMouseEvents.js', () => ({ - useMouseEvents: vi.fn(), -})); - -// Avoid the real terminal-size / keypress / frame-flush wiring — they are not -// part of the contract under test and would otherwise require a TTY + context. -vi.mock('./../hooks/useTerminalSize.js', () => ({ - useTerminalSize: () => ({ rows: 24, columns: 80 }), -})); -vi.mock('./../hooks/useKeypress.js', () => ({ - useKeypress: vi.fn(), -})); -vi.mock('./../hooks/use-frame-coalesced-flush.js', () => ({ - useFrameCoalescedFlush: () => ({ schedule: vi.fn(), cancel: vi.fn() }), -})); - -describe('ThinkingViewer mouse tracking', () => { - beforeEach(() => { - vi.mocked(useMouseEvents).mockClear(); - }); - - it('subscribes the wheel handler WITH bypassVpGate (works in non-VP)', () => { - render( - {}} - useAlternateScreen={false} - />, - ); - expect(vi.mocked(useMouseEvents)).toHaveBeenCalled(); - const opts = vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1]; - expect(opts?.isActive).toBe(true); - // Must bypass the VP gate, or wheel scrolling in the modal silently breaks - // in non-VP mode. - expect(opts?.bypassVpGate).toBe(true); - }); -}); diff --git a/packages/cli/src/ui/components/ThinkingViewer.tsx b/packages/cli/src/ui/components/ThinkingViewer.tsx deleted file mode 100644 index 1eb35b9bf6..0000000000 --- a/packages/cli/src/ui/components/ThinkingViewer.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { FC } 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'; -import { keyMatchers, Command } from '../keyMatchers.js'; -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 { wrapToVisualLines } from '../utils/textUtils.js'; -import { formatDuration } from '../utils/displayUtils.js'; - -interface ThinkingViewerProps { - data: ThinkingViewerData; - onClose: () => void; - /** When true, Ink already owns the alternate screen (VP mode) — skip escape writes. */ - useAlternateScreen?: boolean; -} - -const WHEEL_LINES = 3; - -export const ThinkingViewer: FC = ({ - data, - onClose, - useAlternateScreen = true, -}) => { - const { rows, columns } = useTerminalSize(); - const [scrollOffset, setScrollOffset] = useState(0); - - const headerHeight = 2; - const footerHeight = 2; - const contentHeight = Math.max(rows - headerHeight - footerHeight, 1); - - // 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(() => { - setScrollOffset((prev) => Math.min(prev, maxScroll)); - }, [maxScroll]); - - const scrollBy = useCallback( - (delta: number) => { - setScrollOffset((prev) => Math.max(0, Math.min(maxScroll, prev + delta))); - }, - [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) => { - if (keyMatchers[Command.ESCAPE](key)) { - onClose(); - } else if (keyMatchers[Command.SCROLL_UP](key) || key.name === 'up') { - scrollBy(-1); - } else if ( - keyMatchers[Command.SCROLL_DOWN](key) || - key.name === 'down' - ) { - scrollBy(1); - } else if (keyMatchers[Command.PAGE_UP](key)) { - scrollBy(-contentHeight); - } else if (keyMatchers[Command.PAGE_DOWN](key)) { - scrollBy(contentHeight); - } else if (keyMatchers[Command.SCROLL_HOME](key)) { - setScrollOffset(0); - } else if (keyMatchers[Command.SCROLL_END](key)) { - setScrollOffset(maxScroll); - } - }, - [onClose, scrollBy, contentHeight, maxScroll], - ), - { isActive: true }, - ); - - useMouseEvents( - useCallback( - (event: MouseEvent) => { - if (event.name === 'scroll-up') { - pendingWheelDelta.current -= WHEEL_LINES; - scheduleWheelFlush(); - } else if (event.name === 'scroll-down') { - pendingWheelDelta.current += WHEEL_LINES; - scheduleWheelFlush(); - } - }, - [scheduleWheelFlush], - ), - // Modal viewer renders on the alternate screen in non-VP mode (no native - // scrollback to protect), and owns the wheel for its own scrolling — opt - // out of the VP gate so it works in both VP and non-VP. - { isActive: true, bypassVpGate: true }, - ); - - const title = - data.durationMs != null - ? `${t('Thought for')} ${formatDuration(data.durationMs)}` - : t('Thinking'); - - const visibleLines = lines.slice(scrollOffset, scrollOffset + contentHeight); - const scrollPercent = - maxScroll > 0 ? Math.round((scrollOffset / maxScroll) * 100) : 0; - const scrollIndicator = maxScroll > 0 ? ` (${scrollPercent}%)` : ''; - - return ( - - - - - {THINKING_ICON} - {title} - - {scrollIndicator} - - - {visibleLines.map((line, i) => ( - - {line || ' '} - - ))} - - - - ESC {t('to close')} {' '}↑↓ {t('to scroll')} {' '}PgUp/PgDn{' '} - Ctrl+Home/End - - - - - ); -}; diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index 5771019985..c86b79d0d8 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -30,7 +30,7 @@ import { theme } from '../../semantic-colors.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; import { AgentHeader } from './AgentHeader.js'; -import { buildThinkingFullTextMap } from '../../utils/historyUtils.js'; +import { buildThoughtHeadIdMap } from '../../utils/historyUtils.js'; export interface AgentChatContentProps { /** The agent's AgentCore — the source of truth for transcript state. */ @@ -199,8 +199,8 @@ export const AgentChatContent = ({ const committedItems = allItems.slice(0, splitIndex); const pendingItems = allItems.slice(splitIndex); - const thinkingFullTextByItem = useMemo( - () => buildThinkingFullTextMap(allItems), + const thoughtHeadIdByItem = useMemo( + () => buildThoughtHeadIdMap(allItems), [allItems], ); @@ -238,7 +238,7 @@ export const AgentChatContent = ({ isPending={false} terminalWidth={terminalWidth} mainAreaWidth={contentWidth} - thinkingFullText={thinkingFullTextByItem.get(item)} + thoughtHeadId={thoughtHeadIdByItem.get(item)} /> )), ]} diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx index 8db9d900f5..0274e8e067 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.test.tsx @@ -36,6 +36,25 @@ describe('', () => { expect(output).not.toContain('Analyzing the code structure'); }); + it('advertises click in the collapsed hint only when clickable (VP mode)', () => { + const withoutClick = render( + , + ).lastFrame(); + expect(withoutClick).not.toContain('click'); + expect(withoutClick).toContain(`${toggleKeyHint} to expand`); + + const withClick = render( + , + ).lastFrame(); + expect(withClick).toContain('click'); + expect(withClick).toContain(`${toggleKeyHint} to expand`); + }); + it('should render full text when committed and expanded', () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index e7fca97eaa..e02675bc05 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -58,6 +58,11 @@ interface ThinkMessageProps { availableTerminalHeight?: number; contentWidth: number; durationMs?: number; + /** + * VP mode only: the collapsed line is mouse-clickable, so the hint advertises + * "click" in addition to the keyboard toggle. Non-VP has no click handler. + */ + clickable?: boolean; } interface ThinkMessageContentProps { @@ -289,6 +294,7 @@ export const ThinkMessage: React.FC = ({ availableTerminalHeight, contentWidth, durationMs, + clickable = false, }) => { const durationSuffix = durationMs != null ? ` ${formatDuration(durationMs)}` : ''; @@ -298,10 +304,13 @@ export const ThinkMessage: React.FC = ({ durationMs != null ? `${t('Thought for')} ${formatDuration(durationMs)}` : t('Thinking'); + const hint = clickable + ? t('(click or {{keyHint}} to expand)', { keyHint: toggleKeyHint }) + : t('({{keyHint}} to expand)', { keyHint: toggleKeyHint }); return ( {THINKING_ICON} - {label} {t('({{keyHint}} to expand)', { keyHint: toggleKeyHint })} + {label} {hint} ); } diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 986fd3005f..63426a5cc6 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -833,13 +833,19 @@ function VirtualizedList( {Array.from({ length: trackLen }, (_, i) => { const inThumb = i >= thumbTop && i < thumbTop + thumbLen; - // When the thumb is "active" (recent scroll), draw it bright - // (`█` without dimColor); otherwise collapse the thumb into a - // dim track glyph so the bar quietly disappears into the gutter. - const showActiveThumb = inThumb && scrollbarThumbActive; + // Overlay-style auto-hide: while idle (no recent scroll) the whole + // bar renders as blank cells so it doesn't sit permanently in the + // gutter competing with the conversation. On scroll it pops in — + // bright `█` thumb over a dim `│` track — then fades back to blank + // after the idle window. The column keeps width 1 in all states, so + // the viewport never reflows (which would force a per-item + // re-measure + visible jitter). + if (!scrollbarThumbActive) { + return ; + } return ( - - {showActiveThumb ? '█' : '│'} + + {inThumb ? '█' : '│'} ); })} diff --git a/packages/cli/src/ui/contexts/ThinkingViewerContext.tsx b/packages/cli/src/ui/contexts/ThinkingViewerContext.tsx deleted file mode 100644 index 96b71d64e7..0000000000 --- a/packages/cli/src/ui/contexts/ThinkingViewerContext.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createContext, useContext } from 'react'; - -export interface ThinkingViewerData { - text: string; - durationMs?: number; -} - -export interface ThinkingViewerContextType { - openThinkingViewer: (data: ThinkingViewerData) => void; -} - -const ThinkingViewerContext = createContext({ - openThinkingViewer: () => {}, -}); - -export const useThinkingViewer = (): ThinkingViewerContextType => - useContext(ThinkingViewerContext); - -export const ThinkingViewerProvider = ThinkingViewerContext.Provider; diff --git a/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx b/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx index 6be75f20cd..bc0fae585a 100644 --- a/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx +++ b/packages/cli/src/ui/contexts/ThoughtExpandedContext.tsx @@ -6,9 +6,29 @@ import { createContext, useContext } from 'react'; -const ThoughtExpandedContext = createContext(false); +export interface ThoughtExpandedValue { + /** Alt+T global toggle — expands every thinking block at once. */ + allExpanded: boolean; + /** + * Head ids of thoughts the user expanded individually (by clicking the + * collapsed line in VP mode). A "thought" is one `gemini_thought` head item + * plus its trailing `gemini_thought_content` continuations; all of them key + * off the head id so a single click expands the whole group. + */ + expandedHeadIds: ReadonlySet; + /** Toggle the per-thought expansion for a head id. */ + toggle: (headId: number) => void; +} -export const useThoughtExpanded = (): boolean => +const EMPTY_IDS: ReadonlySet = new Set(); + +const ThoughtExpandedContext = createContext({ + allExpanded: false, + expandedHeadIds: EMPTY_IDS, + toggle: () => {}, +}); + +export const useThoughtExpanded = (): ThoughtExpandedValue => useContext(ThoughtExpandedContext); export const ThoughtExpandedProvider = ThoughtExpandedContext.Provider; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 91c73a1367..2cd2c4ac82 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -130,6 +130,8 @@ export interface UIState { contextFileNames: string[]; availableTerminalHeight: number | undefined; useTerminalBuffer: boolean; + /** Whether the VP scrollbar is shown (auto-hides while idle). */ + showScrollbar?: boolean; mainAreaWidth: number; staticAreaMaxItemHeight: number; staticExtraHeight: number; diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts index 546d4712f1..1e8c3e00ca 100644 --- a/packages/cli/src/ui/hooks/useMouseEvents.ts +++ b/packages/cli/src/ui/hooks/useMouseEvents.ts @@ -33,9 +33,9 @@ export interface MouseEventsOptions { /** * Opt out of the VP gate. By default mouse tracking is enabled only in VP * mode (`ui.useTerminalBuffer`), so non-VP keeps native terminal scrollback. - * Set true for surfaces that own the wheel regardless — the VP viewport - * (ScrollableList) and alternate-screen modals (ThinkingViewer) — where - * there is no main-screen native scrollback to protect. + * Set true for surfaces that own the wheel regardless — e.g. the VP viewport + * (ScrollableList) — where there is no main-screen native scrollback to + * protect. */ bypassVpGate?: boolean; } diff --git a/packages/cli/src/ui/utils/historyUtils.test.ts b/packages/cli/src/ui/utils/historyUtils.test.ts index 1abafd5b17..3254f4b117 100644 --- a/packages/cli/src/ui/utils/historyUtils.test.ts +++ b/packages/cli/src/ui/utils/historyUtils.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest'; import type { HistoryItem } from '../types.js'; import { ToolCallStatus } from '../types.js'; import { - buildThinkingFullTextMap, + buildThoughtHeadIdMap, findLastUserItemIndex, isSyntheticHistoryItem, itemsAfterAreOnlySynthetic, @@ -123,48 +123,59 @@ describe('itemsAfterAreOnlySynthetic', () => { }); }); -describe('buildThinkingFullTextMap', () => { +describe('buildThoughtHeadIdMap', () => { it('returns empty map when no gemini_thought items exist', () => { const h: HistoryItem[] = [ mk({ type: 'user', text: 'hi' }, 1), mk({ type: 'gemini_content', text: 'hello' }, 2), ]; - expect(buildThinkingFullTextMap(h).size).toBe(0); + expect(buildThoughtHeadIdMap(h).size).toBe(0); }); - it('omits thought items with no continuations', () => { - const h: HistoryItem[] = [ - mk({ type: 'gemini_thought', text: 'thinking...' }, 1), - mk({ type: 'gemini_content', text: 'answer' }, 2), - ]; - expect(buildThinkingFullTextMap(h).size).toBe(0); - }); - - it('aggregates consecutive gemini_thought_content into the preceding thought', () => { - const thought = mk({ type: 'gemini_thought', text: 'header' }, 1); + it('maps a lone thought head to its own id', () => { + const thought = mk({ type: 'gemini_thought', text: 'thinking...' }, 1); const h: HistoryItem[] = [ thought, - mk({ type: 'gemini_thought_content', text: 'part1' }, 2), - mk({ type: 'gemini_thought_content', text: 'part2' }, 3), - mk({ type: 'gemini_content', text: 'answer' }, 4), + mk({ type: 'gemini_content', text: 'answer' }, 2), ]; - const map = buildThinkingFullTextMap(h); - expect(map.get(thought)).toBe('header\npart1\npart2'); + const map = buildThoughtHeadIdMap(h); + expect(map.get(thought)).toBe(1); + expect(map.size).toBe(1); }); - it('stops aggregation at the first non-continuation item', () => { - const thought1 = mk({ type: 'gemini_thought', text: 't1' }, 1); - const thought2 = mk({ type: 'gemini_thought', text: 't2' }, 4); + it('maps consecutive continuations to the preceding head id', () => { + const head = mk({ type: 'gemini_thought', text: 'header' }, 1); + const c1 = mk({ type: 'gemini_thought_content', text: 'part1' }, 2); + const c2 = mk({ type: 'gemini_thought_content', text: 'part2' }, 3); const h: HistoryItem[] = [ - thought1, - mk({ type: 'gemini_thought_content', text: 'c1' }, 2), - mk({ type: 'gemini_content', text: 'answer' }, 3), - thought2, - mk({ type: 'gemini_thought_content', text: 'c2' }, 5), + head, + c1, + c2, + mk({ type: 'gemini_content', text: 'answer' }, 4), ]; - const map = buildThinkingFullTextMap(h); - expect(map.get(thought1)).toBe('t1\nc1'); - expect(map.get(thought2)).toBe('t2\nc2'); + const map = buildThoughtHeadIdMap(h); + expect(map.get(head)).toBe(1); + expect(map.get(c1)).toBe(1); + expect(map.get(c2)).toBe(1); + }); + + it('stops grouping at the first non-continuation item', () => { + const head1 = mk({ type: 'gemini_thought', text: 't1' }, 1); + const c1 = mk({ type: 'gemini_thought_content', text: 'c1' }, 2); + const head2 = mk({ type: 'gemini_thought', text: 't2' }, 4); + const c2 = mk({ type: 'gemini_thought_content', text: 'c2' }, 5); + const h: HistoryItem[] = [ + head1, + c1, + mk({ type: 'gemini_content', text: 'answer' }, 3), + head2, + c2, + ]; + const map = buildThoughtHeadIdMap(h); + expect(map.get(head1)).toBe(1); + expect(map.get(c1)).toBe(1); + expect(map.get(head2)).toBe(4); + expect(map.get(c2)).toBe(4); }); }); diff --git a/packages/cli/src/ui/utils/historyUtils.ts b/packages/cli/src/ui/utils/historyUtils.ts index afe1c67743..c7f296cc79 100644 --- a/packages/cli/src/ui/utils/historyUtils.ts +++ b/packages/cli/src/ui/utils/historyUtils.ts @@ -128,29 +128,27 @@ export function findLastUserItemIndex(history: readonly HistoryItem[]): number { } /** - * For each `gemini_thought` item that is immediately followed by one or - * more `gemini_thought_content` continuations, build the concatenated - * full text (header + continuations joined by newlines). Items with no - * continuation are omitted from the result — callers fall back to - * `item.text` in that case. + * Map every thought item to the id of its group's `gemini_thought` head. + * + * A "thought" is one `gemini_thought` head followed by zero or more + * `gemini_thought_content` continuations. Both the head and its continuations + * map to the head id, so a single click on the head can expand/collapse the + * whole group as a unit (see the per-thought inline expansion in + * HistoryItemDisplay). */ -export function buildThinkingFullTextMap( +export function buildThoughtHeadIdMap( items: readonly HistoryItem[], -): Map { - const map = new Map(); +): Map { + const map = new Map(); for (let i = 0; i < items.length; i++) { const item = items[i]!; if (item.type !== 'gemini_thought') continue; - let fullText = item.text; - let hasContinuation = false; + const headId = item.id; + map.set(item, headId); for (let j = i + 1; j < items.length; j++) { const next = items[j]!; if (next.type !== 'gemini_thought_content') break; - fullText += '\n' + next.text; - hasContinuation = true; - } - if (hasContinuation) { - map.set(item, fullText); + map.set(next, headId); } } return map; diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 6a02c68b22..a0d6845ade 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -247,8 +247,8 @@ 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. + * every visual row, so callers that scroll an arbitrary offset can slice the + * rows the user actually sees. */ export function wrapToVisualLines(text: string, width: number): string[] { if (width <= 0) { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index cbea4e3c36..cf500bf3e8 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -376,6 +376,11 @@ "type": "boolean", "default": false }, + "showScrollbar": { + "description": "Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely.", + "type": "boolean", + "default": true + }, "shellOutputMaxLines": { "description": "Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.", "type": "number",