feat(ui): enable "TerminalBuffer" mode to solve flicker (#24512)

This commit is contained in:
Jacob Richman
2026-04-02 17:39:49 -07:00
committed by GitHub
parent 1ae0499e5d
commit 1f5d7014c6
53 changed files with 694 additions and 286 deletions
+22 -11
View File
@@ -346,6 +346,7 @@ describe('AppContainer State Management', () => {
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
capturedUIState = null!;
@@ -470,6 +471,7 @@ describe('AppContainer State Management', () => {
// Mock Config
mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false);
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
@@ -1356,6 +1358,7 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
// Reset mock stdout for each test
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
});
it('verifies useStdout is mocked', async () => {
@@ -2459,7 +2462,7 @@ describe('AppContainer State Management', () => {
});
});
describe('Copy Mode (CTRL+S)', () => {
describe('Copy Mode (F9)', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
@@ -2468,6 +2471,8 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
@@ -2512,6 +2517,8 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
vi.useFakeTimers();
});
@@ -2532,12 +2539,13 @@ describe('AppContainer State Management', () => {
modeName: 'Alternate Buffer Mode',
},
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
(disableMouseEvents as import('vitest').Mock).mockClear();
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2550,13 +2558,13 @@ describe('AppContainer State Management', () => {
});
if (shouldEnable) {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
it('should toggle mouse back on when F9 is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
// Turn it on (disable mouse)
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
@@ -2576,7 +2584,7 @@ describe('AppContainer State Management', () => {
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2656,7 +2664,7 @@ describe('AppContainer State Management', () => {
// 2. Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -3093,6 +3101,7 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
const { unmount } = await act(async () => renderAppContainer());
@@ -3135,16 +3144,13 @@ describe('AppContainer State Management', () => {
// Reset mock stdout to clear any initial writes
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
// Should be reset
expect(capturedUIState.constrainHeight).toBe(true);
// Should refresh static (which clears terminal in non-alternate buffer)
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
unmount();
});
@@ -3154,6 +3160,8 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
@@ -3170,6 +3178,7 @@ describe('AppContainer State Management', () => {
// Reset mock stdout
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
@@ -3403,6 +3412,8 @@ describe('AppContainer State Management', () => {
ui: { useAlternateBuffer: true },
});
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
+81 -5
View File
@@ -11,6 +11,7 @@ import {
useEffect,
useRef,
useLayoutEffect,
useContext,
} from 'react';
import {
type DOMElement,
@@ -19,6 +20,7 @@ import {
useStdout,
useStdin,
type AppProps,
AppContext as InkAppContext,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
@@ -38,6 +40,8 @@ import {
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
@@ -210,12 +214,30 @@ export const AppContainer = (props: AppContainerProps) => {
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
useContext(InkAppContext);
const recordingFilenameRef = useRef<string | null>(null);
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [mouseMode, setMouseMode] = useState(() =>
config.getUseAlternateBuffer(),
);
useEffect(() => {
setOptions({
stickyHeadersInBackbuffer: mouseMode,
});
if (mouseMode) {
enableMouseEvents();
} else {
disableMouseEvents();
}
}, [mouseMode, setOptions]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -621,11 +643,11 @@ export const AppContainer = (props: AppContainerProps) => {
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
if (!isAlternateBuffer && !config.getUseTerminalBuffer()) {
stdout.write(ansiEscapes.clearTerminal);
setHistoryRemountKey((prev) => prev + 1);
}
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
}, [setHistoryRemountKey, isAlternateBuffer, stdout, config]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
@@ -1433,6 +1455,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
!proQuotaRequest;
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
@@ -1731,6 +1761,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShortcutsHelpVisible(false);
}
if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) {
setMouseMode((prev) => !prev);
if (mouseMode && !isAlternateBuffer) {
appEvents.emit(AppEvent.ScrollToBottom);
}
return true;
}
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
@@ -1753,6 +1791,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
} else if (keyMatchers[Command.DUMP_FRAME](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `snapshot-${timestamp}.json`;
if (dumpCurrentFrame) {
dumpCurrentFrame(filename);
debugLogger.log(`Dumped frame to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.START_RECORDING](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `recording-${timestamp}.json`;
if (startRecording) {
startRecording(filename);
recordingFilenameRef.current = filename;
debugLogger.log(`Started recording to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.STOP_RECORDING](key)) {
if (stopRecording) {
stopRecording();
debugLogger.log(
`Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`,
);
recordingFilenameRef.current = null;
}
return true;
} else if (
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer
@@ -1939,6 +2003,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
dumpCurrentFrame,
startRecording,
stopRecording,
mouseMode,
],
);
@@ -1958,7 +2026,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setCopyModeEnabled(false);
enableMouseEvents();
if (mouseMode) {
enableMouseEvents();
}
return true;
},
{
@@ -2275,6 +2345,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2401,6 +2472,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2701,7 +2773,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
@@ -55,12 +55,6 @@ Footer
Gemini CLI v1.2.3
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
Composer
"
`;
@@ -26,6 +26,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { appEvents, AppEvent } from '../../utils/events.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
@@ -55,6 +56,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const { setShortcutsHelpVisible } = uiActions;
useEffect(() => {
if (hasPendingActionRequired) {
appEvents.emit(AppEvent.ScrollToBottom);
}
}, [hasPendingActionRequired]);
useEffect(() => {
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
@@ -166,6 +166,7 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
@@ -18,7 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
}));
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedRows = vi.hoisted(() => ({ current: 24 }));
vi.mock('node:process', async () => {
@@ -85,7 +85,7 @@ describe('FolderTrustDialog', () => {
);
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('hidden');
expect(lastFrame()).not.toContain('cmd9');
unmount();
});
@@ -116,7 +116,7 @@ describe('FolderTrustDialog', () => {
// With maxHeight=4, the intro text (4 lines) will take most of the space.
// The discovery results will likely be hidden.
expect(lastFrame()).toContain('hidden');
expect(lastFrame()).not.toContain('cmd1');
unmount();
});
@@ -145,7 +145,7 @@ describe('FolderTrustDialog', () => {
},
);
expect(lastFrame()).toContain('hidden');
expect(lastFrame()).not.toContain('cmd1');
unmount();
});
@@ -178,10 +178,11 @@ describe('FolderTrustDialog', () => {
// Initial state: truncated
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain('Press Ctrl+O');
expect(lastFrame()).toContain('hidden');
expect(lastFrame()).not.toContain('cmd9');
});
unmount();
// We can't easily simulate global Ctrl+O toggle in this unit test
// because it's handled in AppContainer.
// But we can re-render with constrainHeight: false.
@@ -195,7 +196,7 @@ describe('FolderTrustDialog', () => {
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: false, terminalHeight: 24 },
uiState: { constrainHeight: false, terminalHeight: 50 },
},
);
@@ -205,7 +206,6 @@ describe('FolderTrustDialog', () => {
expect(lastFrameExpanded()).toContain('- cmd4');
});
unmount();
unmountExpanded();
});
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Help Component', () => {
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Shift+Tab');
expect(output).toContain('Page Up/Page Down');
unmount();
});
@@ -338,6 +338,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
useEffect(() => {
appEvents.emit(AppEvent.ScrollToBottom);
}, [buffer.text, buffer.cursor]);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
+90 -67
View File
@@ -12,6 +12,7 @@ import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
@@ -22,6 +23,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
import { appEvents, AppEvent } from '../../utils/events.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -33,7 +35,10 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
const config = useConfig();
const useTerminalBuffer = config.getUseTerminalBuffer();
const isAlternateBuffer = config.getUseAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
@@ -47,12 +52,23 @@ export const MainContent = () => {
}
}, [showConfirmationQueue, confirmingToolCallId]);
useEffect(() => {
const handleScroll = () => {
scrollableListRef.current?.scrollToEnd();
};
appEvents.on(AppEvent.ScrollToBottom, handleScroll);
return () => {
appEvents.off(AppEvent.ScrollToBottom, handleScroll);
};
}, []);
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
mouseMode,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
@@ -228,27 +244,14 @@ export const MainContent = () => {
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
isToolGroupBoundary,
suppressNarration,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
isToolGroupBoundary,
suppressNarration,
}),
),
...augmentedHistory.map((data, index) => ({
type: 'history' as const,
item: data.item,
element: historyItems[index],
})),
{ type: 'pending' as const },
],
[augmentedHistory],
[augmentedHistory, historyItems],
);
const renderItem = useCallback(
@@ -262,59 +265,79 @@ export const MainContent = () => {
/>
);
} else if (item.type === 'history') {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
isToolGroupBoundary={item.isToolGroupBoundary}
suppressNarration={item.suppressNarration}
/>
);
return item.element;
} else {
return pendingItems;
}
},
[
showHeaderDetails,
version,
mainAreaWidth,
uiState.slashCommands,
pendingItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
],
[showHeaderDetails, version, pendingItems],
);
if (isAlternateBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
keyExtractor={(item, _index) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
}}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
);
const estimatedItemHeight = useCallback(() => 100, []);
const keyExtractor = useCallback(
(item: (typeof virtualizedData)[number], _index: number) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
},
[],
);
// TODO(jacobr): we should return true for all messages that are not
// interactive. Gemini messages and Tool results that are not scrollable,
// collapsible, or clickable should also be tagged as static in the future.
const isStaticItem = useCallback(
(item: (typeof virtualizedData)[number]) => item.type === 'header',
[],
);
const scrollableList = useMemo(() => {
if (isAlternateBufferOrTerminalBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={
!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused
}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={estimatedItemHeight}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
renderStatic={useTerminalBuffer}
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
scrollbar={mouseMode}
/>
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
// as that will reduce the # of cases where we will have to clear the
// scrollback buffer due to the scrollback size changing but we need to
// work out ensuring we only attempt it within a smaller range of
// scrollback vals. Right now it sometimes triggers adding more white
// space than it should.
);
}
return null;
}, [
isAlternateBufferOrTerminalBuffer,
uiState.isEditorDialogOpen,
uiState.embeddedShellFocused,
uiState.terminalWidth,
virtualizedData,
renderItem,
estimatedItemHeight,
keyExtractor,
useTerminalBuffer,
isStaticItem,
mouseMode,
isAlternateBuffer,
]);
if (isAlternateBufferOrTerminalBuffer) {
return scrollableList;
}
return (
@@ -22,7 +22,7 @@ import * as processUtils from '../../utils/processUtils.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
+15 -1
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
@@ -77,6 +77,13 @@ export const StatusNode: React.FC<{
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
@@ -169,6 +176,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
tipObserverRef.current?.disconnect();
},
[],
);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
@@ -59,6 +59,7 @@ describe('ToolConfirmationQueue', () => {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -112,7 +112,48 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block:
... 42 hidden (Ctrl+O) ...
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
@@ -126,7 +167,48 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block:
... 42 hidden (Ctrl+O) ...
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
@@ -6,6 +6,8 @@
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { waitFor } from '../../../test-utils/async.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import {
CoreToolCallStatus,
@@ -21,8 +23,6 @@ import type {
ToolResultDisplay,
} from '../../types.js';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
@@ -92,17 +92,22 @@ describe('DenseToolMessage', () => {
model_removed_chars: 40,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
{
settings: createMockSettings({
merged: { useAlternateBuffer: false, useTerminalBuffer: false },
}),
},
);
await waitFor(() => expect(lastFrame()).toContain('test-tool'));
await waitFor(() =>
expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'),
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test.ts → Accepted (+15, -6)');
expect(output).toContain('diff content');
expect(output).toMatchSnapshot();
});
@@ -134,7 +139,6 @@ describe('DenseToolMessage', () => {
expect(output).toContain('Edit');
expect(output).toContain('styles.scss');
expect(output).toContain('→ Confirming');
expect(output).toContain('body { color: red; }');
expect(output).toMatchSnapshot();
});
@@ -169,8 +173,6 @@ describe('DenseToolMessage', () => {
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Rejected (+1, -1)');
expect(output).toContain('- old line');
expect(output).toContain('+ new line');
expect(output).toMatchSnapshot();
});
@@ -245,7 +247,6 @@ describe('DenseToolMessage', () => {
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('config.json → Accepted (+1, -1)');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
@@ -271,8 +272,6 @@ describe('DenseToolMessage', () => {
expect(output).toContain('WriteFile');
expect(output).toContain('config.json');
expect(output).toContain('→ Rejected');
expect(output).toContain('- old content');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
@@ -499,7 +498,6 @@ describe('DenseToolMessage', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Accepted');
expect(output).toContain('new line');
expect(output).toMatchSnapshot();
});
@@ -283,13 +283,18 @@ describe('<ShellToolMessage />', () => {
uiActions,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: {
constrainHeight: false,
terminalHeight: 200,
},
},
);
await waitUntilReady();
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
// Since it's Executing, it might still constrain to ACTIVE_SHELL_MAX_LINES (10)
// Actually let's just assert on the behaviour that happens right now (which is 10 lines)
expect(frame.match(/Line \d+/g)?.length).toBe(10);
unmount();
});
@@ -90,6 +90,13 @@ export const ToolConfirmationMessage: React.FC<
useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
@@ -450,11 +450,11 @@ describe('<ToolMessage />', () => {
const output = lastFrame();
// Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15)
// and show the FIRST lines (overflowDirection='bottom')
expect(output).toContain('Line 1');
expect(output).toContain('Line 14');
expect(output).not.toContain('Line 16');
expect(output).not.toContain('Line 30');
// It should constrain the height, showing the tail of the output (overflowDirection='top' or due to scroll)
expect(output).not.toMatch(/Line 1\b/);
expect(output).not.toMatch(/Line 14\b/);
expect(output).toMatch(/Line 16\b/);
expect(output).toMatch(/Line 30\b/);
unmount();
});
@@ -116,7 +116,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
await waitUntilReady();
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
await waitFor(() => expect(lastFrame()).not.toContain('line 1\n'));
unmount();
});
@@ -229,6 +229,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
fixedItemHeight={true}
/>
</Box>
);
@@ -23,18 +23,17 @@ describe('ToolResultDisplay Overflow', () => {
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
uiState: { constrainHeight: true, terminalHeight: 50 },
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).not.toContain('Line 3'); // Line 3 is replaced by the "hidden" label
expect(output).not.toContain('Line 4');
expect(output).not.toContain('Line 5');
expect(output).toContain('hidden');
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
unmount();
});
@@ -50,7 +49,7 @@ describe('ToolResultDisplay Overflow', () => {
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
uiState: { constrainHeight: true, terminalHeight: 50 },
},
);
await waitUntilReady();
@@ -58,10 +57,9 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('hidden');
unmount();
});
@@ -88,7 +86,7 @@ describe('ToolResultDisplay Overflow', () => {
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
uiState: { constrainHeight: true, terminalHeight: 50 },
},
);
await waitUntilReady();
@@ -1,33 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<rect width="920" height="37" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">Accepted</text>
<text x="171" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="2" fill="#d7afff" textLength="72" lengthAdjust="spacingAndGlyphs" text-decoration="underline">Accepted</text>
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -7,21 +7,12 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
" ✓ edit test.ts → Accepted (+1, -1)
1 - old
1 + new
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `" ✓ edit test.ts → Accepted (+1, -1)"`;
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
" o test-tool Test description
@@ -35,17 +26,11 @@ exports[`DenseToolMessage > flattens newlines in string results 1`] = `
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
" ? Edit styles.scss → Confirming
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
" x Edit styles.scss → Failed (+1, -1)
1 - old line
1 + new line
"
`;
@@ -60,33 +45,21 @@ exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
" - WriteFile config.json → Rejected
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
" ✓ WriteFile config.json → Accepted (+1, -1)
1 - old content
1 + new content
"
`;
@@ -102,9 +75,6 @@ exports[`DenseToolMessage > renders correctly for error status with string messa
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
" ✓ test-tool test.ts → Accepted (+15, -6)
1 - old line
1 + diff content
"
`;
@@ -16,11 +16,11 @@ exports[`ToolResultDisplay > renders ANSI output result 1`] = `
`;
exports[`ToolResultDisplay > renders file diff result 1`] = `
"╭─────────────────────────────────────────────────────────────────────────
│ No changes detected.
╰─────────────────────────────────────────────────────────────────────────
"╭─────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰─────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -72,20 +72,18 @@ Line 50 █"
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 250 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaa
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… █
"
`;
@@ -42,6 +42,14 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [contentHeight, setContentHeight] = useState(0);
const onRefChange = useCallback(
@@ -33,6 +33,9 @@ interface ScrollableProps {
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -45,6 +48,9 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollToBottom,
flexGrow,
reportOverflow = false,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
@@ -91,6 +97,14 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
viewportObserverRef.current?.disconnect();
contentObserverRef.current?.disconnect();
},
[],
);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
@@ -247,6 +261,9 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTop={scrollTop}
flexGrow={flexGrow}
scrollbarThumbColor={scrollbarColor}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
{/*
This inner box is necessary to prevent the parent from shrinking
@@ -16,6 +16,7 @@ import type React from 'react';
import {
VirtualizedList,
type VirtualizedListRef,
type VirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
@@ -27,18 +28,14 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
};
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
@@ -48,7 +45,7 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -258,17 +255,13 @@ function ScrollableList<T>(
useScrollable(scrollableEntry, true);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
width={width}
>
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
/>
</Box>
);
@@ -17,13 +17,6 @@ import {
useState,
} from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { UIState } from '../../contexts/UIStateContext.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
copyModeEnabled: false,
})),
}));
describe('<VirtualizedList />', () => {
const keyExtractor = (item: string) => item;
@@ -324,11 +317,6 @@ describe('<VirtualizedList />', () => {
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
const { useUIState } = await import('../../contexts/UIStateContext.js');
vi.mocked(useUIState).mockReturnValue({
copyModeEnabled: true,
} as Partial<UIState> as UIState);
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
@@ -343,6 +331,7 @@ describe('<VirtualizedList />', () => {
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
/>
</Box>,
);
@@ -12,17 +12,17 @@ import {
useImperativeHandle,
useMemo,
useCallback,
memo,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, Box, ResizeObserver } from 'ink';
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
type VirtualizedListProps<T> = {
export type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
@@ -30,6 +30,15 @@ type VirtualizedListProps<T> = {
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
scrollbarThumbColor?: string;
renderStatic?: boolean;
isStatic?: boolean;
isStaticItem?: (item: T, index: number) => boolean;
width?: number | string;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
};
export type VirtualizedListRef<T> = {
@@ -66,6 +75,43 @@ function findLastIndex<T>(
return -1;
}
const VirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
containerWidth,
itemKey,
itemRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
itemRef: (el: DOMElement | null) => void;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
),
);
VirtualizedListItem.displayName = 'VirtualizedListItem';
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
@@ -77,8 +123,16 @@ function VirtualizedList<T>(
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
renderStatic,
isStatic,
isStaticItem,
width,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
copyModeEnabled = false,
fixedItemHeight = false,
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
dataRef.current = data;
@@ -119,6 +173,7 @@ function VirtualizedList<T>(
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
@@ -133,7 +188,10 @@ function VirtualizedList<T>(
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
const newHeight = Math.round(entry.contentRect.height);
const newWidth = Math.round(entry.contentRect.width);
setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));
setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));
}
});
observer.observe(node);
@@ -242,7 +300,9 @@ function VirtualizedList<T>(
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
const listGrew = data.length > prevDataLength.current;
@@ -253,10 +313,16 @@ function VirtualizedList<T>(
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
const newIndex = data.length > 0 ? data.length - 1 : 0;
if (
scrollAnchor.index !== newIndex ||
scrollAnchor.offset !== SCROLL_TO_ITEM_END
) {
setScrollAnchor({
index: newIndex,
offset: SCROLL_TO_ITEM_END,
});
}
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
@@ -266,9 +332,17 @@ function VirtualizedList<T>(
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
if (
scrollAnchor.index !== newAnchor.index ||
scrollAnchor.offset !== newAnchor.offset
) {
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
setScrollAnchor({ index: 0, offset: 0 });
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
setScrollAnchor({ index: 0, offset: 0 });
}
}
prevDataLength.current = data.length;
@@ -281,6 +355,7 @@ function VirtualizedList<T>(
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -348,15 +423,22 @@ function VirtualizedList<T>(
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight = offsets[startIndex] ?? 0;
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const topSpacerHeight =
renderStatic === true || overflowToBackbuffer === true
? 0
: (offsets[startIndex] ?? 0);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
for (let i = startIndex; i <= endIndex; i++) {
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
const observeEnd = renderStatic ? data.length - 1 : endIndex;
for (let i = observeStart; i <= observeEnd; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
@@ -364,14 +446,16 @@ function VirtualizedList<T>(
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
if (!isStatic && !fixedItemHeight) {
itemsObserver.unobserve(node);
}
nodeToKeyRef.current.delete(node);
}
}
@@ -379,22 +463,49 @@ function VirtualizedList<T>(
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
if (item) {
renderedItems.push(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
>
{renderItem({ item, index: i })}
</Box>,
);
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
// Wait, if it's not static and no width we need to wait for measure.
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
const isReady =
containerHeight > 0 ||
process.env['NODE_ENV'] === 'test' ||
(width !== undefined && typeof width === 'number');
if (isReady) {
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
const isOutsideViewport = i < startIndex || i > endIndex;
const shouldBeStatic =
(renderStatic === true && isOutsideViewport) ||
isStaticItem?.(item, i) === true;
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
renderedItems.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
itemRef={(el: DOMElement | null) => {
if (i >= renderRangeStart && i <= renderRangeEnd) {
itemRefs.current[i] = el;
}
}}
/>,
);
}
}
}
@@ -539,6 +650,9 @@ function VirtualizedList<T>(
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box
flexShrink={0}
@@ -117,6 +117,7 @@ export interface UIState {
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
mouseMode: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
@@ -191,7 +192,7 @@ export interface UIState {
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.RefCallback<DOMElement | null>;
mainControlsRef: (node: DOMElement | null) => void;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
@@ -28,6 +28,7 @@ describe('useAlternateBuffer', () => {
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -37,6 +38,7 @@ describe('useAlternateBuffer', () => {
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -46,6 +48,7 @@ describe('useAlternateBuffer', () => {
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
@@ -65,6 +68,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
@@ -73,6 +77,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
@@ -7,8 +7,13 @@
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
// This method is intentionally misleading while we migrate.
// Once getUseTerminalBuffer() is always enabled we will refactor to remove
// all instances of this method making it the only path.
// Right now this is convenient as it allows us to special case terminalBuffer
// rendering like we special case alternateBuffer rendering.
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
config.getUseAlternateBuffer() || config.getUseTerminalBuffer();
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useLayoutEffect, useRef, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import { interpolateColor } from '../themes/color-utils.js';
import { debugState } from '../debug.js';
@@ -107,7 +107,7 @@ export function useAnimatedScrollbar(
}, [cleanup]);
const wasFocused = useRef(isFocused);
useEffect(() => {
useLayoutEffect(() => {
if (isFocused && !wasFocused.current) {
flashScrollbar();
} else if (!isFocused && wasFocused.current) {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useLayoutEffect, useCallback } from 'react';
/**
* A hook to manage batched scroll state updates.
@@ -17,7 +17,7 @@ export function useBatchedScroll(currentScrollTop: number) {
// and not depend on the currentScrollTop value directly in its dependency array.
const currentScrollTopRef = useRef(currentScrollTop);
useEffect(() => {
useLayoutEffect(() => {
currentScrollTopRef.current = currentScrollTop;
pendingScrollTopRef.current = null;
});
@@ -28,7 +28,7 @@ import * as trustedFolders from '../../config/trustedFolders.js';
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedExit = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async () => {
@@ -24,7 +24,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { coreEvents } from '@google/gemini-cli-core';
// Hoist mocks
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockedUseSettings = vi.hoisted(() => vi.fn());
+19 -1
View File
@@ -85,6 +85,7 @@ export enum Command {
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
TOGGLE_MOUSE_MODE = 'app.toggleMouseMode',
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
@@ -109,6 +110,10 @@ export enum Command {
// Extension Controls
UPDATE_EXTENSION = 'extension.update',
LINK_EXTENSION = 'extension.link',
DUMP_FRAME = 'app.dumpFrame',
START_RECORDING = 'app.startRecording',
STOP_RECORDING = 'app.stopRecording',
}
/**
@@ -385,7 +390,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
@@ -396,6 +402,9 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
[Command.START_RECORDING, [new KeyBinding('f6')]],
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -512,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.TOGGLE_MOUSE_MODE,
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
@@ -535,6 +545,9 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.DUMP_FRAME,
Command.START_RECORDING,
Command.STOP_RECORDING,
],
},
{
@@ -621,6 +634,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_MOUSE_MODE]: 'Toggle mouse mode (scrolling and clicking).',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -654,6 +668,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Extension Controls
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
[Command.DUMP_FRAME]: 'Dump the current frame as a snapshot.',
[Command.START_RECORDING]: 'Start recording the session.',
[Command.STOP_RECORDING]: 'Stop recording the session.',
};
const keybindingsSchema = z.array(
@@ -346,6 +346,11 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('f9')],
negative: [createKey('f8'), createKey('f10')],
},
{
command: Command.TOGGLE_MOUSE_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
@@ -34,7 +34,6 @@ export const DefaultAppLayout: React.FC = () => {
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
@@ -21,6 +21,7 @@ describe('ui-sizing', () => {
(expected, width, altBuffer) => {
const mockConfig = {
getUseAlternateBuffer: () => altBuffer,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
},