mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
ac2f371c44
commit
c62b34433d
19 changed files with 224 additions and 384 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue