feat(cli): VP mode — inline thought expand on click + auto-hiding scrollbar (#6079)

* feat(cli): VP mode — inline thought expand on click + auto-hiding scrollbar

Two VP-mode (ui.useTerminalBuffer) UX improvements:

1. Thinking: clicking a thought now expands it inline, in place, instead of
   opening a full-screen modal. The expanded thought becomes part of the
   conversation and scrolls with it, matching the lighter inline pattern.
   A thought spans the `gemini_thought` head plus its trailing
   `gemini_thought_content` continuations, so expansion is keyed by the head
   id (buildThoughtHeadIdMap) and one click expands/collapses the whole group.
   Alt+T still toggles all thoughts at once.

   The full-screen ThinkingViewer modal (and its context) is removed: it only
   ever opened in VP, where an inline-expanded thought is already scrollable
   via the viewport, so it was redundant. Drops ThinkingViewer.tsx,
   ThinkingViewerContext.tsx, and the now-dead thinkingFullText plumbing.

2. Scrollbar: the VP scrollbar now auto-hides — it renders as blank cells while
   idle (keeping width 1 so the viewport never reflows) and pops in only while
   scrolling, then fades out. Adds `ui.showScrollbar` (default true) to hide it
   entirely.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): advertise click in the collapsed thought hint (VP only)

The collapsed thinking line only hinted "option+t to expand", so the new
click-to-expand affordance was undiscoverable. Show "(click or option+t to
expand)" when the click handler is actually active — i.e. VP mode
(ui.useTerminalBuffer) — and keep the plain "(option+t to expand)" in non-VP,
where clicking does nothing (native scrollback is preserved).

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:
ChiGao 2026-07-09 20:27:29 +08:00 committed by GitHub
parent ac2f371c44
commit c62b34433d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 224 additions and 384 deletions

View file

@ -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',

View file

@ -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<string[]>([]);
// Thinking viewer overlay state
const [thinkingViewerData, setThinkingViewerData] =
useState<ThinkingViewerData | null>(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<number>
>(() => new Set<number>());
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) => {
}}
>
<CompactModeProvider value={compactModeValue}>
<ThoughtExpandedProvider value={thoughtExpanded}>
<ThoughtExpandedProvider value={thoughtExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ThinkingViewerProvider value={thinkingViewerValue}>
<ShellFocusContext.Provider value={isFocused}>
{thinkingViewerData ? (
<ThinkingViewer
data={thinkingViewerData}
onClose={closeThinkingViewer}
useAlternateScreen={!useTerminalBuffer}
/>
) : (
<App />
)}
</ShellFocusContext.Provider>
</ThinkingViewerProvider>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</ThoughtExpandedProvider>

View file

@ -440,7 +440,13 @@ describe('<HistoryItemDisplay />', () => {
};
const { lastFrame } = renderWithProviders(
<ThoughtExpandedProvider value={true}>
<ThoughtExpandedProvider
value={{
allExpanded: true,
expandedHeadIds: new Set<number>(),
toggle: () => {},
}}
>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</ThoughtExpandedProvider>,
);

View file

@ -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<DOMElement>(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}
/>
</Box>
);
@ -199,12 +202,22 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
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<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'gemini_thought' && (
<ClickableThinkMessage
text={itemForDisplay.text.trimEnd()}
viewerText={(thinkingFullText || itemForDisplay.text).trimEnd()}
isPending={isPending}
expanded={resolvedThoughtExpanded}
availableTerminalHeight={
@ -277,6 +289,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
}
contentWidth={contentWidth}
durationMs={itemForDisplay.durationMs}
onToggle={() => toggleThought(thoughtGroupHeadId)}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (

View file

@ -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}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</OverflowProvider>
@ -430,7 +432,7 @@ export const MainContent = () => {
isPending={false}
commands={uiState.slashCommands}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
thinkingFullText={thinkingFullTextByItem.get(h)}
thoughtHeadId={thoughtHeadIdByItem.get(h)}
/>
),
),

View file

@ -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(
<ThinkingViewer
data={{ text: 'Inspecting the repository', durationMs: 1200 }}
onClose={() => {}}
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);
});
});

View file

@ -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<ThinkingViewerProps> = ({
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 (
<AlternateScreen disabled={!useAlternateScreen}>
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.text.secondary}
paddingX={1}
height={rows}
>
<Box>
<Text color={theme.text.accent} bold>
{THINKING_ICON}
{title}
</Text>
<Text dimColor>{scrollIndicator}</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
{visibleLines.map((line, i) => (
<Text key={i} dimColor wrap="truncate-end">
{line || ' '}
</Text>
))}
</Box>
<Box justifyContent="center">
<Text dimColor italic>
ESC {t('to close')} {' '} {t('to scroll')} {' '}PgUp/PgDn{' '}
Ctrl+Home/End
</Text>
</Box>
</Box>
</AlternateScreen>
);
};

View file

@ -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)}
/>
)),
]}

View file

@ -36,6 +36,25 @@ describe('<ThinkMessage />', () => {
expect(output).not.toContain('Analyzing the code structure');
});
it('advertises click in the collapsed hint only when clickable (VP mode)', () => {
const withoutClick = render(
<ThinkMessage {...defaultProps} isPending={false} expanded={false} />,
).lastFrame();
expect(withoutClick).not.toContain('click');
expect(withoutClick).toContain(`${toggleKeyHint} to expand`);
const withClick = render(
<ThinkMessage
{...defaultProps}
isPending={false}
expanded={false}
clickable={true}
/>,
).lastFrame();
expect(withClick).toContain('click');
expect(withClick).toContain(`${toggleKeyHint} to expand`);
});
it('should render full text when committed and expanded', () => {
const { lastFrame } = render(
<ThinkMessage {...defaultProps} isPending={false} expanded={true} />,

View file

@ -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<ThinkMessageProps> = ({
availableTerminalHeight,
contentWidth,
durationMs,
clickable = false,
}) => {
const durationSuffix =
durationMs != null ? ` ${formatDuration(durationMs)}` : '';
@ -298,10 +304,13 @@ export const ThinkMessage: React.FC<ThinkMessageProps> = ({
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 (
<Text dimColor italic>
{THINKING_ICON}
{label} {t('({{keyHint}} to expand)', { keyHint: toggleKeyHint })}
{label} {hint}
</Text>
);
}

View file

@ -833,13 +833,19 @@ function VirtualizedList<T>(
<Box width={1} flexDirection="column" flexShrink={0}>
{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 <Text key={i}> </Text>;
}
return (
<Text key={i} dimColor={!showActiveThumb}>
{showActiveThumb ? '█' : '│'}
<Text key={i} dimColor={!inThumb}>
{inThumb ? '█' : '│'}
</Text>
);
})}

View file

@ -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<ThinkingViewerContextType>({
openThinkingViewer: () => {},
});
export const useThinkingViewer = (): ThinkingViewerContextType =>
useContext(ThinkingViewerContext);
export const ThinkingViewerProvider = ThinkingViewerContext.Provider;

View file

@ -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<number>;
/** Toggle the per-thought expansion for a head id. */
toggle: (headId: number) => void;
}
export const useThoughtExpanded = (): boolean =>
const EMPTY_IDS: ReadonlySet<number> = new Set<number>();
const ThoughtExpandedContext = createContext<ThoughtExpandedValue>({
allExpanded: false,
expandedHeadIds: EMPTY_IDS,
toggle: () => {},
});
export const useThoughtExpanded = (): ThoughtExpandedValue =>
useContext(ThoughtExpandedContext);
export const ThoughtExpandedProvider = ThoughtExpandedContext.Provider;

View file

@ -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;

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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<HistoryItem, string> {
const map = new Map<HistoryItem, string>();
): Map<HistoryItem, number> {
const map = new Map<HistoryItem, number>();
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;

View file

@ -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) {

View file

@ -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",