Compare commits

...

1 Commits

Author SHA1 Message Date
mkorwel 32b098697b WIP 2026-03-29 12:10:03 -07:00
61 changed files with 364 additions and 13944 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import v8 from 'node:v8';
import path from 'node:path';
import os from 'node:os';
import { MessageType } from '../types.js';
import {
CommandKind,
type SlashCommand,
} from './types.js';
export const debugHeapDumpCommand: SlashCommand = {
name: 'debug-heap-dump',
description: 'Generate a V8 heap snapshot for memory analysis',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Generating heap snapshot...',
},
Date.now(),
);
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gemini-cli-heap-${timestamp}.heapsnapshot`;
const filepath = path.join(os.tmpdir(), filename);
v8.writeHeapSnapshot(filepath);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Heap snapshot generated successfully: ${filepath}\n\nTo analyze:\n1. Open Chrome DevTools (any tab).\n2. Go to the "Memory" tab.\n3. Click "Load" and select this file.\n4. Use "Summary" or "Comparison" views to find leaks.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Error generating heap snapshot: ${error instanceof Error ? error.message : String(error)}`,
},
Date.now(),
);
}
},
};
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import { MessageType } from '../types.js';
import {
CommandKind,
type SlashCommand,
} from './types.js';
import { formatBytes } from '../utils/formatters.js';
export const debugMemoryInfoCommand: SlashCommand = {
name: 'debug-memory-info',
description: 'Show detailed process memory information',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const usage = process.memoryUsage();
const lines = [
`RSS: ${formatBytes(usage.rss)} (Total memory allocated for the process)`,
`Heap Total: ${formatBytes(usage.heapTotal)} (V8 heap total size)`,
`Heap Used: ${formatBytes(usage.heapUsed)} (V8 heap actually used)`,
`External: ${formatBytes(usage.external)} (C++ objects bound to JS objects)`,
`Array Buffers: ${formatBytes(usage.arrayBuffers)} (Memory for ArrayBuffer and SharedArrayBuffer)`,
];
context.ui.addItem(
{
type: MessageType.INFO,
text: `Detailed Memory Info:\n${lines.map(l => `${l}`).join('\n')}`,
},
Date.now(),
);
},
};
@@ -1,80 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js';
import { useAppContext } from '../contexts/AppContext.js';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
const confirmingTool = useConfirmingTool();
const showPromptedTool = confirmingTool !== null;
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
// user exits alternate buffer mode.
// Our version of Ink is clever and will render a final frame outside of
// the alternate buffer on app exit.
return (
<Box
flexDirection="column"
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
>
<AppHeader key="app-header" version={version} />
{uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={uiState.mainAreaWidth}
availableTerminalHeight={undefined}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
))}
{uiState.pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={undefined}
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
/>
))}
{showPromptedTool && (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text color={theme.status.warning} bold>
Action Required (was prompted):
</Text>
<Box marginTop={1}>
<ToolStatusIndicator
status={confirmingTool.tool.status}
name={confirmingTool.tool.name}
/>
<ToolInfo
name={confirmingTool.tool.name}
status={confirmingTool.tool.status}
description={confirmingTool.tool.description}
emphasis="high"
/>
</Box>
</Box>
)}
<QuittingDisplay />
</Box>
);
};
@@ -1,166 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { UserIdentity } from './UserIdentity.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { useBanner } from '../hooks/useBanner.js';
import { useTips } from '../hooks/useTips.js';
import { theme } from '../semantic-colors.js';
import { ThemedGradient } from './ThemedGradient.js';
import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
const DEFAULT_ICON = `▝▜▄
▝▜▄
▗▟▀
▝▀ `;
/**
* The default Apple Terminal.app adds significant line-height padding between
* rows. This breaks Unicode block-drawing characters that rely on vertical
* adjacency (like half-blocks). This version is perfectly symmetric vertically,
* which makes the padding gaps look like an intentional "scanline" design
* rather than a broken image.
*/
const MAC_TERMINAL_ICON = `▝▜▄
▝▜▄
▗▟▀
▗▟▀ `;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo?.isUpdating && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
</Box>
)}
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
</Box>
);
};
-180
View File
@@ -1,180 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { StatusRow } from './StatusRow.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const uiActions = useUIActions();
const settings = useSettings();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
const { hasPendingActionRequired, shouldCollapseDuringApproval } =
useComposerStatus();
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
const { setShortcutsHelpVisible } = uiActions;
useEffect(() => {
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
}
}, [
uiState.shortcutsHelpVisible,
isPassiveShortcutsHelpState,
setShortcutsHelpVisible,
]);
const showShortcutsHelp =
uiState.shortcutsHelpVisible &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
const hasToast = shouldShowToast(uiState);
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
// Mini Mode VIP Flags (Pure Content Triggers)
const showMinimalToast = hasToast;
return (
<Box
flexDirection="column"
width={uiState.terminalWidth}
flexGrow={0}
flexShrink={0}
>
{uiState.isResuming && (
<ConfigInitDisplay message="Resuming session..." />
)}
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
)}
{showUiDetails && <TodoTray />}
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
</Box>
{showUiDetails && uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.terminalWidth}
hasFocus={uiState.showErrorDetails}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
? " Press 'Esc' for NORMAL mode."
: " Press 'i' for INSERT mode."
: uiState.shellModeActive
? ' Type your shell command'
: ' Type your message or @path/to/file'
}
setQueueErrorMessage={uiActions.setQueueErrorMessage}
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
</Box>
);
};
@@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
return (
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
</Box>
);
};
@@ -1,243 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { useEffect, useState } from 'react';
import { FixedDeque } from 'mnemonist';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { debugState } from '../debug.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
const MIN_TIME_FROM_ACTION_TO_BE_IDLE = 500;
export const ACTION_TIMESTAMP_CAPACITY = 2048;
export const FRAME_TIMESTAMP_CAPACITY = 2048;
// Exported for testing purposes.
export const profiler = {
profilersActive: 0,
numFrames: 0,
totalIdleFrames: 0,
totalFlickerFrames: 0,
hasLoggedFirstFlicker: false,
lastFrameStartTime: 0,
openedDebugConsole: false,
lastActionTimestamp: 0,
possiblyIdleFrameTimestamps: new FixedDeque<number>(
Array,
FRAME_TIMESTAMP_CAPACITY,
),
actionTimestamps: new FixedDeque<number>(Array, ACTION_TIMESTAMP_CAPACITY),
reportAction() {
const now = Date.now();
if (now - this.lastActionTimestamp > 16) {
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
this.lastActionTimestamp = now;
}
},
reportFrameRendered() {
if (this.profilersActive === 0) {
return;
}
const now = Date.now();
this.lastFrameStartTime = now;
this.numFrames++;
if (debugState.debugNumAnimatedComponents === 0) {
if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
this.possiblyIdleFrameTimestamps.shift();
}
this.possiblyIdleFrameTimestamps.push(now);
} else {
// If a spinner is present, consider this an action that both prevents
// this frame from being idle and also should prevent a follow on frame
// from being considered idle.
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
}
},
checkForIdleFrames() {
const now = Date.now();
const judgementCutoff = now - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
const oneSecondIntervalFromJudgementCutoff = judgementCutoff - 1000;
let idleInPastSecond = 0;
while (
this.possiblyIdleFrameTimestamps.size > 0 &&
this.possiblyIdleFrameTimestamps.peekFirst()! <= judgementCutoff
) {
const frameTime = this.possiblyIdleFrameTimestamps.shift()!;
const start = frameTime - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
const end = frameTime + MIN_TIME_FROM_ACTION_TO_BE_IDLE;
while (
this.actionTimestamps.size > 0 &&
this.actionTimestamps.peekFirst()! < start
) {
this.actionTimestamps.shift();
}
const hasAction =
this.actionTimestamps.size > 0 &&
this.actionTimestamps.peekFirst()! <= end;
if (!hasAction) {
if (frameTime >= oneSecondIntervalFromJudgementCutoff) {
idleInPastSecond++;
}
this.totalIdleFrames++;
}
}
if (idleInPastSecond >= 5) {
if (this.openedDebugConsole === false) {
this.openedDebugConsole = true;
appEvents.emit(AppEvent.OpenDebugConsole);
}
debugLogger.error(
`${idleInPastSecond} frames rendered while the app was ` +
`idle in the past second. This likely indicates severe infinite loop ` +
`React state management bugs.`,
);
}
},
registerFlickerHandler(constrainHeight: boolean) {
const flickerHandler = () => {
// If we are not constraining the height, we are intentionally
// overflowing the screen.
if (!constrainHeight) {
return;
}
this.totalFlickerFrames++;
this.reportAction();
if (!this.hasLoggedFirstFlicker) {
this.hasLoggedFirstFlicker = true;
debugLogger.error(
'A flicker frame was detected. This will cause UI instability. Type `/profile` for more info.',
);
}
};
appEvents.on(AppEvent.Flicker, flickerHandler);
return () => {
appEvents.off(AppEvent.Flicker, flickerHandler);
};
},
};
export const DebugProfiler = () => {
const { showDebugProfiler, constrainHeight } = useUIState();
const [forceRefresh, setForceRefresh] = useState(0);
// Effect for listening to stdin for keypresses and stdout for resize events.
useEffect(() => {
profiler.profilersActive++;
const stdin = process.stdin;
const stdout = process.stdout;
const handler = () => {
profiler.reportAction();
};
stdin.on('data', handler);
stdout.on('resize', handler);
// Register handlers for all core and app events to ensure they are
// considered "actions" and don't trigger spurious idle frame warnings.
// These events are expected to trigger UI renders.
for (const eventName of Object.values(CoreEvent)) {
coreEvents.on(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.on(eventName, handler);
}
// Register handlers for extension lifecycle events emitted on coreEvents
// but not part of the CoreEvent enum, to prevent false-positive idle warnings.
const extensionEvents = [
'extensionsStarting',
'extensionsStopping',
] as const;
for (const eventName of extensionEvents) {
coreEvents.on(eventName, handler);
}
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
for (const eventName of Object.values(CoreEvent)) {
coreEvents.off(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.off(eventName, handler);
}
for (const eventName of extensionEvents) {
coreEvents.off(eventName, handler);
}
profiler.profilersActive--;
};
}, []);
useEffect(() => {
const updateInterval = setInterval(() => {
profiler.checkForIdleFrames();
}, 1000);
return () => clearInterval(updateInterval);
}, []);
useEffect(
() => profiler.registerFlickerHandler(constrainHeight),
[constrainHeight],
);
// Effect for updating stats
useEffect(() => {
if (!showDebugProfiler) {
return;
}
// Only update the UX infrequently as updating the UX itself will cause
// frames to run so can disturb what we are measuring.
const forceRefreshInterval = setInterval(() => {
setForceRefresh((f) => f + 1);
profiler.reportAction();
}, 4000);
return () => clearInterval(forceRefreshInterval);
}, [showDebugProfiler]);
if (!showDebugProfiler) {
return null;
}
return (
<Text color={theme.status.warning} key={forceRefresh}>
Renders: {profiler.numFrames} (total),{' '}
<Text color={theme.status.error}>{profiler.totalIdleFrames} (idle)</Text>,{' '}
<Text color={theme.status.error}>
{profiler.totalFlickerFrames} (flicker)
</Text>
</Text>
);
};
@@ -1,136 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ConsoleMessageItem } from '../types.js';
import { Box } from 'ink';
import type React from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
vi.mock('../hooks/useConsoleMessages.js', () => ({
useConsoleMessages: vi.fn(),
}));
vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({
data,
renderItem,
}: {
data: unknown[];
renderItem: (props: { item: unknown }) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: unknown, index: number) => (
<Box key={index}>{renderItem({ item })}</Box>
))}
</Box>
),
}));
describe('DetailedMessagesDisplay', () => {
beforeEach(() => {
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: [],
clearConsoleMessages: vi.fn(),
});
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders messages correctly', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Log message', count: 1 },
{ type: 'warn', content: 'Warning message', count: 1 },
{ type: 'error', content: 'Error message', count: 1 },
{ type: 'debug', content: 'Debug message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
it('shows the F12 hint even in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('shows the F12 hint in full error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -1,131 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useRef, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
import { useConfig } from '../contexts/ConfigContext.js';
interface DetailedMessagesDisplayProps {
maxHeight: number | undefined;
width: number;
hasFocus: boolean;
}
const iconBoxWidth = 3;
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const { consoleMessages } = useConsoleMessages();
const config = useConfig();
const messages = useMemo(() => {
if (config.getDebugMode()) {
return consoleMessages;
}
return consoleMessages.filter((msg) => msg.type !== 'debug');
}, [consoleMessages, config]);
const borderAndPadding = 3;
const estimatedItemHeight = useCallback(
(index: number) => {
const msg = messages[index];
if (!msg) {
return 1;
}
const textWidth = width - borderAndPadding - iconBoxWidth;
if (textWidth <= 0) {
return 1;
}
const lines = Math.ceil((msg.content?.length || 1) / textWidth);
return Math.max(1, lines);
},
[width, messages],
);
if (messages.length === 0) {
return null;
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
width={width}
height={maxHeight}
flexShrink={0}
flexGrow={0}
overflow="hidden"
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
<ScrollableList
ref={scrollableListRef}
data={messages}
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
let textColor = theme.text.primary;
let icon = ''; // Information source ()
switch (msg.type) {
case 'warn':
textColor = theme.status.warning;
icon = '⚠'; // Warning sign (⚠)
break;
case 'error':
textColor = theme.status.error;
icon = '✖'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '🔍'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box flexDirection="row">
<Box minWidth={iconBoxWidth} flexShrink={0}>
<Text color={textColor}>{icon}</Text>
</Box>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
}}
keyExtractor={(item, index) => `${item.content}-${index}`}
estimatedItemHeight={estimatedItemHeight}
hasFocus={hasFocus}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</Box>
);
};
@@ -1,367 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
terminalWidth: number;
}
// Props for DialogManager
export const DialogManager = ({
addItem,
terminalWidth,
}: DialogManagerProps) => {
const config = useConfig();
const settings = useSettings();
const uiState = useUIState();
const uiActions = useUIActions();
const {
constrainHeight,
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
} = uiState;
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
if (uiState.showIdeRestartPrompt) {
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
}
if (uiState.newAgents) {
return (
<NewAgentsNotification
agents={uiState.newAgents}
onSelect={uiActions.handleNewAgentsSelect}
/>
);
}
if (uiState.quota.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.quota.proQuotaRequest.failedModel}
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
message={uiState.quota.proQuotaRequest.message}
isTerminalQuotaError={
uiState.quota.proQuotaRequest.isTerminalQuotaError
}
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.quota.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.quota.validationRequest.validationLink}
validationDescription={
uiState.quota.validationRequest.validationDescription
}
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.quota.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
/>
);
}
if (uiState.isFolderTrustDialogOpen) {
return (
<FolderTrustDialog
onSelect={uiActions.handleFolderTrustSelect}
isRestarting={uiState.isRestarting}
discoveryResults={uiState.folderDiscoveryResults}
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
return (
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
onComplete={uiState.loopDetectionConfirmationRequest.onComplete}
/>
);
}
if (uiState.permissionConfirmationRequest) {
const files = uiState.permissionConfirmationRequest.files;
const filesList = files.map((f) => `- ${f}`).join('\n');
return (
<ConsentPrompt
prompt={`The following files are outside your workspace:\n\n${filesList}\n\nDo you want to allow this read?`}
onConfirm={(allowed) => {
uiState.permissionConfirmationRequest?.onComplete({ allowed });
}}
terminalWidth={terminalWidth}
/>
);
}
// commandConfirmationRequest and authConsentRequest are kept separate
// to avoid focus deadlocks and state race conditions between the
// synchronous command loop and the asynchronous auth flow.
if (uiState.commandConfirmationRequest) {
return (
<ConsentPrompt
prompt={uiState.commandConfirmationRequest.prompt}
onConfirm={uiState.commandConfirmationRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.authConsentRequest) {
return (
<ConsentPrompt
prompt={uiState.authConsentRequest.prompt}
onConfirm={uiState.authConsentRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.confirmUpdateExtensionRequests.length > 0) {
const request = uiState.confirmUpdateExtensionRequests[0];
return (
<ConsentPrompt
prompt={request.prompt}
onConfirm={request.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">
{uiState.themeError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.themeError}</Text>
</Box>
)}
<ThemeDialog
onSelect={uiActions.handleThemeSelect}
onCancel={uiActions.closeThemeDialog}
onHighlight={uiActions.handleThemeHighlight}
settings={settings}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
terminalWidth={uiTerminalWidth}
/>
</Box>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
<SettingsDialog
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={relaunchApp}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
</Box>
);
}
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (
uiState.isAgentConfigDialogOpen &&
uiState.selectedAgentName &&
uiState.selectedAgentDisplayName &&
uiState.selectedAgentDefinition
) {
return (
<Box flexDirection="column">
<AgentConfigDialog
agentName={uiState.selectedAgentName}
displayName={uiState.selectedAgentDisplayName}
definition={uiState.selectedAgentDefinition}
settings={settings}
availableTerminalHeight={terminalHeight - staticExtraHeight}
onClose={uiActions.closeAgentConfigDialog}
onSave={async () => {
// Reload agent registry to pick up changes
const agentRegistry = config?.getAgentRegistry();
if (agentRegistry) {
await agentRegistry.reload();
}
}}
/>
</Box>
);
}
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAwaitingApiKeyInput) {
return (
<Box flexDirection="column">
<ApiAuthDialog
key={uiState.apiKeyDefaultValue}
onSubmit={uiActions.handleApiKeySubmit}
onCancel={uiActions.handleApiKeyCancel}
error={uiState.authError}
defaultValue={uiState.apiKeyDefaultValue}
/>
</Box>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
config={config}
settings={settings}
setAuthState={uiActions.setAuthState}
authError={uiState.authError}
onAuthError={uiActions.onAuthError}
setAuthContext={uiActions.setAuthContext}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.showPrivacyNotice) {
return (
<PrivacyNotice
onExit={() => uiActions.exitPrivacyNotice()}
config={config}
/>
);
}
if (uiState.isSessionBrowserOpen) {
return (
<SessionBrowser
config={config}
onResumeSession={uiActions.handleResumeSession}
onDeleteSession={uiActions.handleDeleteSession}
onExit={uiActions.closeSessionBrowser}
/>
);
}
if (uiState.isPermissionsDialogOpen) {
return (
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
/>
);
}
return null;
};
@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
export const ExitWarning: React.FC = () => {
const uiState = useUIState();
return (
<>
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
</Box>
)}
</>
);
};
@@ -1,318 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import stripAnsi from 'strip-ansi';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { Scrollable } from './shared/Scrollable.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import {
ExitCodes,
type FolderDiscoveryResults,
} from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
TRUST_PARENT = 'trust_parent',
DO_NOT_TRUST = 'do_not_trust',
}
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
isRestarting?: boolean;
discoveryResults?: FolderDiscoveryResults | null;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
discoveryResults,
}) => {
const [exiting, setExiting] = useState(false);
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isExpanded = !constrainHeight;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isRestarting) {
timer = setTimeout(relaunchApp, 250);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isRestarting]);
const handleExit = useCallback(() => {
setExiting(true);
// Give time for the UI to render the exiting message
setTimeout(async () => {
await runExitCleanup();
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
}, 100);
}, []);
useKeypress(
(key) => {
if (key.name === 'escape') {
handleExit();
return true;
}
return false;
},
{ isActive: !isRestarting },
);
const dirName = path.basename(process.cwd());
const parentFolder = path.basename(path.dirname(process.cwd()));
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: `Trust folder (${dirName})`,
value: FolderTrustChoice.TRUST_FOLDER,
key: `Trust folder (${dirName})`,
},
{
label: `Trust parent folder (${parentFolder})`,
value: FolderTrustChoice.TRUST_PARENT,
key: `Trust parent folder (${parentFolder})`,
},
{
label: "Don't trust",
value: FolderTrustChoice.DO_NOT_TRUST,
key: "Don't trust",
},
];
const hasDiscovery =
discoveryResults &&
(discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0);
const hasWarnings =
discoveryResults && discoveryResults.securityWarnings.length > 0;
const hasErrors =
discoveryResults &&
discoveryResults.discoveryErrors &&
discoveryResults.discoveryErrors.length > 0;
const dialogWidth = terminalWidth - 2;
const borderColor = theme.status.warning;
// Header: 3 lines
// Options: options.length + 2 lines for margins
// Footer: 1 line
// Safety margin: 2 lines
const overhead = 3 + options.length + 2 + 1 + 2;
const scrollableHeight = Math.max(4, terminalHeight - overhead);
const groups = [
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0);
const discoveryContent = (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={theme.text.primary}>
Trusting a folder allows Gemini CLI to load its local configurations,
including custom commands, hooks, MCP servers, agent skills, and
settings. These configurations could execute code on your behalf or
change the behavior of the CLI.
</Text>
</Box>
{hasErrors && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
Discovery Errors:
</Text>
{discoveryResults.discoveryErrors.map((error, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.error}> {stripAnsi(error)}</Text>
</Box>
))}
</Box>
)}
{hasWarnings && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.warning} bold>
Security Warnings:
</Text>
{discoveryResults.securityWarnings.map((warning, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.warning}> {stripAnsi(warning)}</Text>
</Box>
))}
</Box>
)}
{hasDiscovery && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary} bold>
This folder contains:
</Text>
{groups.map((group) => (
<Box key={group.label} flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary} bold>
{group.label} ({group.items.length}):
</Text>
{group.items.map((item, idx) => (
<Box key={idx} marginLeft={2}>
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
</Box>
))}
</Box>
))}
</Box>
)}
</Box>
);
const title = (
<Text bold color={theme.text.primary}>
Do you trust the files in this folder?
</Text>
);
const selectOptions = (
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
);
const renderContent = () => {
if (isAlternateBuffer) {
return (
<Box flexDirection="column" width={dialogWidth}>
<StickyHeader
width={dialogWidth}
isFirst={true}
borderColor={borderColor}
borderDimColor={false}
>
{title}
</StickyHeader>
<Box
flexDirection="column"
borderLeft={true}
borderRight={true}
borderColor={borderColor}
borderStyle="round"
borderTop={false}
borderBottom={false}
width={dialogWidth}
>
<Scrollable
hasFocus={!isRestarting}
height={scrollableHeight}
width={dialogWidth - 2}
>
<Box flexDirection="column" paddingX={1}>
{discoveryContent}
</Box>
</Scrollable>
<Box paddingX={1} marginY={1}>
{selectOptions}
</Box>
</Box>
<Box
height={0}
width={dialogWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
/>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
padding={1}
width="100%"
>
<Box marginBottom={1}>{title}</Box>
<MaxSizedBox
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
overflowDirection="bottom"
>
{discoveryContent}
</MaxSizedBox>
<Box marginTop={1}>{selectOptions}</Box>
</Box>
);
};
const content = (
<Box flexDirection="column" width="100%">
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{renderContent()}
</Box>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the trust changes...
</Text>
</Box>
)}
{exiting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
A folder trust level must be selected to continue. Exiting since
escape was pressed.
</Text>
</Box>
)}
</Box>
);
return <OverflowProvider>{content}</OverflowProvider>;
};
-511
View File
@@ -1,511 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
shortenPath,
tildeifyPath,
getDisplayString,
checkExhaustive,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import {
ALL_ITEMS,
type FooterItemId,
deriveItemsFromLegacySettings,
} from '../../config/footerItems.js';
import { isDevelopment } from '../../utils/installationInfo.js';
interface CwdIndicatorProps {
targetDir: string;
maxWidth: number;
debugMode?: boolean;
debugMessage?: string;
color?: string;
}
const CwdIndicator: React.FC<CwdIndicatorProps> = ({
targetDir,
maxWidth,
debugMode,
debugMessage,
color = theme.text.primary,
}) => {
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
return (
<Text color={color}>
{displayPath}
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
</Text>
);
};
interface SandboxIndicatorProps {
isTrustedFolder: boolean | undefined;
}
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
isTrustedFolder,
}) => {
if (isTrustedFolder === false) {
return <Text color={theme.status.warning}>untrusted</Text>;
}
const sandbox = process.env['SANDBOX'];
if (sandbox && sandbox !== 'sandbox-exec') {
return (
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
);
}
if (sandbox === 'sandbox-exec') {
return (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.ui.comment}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
);
}
return <Text color={theme.status.error}>no sandbox</Text>;
};
const CorgiIndicator: React.FC = () => (
<Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼</Text>
</Text>
);
export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
flexGrow?: number;
flexShrink?: number;
isFocused?: boolean;
alignItems?: 'flex-start' | 'center' | 'flex-end';
}
const COLUMN_GAP = 3;
export const FooterRow: React.FC<{
items: FooterRowItem[];
showLabels: boolean;
}> = ({ items, showLabels }) => {
const elements: React.ReactNode[] = [];
items.forEach((item, idx) => {
if (idx > 0) {
elements.push(
<Box
key={`sep-${item.key}`}
flexGrow={1}
flexShrink={1}
minWidth={showLabels ? COLUMN_GAP : 3}
justifyContent="center"
alignItems="center"
>
{!showLabels && <Text color={theme.ui.comment}> · </Text>}
</Box>,
);
}
elements.push(
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
alignItems={item.alignItems}
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
</Box>,
);
});
return (
<Box flexDirection="row" flexWrap="nowrap" width="100%">
{elements}
</Box>
);
};
function isFooterItemId(id: string): id is FooterItemId {
return ALL_ITEMS.some((i) => i.id === id);
}
interface FooterColumn {
id: string;
header: string;
element: (maxWidth: number) => React.ReactNode;
width: number;
isHighPriority: boolean;
}
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
if (copyModeEnabled) {
return <Box height={1} />;
}
const {
model,
targetDir,
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
isTrustedFolder,
terminalWidth,
quotaStats,
} = {
model: uiState.currentModel,
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
};
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const displayVimMode = vimEnabled ? vimMode : undefined;
const items =
settings.merged.ui.footer.items ??
deriveItemsFromLegacySettings(settings.merged);
const showLabels = settings.merged.ui.footer.showLabels !== false;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const potentialColumns: FooterColumn[] = [];
const addCol = (
id: string,
header: string,
element: (maxWidth: number) => React.ReactNode,
dataWidth: number,
isHighPriority = false,
) => {
potentialColumns.push({
id,
header: showLabels ? header : '',
element,
width: Math.max(dataWidth, showLabels ? header.length : 0),
isHighPriority,
});
};
// 1. System Indicators (Far Left, high priority)
if (uiState.showDebugProfiler) {
addCol('debug', '', () => <DebugProfiler />, 45, true);
}
if (displayVimMode) {
const vimStr = `[${displayVimMode}]`;
addCol(
'vim',
'',
() => <Text color={theme.text.accent}>{vimStr}</Text>,
vimStr.length,
true,
);
}
// 2. Main Configurable Items
for (const id of items) {
if (!isFooterItemId(id)) continue;
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
const header = itemConfig?.header ?? id;
switch (id) {
case 'workspace': {
const fullPath = tildeifyPath(targetDir);
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
addCol(
id,
header,
(maxWidth) => (
<CwdIndicator
targetDir={targetDir}
maxWidth={maxWidth}
debugMode={debugMode}
debugMessage={debugMessage}
color={itemColor}
/>
),
fullPath.length + debugSuffix.length,
);
break;
}
case 'git-branch': {
if (branchName) {
addCol(
id,
header,
() => <Text color={itemColor}>{branchName}</Text>,
branchName.length,
);
}
break;
}
case 'sandbox': {
let str = 'no sandbox';
const sandbox = process.env['SANDBOX'];
if (isTrustedFolder === false) str = 'untrusted';
else if (sandbox === 'sandbox-exec')
str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
addCol(
id,
header,
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
str.length,
);
break;
}
case 'model-name': {
const str = getDisplayString(model);
addCol(
id,
header,
() => <Text color={itemColor}>{str}</Text>,
str.length,
);
break;
}
case 'context-used': {
addCol(
id,
header,
() => (
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
),
10, // "100% used" is 9 chars
);
break;
}
case 'quota': {
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
addCol(
id,
header,
() => (
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
);
}
break;
}
case 'memory-usage': {
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
addCol(
id,
header,
() => (
<Text color={itemColor}>
{uiState.sessionStats.sessionId.slice(0, 8)}
</Text>
),
8,
);
break;
}
case 'code-changes': {
const added = uiState.sessionStats.metrics.files.totalLinesAdded;
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
if (added > 0 || removed > 0) {
const str = `+${added} -${removed}`;
addCol(
id,
header,
() => (
<Text>
<Text color={theme.status.success}>+{added}</Text>{' '}
<Text color={theme.status.error}>-{removed}</Text>
</Text>
),
str.length,
);
}
break;
}
case 'token-count': {
let total = 0;
for (const m of Object.values(uiState.sessionStats.metrics.models))
total += m.tokens.total;
if (total > 0) {
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const formatted = formatter.format(total).toLowerCase();
addCol(
id,
header,
() => <Text color={itemColor}>{formatted} tokens</Text>,
formatted.length + 7,
);
}
break;
}
default:
checkExhaustive(id);
break;
}
}
// 3. Transients
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
if (showErrorSummary) {
addCol(
'error-count',
'',
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
12,
true,
);
}
// --- Width Fitting Logic ---
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
let currentUsedWidth = 2; // Initial padding
for (const col of potentialColumns) {
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => {
const isWorkspace = col.id === 'workspace';
const isLast = index === columnsToRender.length - 1;
// Calculate exact space available for growth to prevent over-estimation truncation
const otherItemsWidth = columnsToRender
.filter((c) => c.id !== 'workspace')
.reduce((sum, c) => sum + c.width, 0);
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
const numGaps = numItems > 1 ? numItems - 1 : 0;
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
const ellipsisWidth = droppedAny ? 1 : 0;
const availableForWorkspace = Math.max(
20,
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
);
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
return {
key: col.id,
header: col.header,
element: col.element(estimatedWidth),
flexGrow: 0,
flexShrink: isWorkspace ? 1 : 0,
alignItems:
isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start',
};
});
if (droppedAny) {
rowItems.push({
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}>…</Text>,
flexGrow: 0,
flexShrink: 0,
alignItems: 'flex-end',
});
}
return (
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
};
@@ -1,395 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useReducer, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
interface FooterConfigItem {
key: string;
id: string;
label: string;
description?: string;
type: 'config' | 'labels-toggle' | 'reset';
}
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
}
type FooterConfigAction =
| { type: 'MOVE_ITEM'; id: string; direction: number }
| { type: 'TOGGLE_ITEM'; id: string }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
case 'MOVE_ITEM': {
const currentIndex = state.orderedIds.indexOf(action.id);
const newIndex = currentIndex + action.direction;
if (
currentIndex === -1 ||
newIndex < 0 ||
newIndex >= state.orderedIds.length
) {
return state;
}
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
return { ...state, orderedIds: newOrderedIds };
}
case 'TOGGLE_ITEM': {
const nextSelected = new Set(state.selectedIds);
if (nextSelected.has(action.id)) {
nextSelected.delete(action.id);
} else {
nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
default:
return state;
}
}
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const { settings, setSetting } = useSettingsStore();
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
resolveFooterState(settings.merged),
);
const { orderedIds, selectedIds } = state;
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
value: {
key: id,
id,
label: item.id,
description: item.description as string,
type: 'config' as const,
},
};
})
.filter((i): i is NonNullable<typeof i> => i !== null);
items.push({
key: 'show-labels',
value: {
key: 'show-labels',
id: 'show-labels',
label: 'Show footer labels',
type: 'labels-toggle',
},
});
items.push({
key: 'reset',
value: {
key: 'reset',
id: 'reset',
label: 'Reset to default footer',
type: 'reset',
},
});
return items;
}, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
const currentSetting = settings.merged.ui?.footer?.items;
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
}
onClose?.();
}, [
orderedIds,
selectedIds,
setSetting,
settings.merged.ui?.footer?.items,
onClose,
]);
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
const newState = resolveFooterState(settings.merged);
dispatch({ type: 'SET_STATE', payload: newState });
setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
const current = settings.merged.ui.footer.showLabels !== false;
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
const handleSelect = useCallback(
(item: FooterConfigItem) => {
if (item.type === 'config') {
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
} else if (item.type === 'labels-toggle') {
handleToggleLabels();
} else if (item.type === 'reset') {
handleResetToDefaults();
}
},
[handleResetToDefaults, handleToggleLabels],
);
const handleHighlight = useCallback((item: FooterConfigItem) => {
setFocusKey(item.key);
}, []);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
handleSaveAndClose();
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
return true;
}
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
return true;
}
}
return false;
},
{ isActive: true, priority: true },
);
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
if (focusKey === 'reset') {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
</Text>
);
}
const itemsToPreview = orderedIds.filter((id: string) =>
selectedIds.has(id),
);
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) =>
defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
workspace: (
<Text color={getColor('workspace', itemColor)}>~/project/path</Text>
),
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
sandbox: <Text color={getColor('sandbox', 'green')}>docker</Text>,
'model-name': (
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
),
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
'session-id': (
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
),
'code-changes': (
<Box flexDirection="row">
<Text color={getColor('code-changes', theme.status.success)}>
+12
</Text>
<Text color={getColor('code-changes')}> </Text>
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
</Box>
),
'token-count': (
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
),
};
const rowItems: FooterRowItem[] = itemsToPreview
.filter((id: string) => mockData[id])
.map((id: string) => ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
flexGrow: 0,
isFocused: id === focusKey,
}));
return (
<Box overflow="hidden" flexWrap="nowrap" width="100%">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
}, [orderedIds, selectedIds, focusKey, showLabels]);
const availableTerminalHeight = constrainHeight
? terminalHeight - staticExtraHeight
: Number.MAX_SAFE_INTEGER;
const BORDER_HEIGHT = 2; // Outer round border
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
// Default padding adds 2 lines (top and bottom)
let includePadding = true;
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
includePadding = false;
}
const effectivePaddingY = includePadding ? 2 : 0;
const availableListSpace = Math.max(
0,
availableTerminalHeight -
BORDER_HEIGHT -
effectivePaddingY -
STATIC_ELEMENTS,
);
const maxItemsToShow = Math.max(
1,
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={includePadding ? 1 : 0}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
<Text color={theme.text.secondary}>
Select which items to display in the footer.
</Text>
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<BaseSelectionList<FooterConfigItem>
items={listItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={focusKey}
showNumbers={false}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
selectedIndicator=">"
renderItem={(item, { isSelected, titleColor }) => {
const configItem = item.value;
const isChecked =
configItem.type === 'config'
? selectedIds.has(configItem.id)
: configItem.type === 'labels-toggle'
? showLabels
: false;
return (
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
{configItem.type !== 'reset' && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? '✓' : ' '}]
</Text>
)}
<Text
color={
configItem.type === 'reset' && isSelected
? theme.status.warning
: titleColor
}
>
{configItem.type !== 'reset' ? ' ' : ''}
{configItem.label}
</Text>
</Box>
{configItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
{configItem.description}
</Text>
)}
</Box>
);
}}
/>
</Box>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate · ←/→ to reorder"
cancelAction="Esc to close"
/>
<Box
marginTop={1}
borderStyle="single"
borderColor={theme.border.default}
paddingX={1}
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row" width="100%">
{previewContent}
</Box>
</Box>
</Box>
);
};
@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
vi.mock('../contexts/StreamingContext.js');
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useIsScreenReaderEnabled: vi.fn(),
};
});
vi.mock('./GeminiSpinner.js', () => ({
GeminiSpinner: ({ altText }: { altText?: string }) => (
<Text>GeminiSpinner {altText}</Text>
),
}));
describe('GeminiRespondingSpinner', () => {
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
beforeEach(() => {
vi.clearAllMocks();
mockUseIsScreenReaderEnabled.mockReturnValue(false);
});
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
expect(lastFrame()).toContain('Waiting...');
unmount();
});
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});
});
@@ -1,65 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { GeminiSpinner } from './GeminiSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
*/
isHookActive?: boolean;
color?: string;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({
nonRespondingDisplay,
spinnerType = 'dots',
isHookActive = false,
color,
}) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
if (streamingState === StreamingState.Responding && !isHookActive) {
return (
<GeminiSpinner
spinnerType={spinnerType}
altText={SCREEN_READER_RESPONDING}
/>
);
}
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
return null;
};
File diff suppressed because it is too large Load Diff
@@ -1,467 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { describe, it, expect, vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
},
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = async (
ui: React.ReactElement,
streamingStateValue: StreamingState,
width = 120,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return renderWithProviders(ui, {
uiState: { streamingState: streamingStateValue },
width,
});
};
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
});
it('should display the currentLoadingPhrase correctly', async () => {
const props = {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', async () => {
const props = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 60,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
it('should display the elapsedTime correctly in human-readable format', async () => {
const props = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 125,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
it('should transition correctly between states', async () => {
let setStateExternally:
| React.Dispatch<
React.SetStateAction<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>
>
| undefined;
const TestWrapper = () => {
const [config, setConfig] = React.useState<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
setStateExternally = setConfig;
return (
<StreamingContext.Provider value={config.state}>
<LoadingIndicator
currentLoadingPhrase={config.phrase}
elapsedTime={config.elapsedTime}
/>
</StreamingContext.Provider>
);
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<TestWrapper />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
await act(async () => {
setStateExternally?.({
state: StreamingState.Responding,
phrase: 'Now Responding',
elapsedTime: 2,
});
});
await waitUntilReady();
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
// Transition to WaitingForConfirmation
await act(async () => {
setStateExternally?.({
state: StreamingState.WaitingForConfirmation,
phrase: 'Please Confirm',
elapsedTime: 15,
});
});
await waitUntilReady();
output = lastFrame();
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
// Transition back to Idle
await act(async () => {
setStateExternally?.({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
});
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
unmount();
});
it('should display the subject of a thought', async () => {
const props = {
thought: {
subject: 'Thinking about something...',
description: 'and other stuff.',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
// Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking"
expect(output).not.toContain('Thinking... Thinking');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
unmount();
});
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
description: 'details',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Planning the response...');
expect(output).not.toContain('Thinking... ');
unmount();
});
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
const props = {
thought: {
subject: 'This should be displayed',
description: 'A description',
},
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
'This is an extremely long loading phrase that should be truncated in the UI to keep the primary line concise.'
}
/>,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
unmount();
});
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
unmount();
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
unmount();
});
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Witty Phrase
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('I am witty');
}
unmount();
});
});
it('should use spinnerIcon when provided', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
spinnerIcon: '?',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('⠏');
unmount();
});
});
@@ -1,183 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ThoughtSummary } from '@google/gemini-cli-core';
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
wittyPhrase?: string;
showWit?: boolean;
showTips?: boolean;
errorVerbosity?: 'low' | 'full';
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
forceRealStatusOnly?: boolean;
spinnerIcon?: string;
isHookActive?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
wittyPhrase,
showWit = false,
elapsedTime,
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
forceRealStatusOnly = false,
spinnerIcon,
isHookActive = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!thought
) {
return null;
}
// Prioritize the interactive shell waiting phrase over the thought subject
// because it conveys an actionable state for the user (waiting for input).
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase ||
(streamingState === StreamingState.Responding
? 'Thinking...'
: undefined);
const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
const wittyPhraseNode =
!forceRealStatusOnly &&
showWit &&
wittyPhrase &&
primaryText === 'Thinking...' ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase}
</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
)}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{wittyPhraseNode}
</Box>
);
}
return (
<Box paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
)}
{!isNarrow && cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{!isNarrow && wittyPhraseNode}
</Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && wittyPhraseNode}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
};
@@ -1,322 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
} from './shared/VirtualizedList.js';
import { ScrollableList } from './shared/ScrollableList.js';
import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
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';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
// This threshold is arbitrary but should be high enough to never impact normal
// usage.
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type;
if (type === 'user' || type === 'user_shell') {
return i;
}
}
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
return {
item,
isExpandable: i > lastUserPromptIndex,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.id}
item={item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
[
augmentedHistory,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
uiState.constrainHeight,
],
);
const staticHistoryItems = useMemo(
() => historyItems.slice(0, lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const lastResponseHistoryItems = useMemo(
() => historyItems.slice(lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column" key="pending-items-group">
{pendingHistoryItems.map((item, i) => {
const prevType =
i === 0
? uiState.history.at(-1)?.type
: pendingHistoryItems[i - 1]?.type;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: -(i + 1) }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue
key="confirmation-queue"
confirmingTool={confirmingTool}
/>
)}
</Box>
),
[
pendingHistoryItems,
uiState.constrainHeight,
availableTerminalHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
],
[augmentedHistory],
);
const renderItem = useCallback(
({ item }: { item: (typeof virtualizedData)[number] }) => {
if (item.type === 'header') {
return (
<MemoizedAppHeader
key="app-header"
version={version}
showDetails={showHeaderDetails}
/>
);
} 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}
suppressNarration={item.suppressNarration}
/>
);
} else {
return pendingItems;
}
},
[
showHeaderDetails,
version,
mainAreaWidth,
uiState.slashCommands,
pendingItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
],
);
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}
/>
);
}
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...staticHistoryItems,
...lastResponseHistoryItems,
]}
>
{(item) => item}
</Static>
{pendingItems}
</>
);
};
@@ -1,44 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color, isActive]);
return (
<Box>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);
};
@@ -1,181 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
import { persistentState } from '../../utils/persistentState.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { KeypressPriority } from '../contexts/KeypressContext.js';
import {
GEMINI_DIR,
Storage,
homedir,
WarningPriority,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
const settingsPath = path.join(homedir(), GEMINI_DIR, 'settings.json');
const screenReaderNudgeFilePath = path.join(
Storage.getGlobalTempDir(),
'seen_screen_reader_nudge.json',
);
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const showInitError =
initError && streamingState !== StreamingState.Responding;
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
persistentState.get('hasSeenScreenReaderNudge'),
);
const [dismissed, setDismissed] = useState(false);
// Track if we have already incremented the show count in this session
const hasIncrementedRef = useRef(false);
// Filter warnings based on persistent state count if low priority
const visibleWarnings = useMemo(() => {
if (dismissed) return [];
const counts = persistentState.get('startupWarningCounts') || {};
return startupWarnings.filter((w) => {
if (w.priority === WarningPriority.Low) {
const count = counts[w.id] || 0;
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
}
return true;
});
}, [startupWarnings, dismissed]);
const showStartupWarnings = visibleWarnings.length > 0;
// Increment counts for low priority warnings when shown
useEffect(() => {
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
let changed = false;
visibleWarnings.forEach((w) => {
if (w.priority === WarningPriority.Low) {
counts[w.id] = (counts[w.id] || 0) + 1;
changed = true;
}
});
if (changed) {
persistentState.set('startupWarningCounts', counts);
}
hasIncrementedRef.current = true;
}
}, [visibleWarnings]);
const handleKeyPress = useCallback(() => {
if (showStartupWarnings) {
setDismissed(true);
}
return false;
}, [showStartupWarnings]);
useKeypress(handleKeyPress, {
isActive: showStartupWarnings,
priority: KeypressPriority.Critical,
});
useEffect(() => {
const checkLegacyScreenReaderNudge = async () => {
if (hasSeenScreenReaderNudge !== undefined) return;
try {
await fs.access(screenReaderNudgeFilePath);
persistentState.set('hasSeenScreenReaderNudge', true);
setHasSeenScreenReaderNudge(true);
// Best effort cleanup of legacy file
await fs.unlink(screenReaderNudgeFilePath).catch(() => {});
} catch {
setHasSeenScreenReaderNudge(false);
}
};
if (isScreenReaderEnabled) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkLegacyScreenReaderNudge();
}
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
const showScreenReaderNudge =
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
useEffect(() => {
if (showScreenReaderNudge) {
persistentState.set('hasSeenScreenReaderNudge', true);
}
}, [showScreenReaderNudge]);
if (
!showStartupWarnings &&
!showInitError &&
!updateInfo &&
!showScreenReaderNudge
) {
return null;
}
return (
<>
{showScreenReaderNudge && (
<Text>
You are currently in screen reader-friendly view. To switch out, open{' '}
{settingsPath} and remove the entry for {'"screenReader"'}. This will
disappear on next run.
</Text>
)}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{showStartupWarnings && (
<Box marginY={1} flexDirection="column">
{visibleWarnings.map((warning, index) => (
<Box key={index} flexDirection="row">
<Box width={3}>
<Text color={theme.status.warning}> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.status.warning}>{warning.message}</Text>
</Box>
</Box>
))}
</Box>
)}
{showInitError && (
<Box
borderStyle="round"
borderColor={theme.status.error}
paddingX={1}
marginBottom={1}
>
<Text color={theme.status.error}>
Initialization Error: {initError}
</Text>
<Text color={theme.status.error}>
{' '}
Please check API key and configuration.
</Text>
</Box>
)}
</>
);
};
@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
if (!uiState.quittingMessages) {
return null;
}
return (
<Box flexDirection="column" marginBottom={1}>
{uiState.quittingMessages.map((item) => (
<HistoryItemDisplay
key={item.id}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item}
isPending={false}
/>
))}
</Box>
);
};
@@ -1,334 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
type ConversationRecord,
type MessageRecord,
partToString,
} from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { Command } from '../key/keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface RewindViewerProps {
conversation: ConversationRecord;
onExit: () => void;
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
}
const MAX_LINES_PER_BOX = 2;
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
const contentToUse = userPrompt.displayContent || userPrompt.content;
const originalUserText = contentToUse ? partToString(contentToUse) : '';
return userPrompt.displayContent
? originalUserText
: stripReferenceContent(originalUserText);
};
export const RewindViewer: React.FC<RewindViewerProps> = ({
conversation,
onExit,
onRewind,
}) => {
const keyMatchers = useKeyMatchers();
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const {
selectedMessageId,
getStats,
confirmationStats,
selectMessage,
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return true;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
return true;
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
return true;
}
}
return false;
},
{ isActive: true },
);
// Height constraint calculations
const DIALOG_PADDING = 2; // Top/bottom padding
const HEADER_HEIGHT = 2; // Title + margin
const CONTROLS_HEIGHT = 2; // Controls text + margin
const listHeight = Math.max(
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
return (
<RewindConfirmation
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
void (async () => {
const userPrompt = interactions.find(
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
})();
}
}}
/>
);
}
if (isScreenReaderEnabled) {
return (
<Box flexDirection="column" width={terminalWidth}>
<Text bold>Rewind - Select a conversation point:</Text>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={true}
wrapAround={false}
onSelect={(item: MessageRecord) => {
if (item?.id) {
if (item.id === 'current-position') {
onExit();
} else {
selectMessage(item.id);
}
}
}}
renderItem={(itemWrapper) => {
const item = itemWrapper.value;
const text =
item.id === 'current-position'
? 'Stay at current position'
: getCleanedRewindText(item);
return <Text>{text}</Text>;
}}
/>
<Text color={theme.text.secondary}>
Press Esc to exit, Enter to select, arrow keys to navigate.
</Text>
</Box>
);
}
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
width={terminalWidth}
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text bold>{'> '}Rewind</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={false}
wrapAround={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(
userPrompt.displayContent || userPrompt.content,
)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const cleanedText = getCleanedRewindText(userPrompt);
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
</Box>
{stats ? (
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{stats.fileCount === 1
? firstFileName
? firstFileName
: '1 file changed'
: `${stats.fileCount} files changed`}{' '}
</Text>
{stats.addedLines > 0 && (
<Text color="green">+{stats.addedLines} </Text>
)}
{stats.removedLines > 0 && (
<Text color="red">-{stats.removedLines}</Text>
)}
</Box>
) : (
<Text color={theme.text.secondary}>
No files have been changed
</Text>
)}
</Box>
);
}}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
</Text>
</Box>
</Box>
);
};
@@ -1,96 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Command } from '../key/keyBindings.js';
import { formatCommand } from '../key/keybindingUtils.js';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutItems = (): ShortcutItem[] => [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
{ key: 'Double Esc', description: 'clear & rewind' },
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
{
key: formatCommand(Command.CYCLE_APPROVAL_MODE),
description: 'cycle mode',
},
{
key: formatCommand(Command.PASTE_CLIPBOARD),
description: 'paste images',
},
{
key: formatCommand(Command.TOGGLE_MARKDOWN),
description: 'raw markdown mode',
},
{
key: formatCommand(Command.REVERSE_SEARCH),
description: 'reverse-search history',
},
{
key: formatCommand(Command.OPEN_EXTERNAL_EDITOR),
description: 'open external editor',
},
];
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
<Box flexDirection="row">
<Box flexShrink={0} marginRight={1}>
<Text color={theme.text.accent}>{item.key}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>{item.description}</Text>
</Box>
</Box>
);
export const ShortcutsHelp: React.FC = () => {
const { terminalWidth } = useUIState();
const isNarrow = isNarrowWidth(terminalWidth);
const items = buildShortcutItems();
const itemsForDisplay = isNarrow
? items
: [
// Keep first column stable: !, @, Esc Esc, Tab Tab.
items[0],
items[5],
items[6],
items[1],
items[4],
items[7],
items[2],
items[8],
items[9],
items[3],
];
return (
<Box flexDirection="column" width="100%">
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{itemsForDisplay.map((item, index) => (
<Box
key={`${item.key}-${index}`}
width={isNarrow ? '100%' : '33%'}
paddingRight={isNarrow ? 0 : 2}
>
<Shortcut item={item} />
</Box>
))}
</Box>
</Box>
);
};
@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState } from '../types.js';
vi.mock('../contexts/OverflowContext.js');
vi.mock('../contexts/StreamingContext.js');
vi.mock('../hooks/useAlternateBuffer.js');
describe('ShowMoreLines', () => {
const mockUseOverflowState = vi.mocked(useOverflowState);
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
beforeEach(() => {
vi.clearAllMocks();
mockUseAlternateBuffer.mockReturnValue(false);
});
it.each([
[new Set(), StreamingState.Idle, true], // No overflow
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
])(
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
async (overflowingIds, streamingState, constrainHeight) => {
mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable<
ReturnType<typeof useOverflowState>
>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={constrainHeight} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
},
);
it('renders message in STANDARD mode when overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it.each([
[StreamingState.Idle],
[StreamingState.WaitingForConfirmation],
[StreamingState.Responding],
])(
'renders message in ASB mode when overflowing and state is %s',
async (streamingState) => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
},
);
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { theme } from '../semantic-colors.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
isOverflowing?: boolean;
}
export const ShowMoreLines = ({
constrainHeight,
isOverflowing: isOverflowingProp,
}: ShowMoreLinesProps) => {
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
const isOverflowing =
isOverflowingProp ??
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
if (
!isOverflowing ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation ||
streamingState === StreamingState.Responding
)
) {
return null;
}
return (
<Box paddingX={1} marginBottom={1}>
<Text color={theme.text.accent} wrap="truncate">
Press Ctrl+O to show more lines
</Text>
</Box>
);
};
@@ -1,93 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Box, Text } from 'ink';
import { render } from '../../test-utils/render.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState } from '../types.js';
vi.mock('../contexts/OverflowContext.js');
vi.mock('../contexts/StreamingContext.js');
vi.mock('../hooks/useAlternateBuffer.js');
describe('ShowMoreLines layout and padding', () => {
const mockUseOverflowState = vi.mocked(useOverflowState);
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
beforeEach(() => {
vi.clearAllMocks();
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders with single padding (paddingX=1, marginBottom=1)', async () => {
const TestComponent = () => (
<Box flexDirection="column">
<Text>Top</Text>
<ShowMoreLines constrainHeight={true} />
<Text>Bottom</Text>
</Box>
);
const { lastFrame, unmount } = await render(<TestComponent />);
// lastFrame() strips some formatting but keeps layout
const output = lastFrame({ allowEmpty: true });
// With paddingX=1, there should be a space before the text
// With marginBottom=1, there should be an empty line between the text and "Bottom"
// Since "Top" is just above it without margin, it should be on the previous line
const lines = output.split('\n');
expect(lines).toEqual([
'Top',
' Press Ctrl+O to show more lines',
'',
'Bottom',
'',
]);
unmount();
});
it('renders in Standard mode as well', async () => {
mockUseAlternateBuffer.mockReturnValue(false); // Standard mode
const TestComponent = () => (
<Box flexDirection="column">
<Text>Top</Text>
<ShowMoreLines constrainHeight={true} />
<Text>Bottom</Text>
</Box>
);
const { lastFrame, unmount } = await render(<TestComponent />);
const output = lastFrame({ allowEmpty: true });
const lines = output.split('\n');
expect(lines).toEqual([
'Top',
' Press Ctrl+O to show more lines',
'',
'Bottom',
'',
]);
unmount();
});
});
@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
export interface StatusDisplayProps {
hideContextSummary: boolean;
}
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
hideContextSummary,
}) => {
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
if (process.env['GEMINI_SYSTEM_MD']) {
return <Text color={theme.status.error}>|_|</Text>;
}
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={uiState.contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundTaskCount}
/>
);
}
return null;
};
@@ -1,437 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
type ThoughtSummary,
} from '@google/gemini-cli-core';
import stripAnsi from 'strip-ansi';
import { type ActiveHook } from '../types.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
/**
* Layout constants to prevent magic numbers.
*/
const LAYOUT = {
STATUS_MIN_HEIGHT: 1,
TIP_LEFT_MARGIN: 2,
TIP_RIGHT_MARGIN_NARROW: 0,
TIP_RIGHT_MARGIN_WIDE: 1,
INDICATOR_LEFT_MARGIN: 1,
CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
COLLISION_GAP: 10,
};
interface StatusRowProps {
showUiDetails: boolean;
isNarrow: boolean;
terminalWidth: number;
hideContextSummary: boolean;
hideUiDetailsForSuggestions: boolean;
hasPendingActionRequired: boolean;
}
/**
* Renders the loading or hook execution status.
*/
export const StatusNode: React.FC<{
showTips: boolean;
showWit: boolean;
thought: ThoughtSummary | null;
elapsedTime: number;
currentWittyPhrase: string | undefined;
activeHooks: ActiveHook[];
showLoadingIndicator: boolean;
errorVerbosity: 'low' | 'full' | undefined;
onResize?: (width: number) => void;
}> = ({
showTips,
showWit,
thought,
elapsedTime,
currentWittyPhrase,
activeHooks,
showLoadingIndicator,
errorVerbosity,
onResize,
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (node && onResize) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
onResize(Math.round(entry.contentRect.width));
}
});
observer.observe(node);
observerRef.current = observer;
}
},
[onResize],
);
if (activeHooks.length === 0 && !showLoadingIndicator) return null;
let currentLoadingPhrase: string | undefined = undefined;
let currentThought: ThoughtSummary | null = null;
if (activeHooks.length > 0) {
const userVisibleHooks = activeHooks.filter((h) =>
isUserVisibleHook(h.source),
);
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = stripAnsi(h.name);
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
} else {
currentLoadingPhrase = GENERIC_WORKING_LABEL;
}
} else {
// Sanitize thought subject to prevent terminal injection
currentThought = thought
? { ...thought, subject: stripAnsi(thought.subject) }
: null;
}
return (
<Box ref={onRefChange}>
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={errorVerbosity}
thought={currentThought}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={currentWittyPhrase}
/>
</Box>
);
};
export const StatusRow: React.FC<StatusRowProps> = ({
showUiDetails,
isNarrow,
terminalWidth,
hideContextSummary,
hideUiDetailsForSuggestions,
hasPendingActionRequired,
}) => {
const uiState = useUIState();
const settings = useSettings();
const {
isInteractiveShellWaiting,
showLoadingIndicator,
showTips,
showWit,
modeContentObj,
showMinimalContext,
} = useComposerStatus();
const [statusWidth, setStatusWidth] = useState(0);
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
tipObserverRef.current = null;
}
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
tipObserverRef.current = observer;
}
}, []);
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
// Collision detection using measured widths
const willCollideTip =
statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
const showTipLine = Boolean(
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
);
const showRow1Minimal =
showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
const showRow2Minimal =
(Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
showMinimalContext;
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
showWit={showWit}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
currentWittyPhrase={uiState.currentWittyPhrase}
activeHooks={uiState.activeHooks}
showLoadingIndicator={showLoadingIndicator}
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={onStatusResize}
/>
);
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: Status & Tips */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1Minimal ? (
<Box flexDirection="row" columnGap={1}>
{statusNode}
{!showUiDetails && showRow2Minimal && modeContentObj && (
<Box>
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
</Box>
)}
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{statusNode}
</Box>
)}
</Box>
<Box
flexShrink={0}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Modes & Context */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box
flexDirection="row"
alignItems="center"
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
)}
{!uiState.renderMarkdown && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
showRow2Minimal &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
flexDirection="row"
alignItems="center"
marginLeft={
isNarrow
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
}
>
{(showUiDetails || showMinimalContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{showMinimalContext && !showUiDetails && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
@@ -1,398 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { pickDefaultThemeName, type Theme } from '../themes/theme.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import type {
LoadableSettingScope,
LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { isDevelopment } from '../../utils/installationInfo.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (
themeName: string,
scope: LoadableSettingScope,
) => void | Promise<void>;
/** Callback function when the dialog is cancelled */
onCancel: () => void;
/** Callback function when a theme is highlighted */
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
availableTerminalHeight?: number;
terminalWidth: number;
}
import { resolveColor } from '../themes/color-utils.js';
function generateThemeItem(
name: string,
typeDisplay: string,
fullTheme: Theme | undefined,
terminalBackgroundColor: string | undefined,
) {
const isCompatible = fullTheme
? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
: true;
const themeBackground = fullTheme
? resolveColor(fullTheme.colors.Background)
: undefined;
const isBackgroundMatch =
terminalBackgroundColor &&
themeBackground &&
terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase();
return {
label: name,
value: name,
themeNameDisplay: name,
themeTypeDisplay: typeDisplay,
themeWarning: isCompatible ? '' : ' (Incompatible)',
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
key: name,
isCompatible,
};
}
export function ThemeDialog({
onSelect,
onCancel,
onHighlight,
settings,
availableTerminalHeight,
terminalWidth,
}: ThemeDialogProps): React.JSX.Element {
const isAlternateBuffer = useAlternateBuffer();
const { terminalBackgroundColor } = useUIState();
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
// Track the currently highlighted theme name
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
() => {
// If a theme is already set, use it.
if (settings.merged.ui.theme) {
return settings.merged.ui.theme;
}
// Otherwise, try to pick a theme that matches the terminal background.
return pickDefaultThemeName(
terminalBackgroundColor,
themeManager.getAllThemes(),
DEFAULT_THEME.name,
'Default Light',
);
},
);
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Generate theme items
const themeItems = themeManager
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const capitalizedType = capitalize(theme.type);
const typeDisplay = theme.name.endsWith(capitalizedType)
? ''
: capitalizedType;
return generateThemeItem(
theme.name,
typeDisplay,
fullTheme,
terminalBackgroundColor,
);
})
.sort((a, b) => {
// Show compatible themes first
if (a.isCompatible && !b.isCompatible) return -1;
if (!a.isCompatible && b.isCompatible) return 1;
// Then sort by name
return a.label.localeCompare(b.label);
});
// Find the index of the selected theme, but only if it exists in the list
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === highlightedThemeName,
);
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const handleThemeSelect = useCallback(
async (themeName: string) => {
await onSelect(themeName, selectedScope);
},
[onSelect, selectedScope],
);
const handleThemeHighlight = (themeName: string) => {
setHighlightedThemeName(themeName);
onHighlight(themeName);
};
const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
const handleScopeSelect = useCallback(
async (scope: LoadableSettingScope) => {
await onSelect(highlightedThemeName, scope);
},
[onSelect, highlightedThemeName],
);
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
useKeypress(
(key) => {
if (key.name === 'tab') {
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
return true;
}
if (key.name === 'escape') {
onCancel();
return true;
}
return false;
},
{ isActive: true },
);
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'ui.theme',
selectedScope,
settings,
);
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
// A safety margin to prevent text from touching the border.
// This is a complete hack unrelated to the 0.9 used in App.tsx
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
// Combined horizontal padding from the dialog and preview pane.
const TOTAL_HORIZONTAL_PADDING = 4;
const colorizeCodeWidth = Math.max(
Math.floor(
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
PREVIEW_PANE_WIDTH_PERCENTAGE *
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
),
1,
);
const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight;
let includePadding = true;
// Remove content from the LHS that can be omitted if it exceeds the available height.
if (totalLeftHandSideHeight > availableTerminalHeight) {
includePadding = false;
totalLeftHandSideHeight -= DIALOG_PADDING;
}
// Vertical space taken by elements other than the two code blocks in the preview pane.
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math.max(
availableTerminalHeight,
totalLeftHandSideHeight,
);
const availableTerminalHeightCodeBlock =
availableTerminalHeight -
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
(includePadding ? 2 : 0) * 2;
// Subtract margin between code blocks from available height.
const availableHeightForPanes = Math.max(
0,
availableTerminalHeightCodeBlock - 1,
);
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
const previewTheme =
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
DEFAULT_THEME;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
width="100%"
>
{mode === 'theme' ? (
<Box flexDirection="row">
{/* Left Column: Selection */}
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={mode === 'theme'} wrap="truncate">
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={theme.text.secondary}>
{otherScopeModifiedMessage}
</Text>
</Text>
<RadioButtonSelect
items={themeItems}
initialIndex={safeInitialThemeIndex}
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
isFocused={mode === 'theme'}
maxItemsToShow={12}
showScrollArrows={true}
showNumbers={mode === 'theme'}
renderItem={(item, { titleColor }) => {
// We know item has themeWarning because we put it there, but we need to cast or access safely
const itemWithExtras = item as typeof item & {
themeWarning?: string;
themeMatch?: string;
};
if (item.themeNameDisplay && item.themeTypeDisplay) {
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode = item.themeNameDisplay;
if (match) {
themeNamePart = (
<>
{match[1]}{' '}
<Text color={theme.text.secondary}>({match[2]})</Text>
</>
);
}
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{themeNamePart}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>
{itemWithExtras.themeMatch && (
<Text color={theme.status.success}>
{itemWithExtras.themeMatch}
</Text>
)}
{itemWithExtras.themeWarning && (
<Text color={theme.status.warning}>
{itemWithExtras.themeWarning}
</Text>
)}
</Text>
);
}
// Regular label display
return (
<Text color={titleColor} wrap="truncate">
{item.label}
</Text>
);
}}
/>
</Box>
{/* Right Column: Preview */}
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold color={theme.text.primary}>
Preview
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode({
code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
</Box>
</Box>
) : (
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={mode === 'scope'}
initialScope={selectedScope}
/>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary} wrap="truncate">
(Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '}
{mode === 'theme' ? 'configure scope' : 'select theme'}, Esc to close)
</Text>
</Box>
</Box>
);
}
@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
}
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.secondary}>
Press Ctrl+O to {action} lines of the last response
</Text>
);
}
return null;
};
@@ -1,53 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
</Box>
</Box>
);
};
@@ -1,50 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
</Box>
);
};
@@ -1,213 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, type DOMElement } from 'ink';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { StickyHeader } from '../StickyHeader.js';
import { useUIActions } from '../../contexts/UIActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import {
type Config,
ShellExecutionService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
SHELL_CONTENT_OVERHEAD,
} from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
isExpandable?: boolean;
}
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
name,
description,
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
ptyId,
config,
isFirst,
borderColor,
borderDimColor,
isExpandable,
originalRequestName,
}) => {
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
constrainHeight,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
status,
ptyId,
activeShellPtyId,
embeddedShellFocused,
);
const maxLines = calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
constrainHeight,
isExpandable,
});
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
React.useEffect(() => {
const isExecuting = status === CoreToolCallStatus.Executing;
if (isExecuting && ptyId) {
try {
const childWidth = terminalWidth - 4; // account for padding and borders
const finalHeight =
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
ShellExecutionService.resizePty(
ptyId,
Math.max(1, childWidth),
Math.max(1, finalHeight),
);
} catch (e) {
if (
!(
e instanceof Error &&
e.message.includes('Cannot resize a pty that has already exited')
)
) {
throw e;
}
}
}
}, [ptyId, status, terminalWidth, availableHeight]);
const { setEmbeddedShellFocused } = useUIActions();
const wasFocusedRef = React.useRef(false);
React.useEffect(() => {
if (isThisShellFocused) {
wasFocusedRef.current = true;
} else if (wasFocusedRef.current) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
}
wasFocusedRef.current = false;
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const headerRef = React.useRef<DOMElement>(null);
const contentRef = React.useRef<DOMElement>(null);
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
const handleFocus = () => {
if (isThisShellFocusable) {
setEmbeddedShellFocused(true);
}
};
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,
isThisShellFocused,
resultDisplay,
);
return (
<>
<StickyHeader
width={terminalWidth}
isFirst={isFirst}
borderColor={borderColor}
borderDimColor={borderDimColor}
containerRef={headerRef}
>
<ToolStatusIndicator
status={status}
name={name}
isFocused={isThisShellFocused}
/>
<ToolInfo
name={name}
status={status}
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
isThisShellFocused={isThisShellFocused}
/>
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
<Box
ref={contentRef}
width={terminalWidth}
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
<ToolResultDisplay
resultDisplay={resultDisplay}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={maxLines}
/>
{isThisShellFocused && config && (
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
)}
</Box>
</>
);
};
@@ -1,63 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
import { formatCommand } from '../../key/keybindingUtils.js';
import { Command } from '../../key/keyBindings.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
const todos: TodoList | null = useMemo(() => {
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
for (let i = uiState.history.length - 1; i >= 0; i--) {
const entry = uiState.history[i];
if (entry.type !== 'tool_group') {
continue;
}
const toolGroup = entry as HistoryItemToolGroup;
for (const tool of toolGroup.tools) {
if (
typeof tool.resultDisplay !== 'object' ||
!('todos' in tool.resultDisplay)
) {
continue;
}
return tool.resultDisplay;
}
}
return null;
}, [uiState.history]);
const checklistItems: ChecklistItemData[] = useMemo(() => {
if (!todos || !todos.todos) {
return [];
}
return todos.todos.map((todo) => ({
status: todo.status,
label: todo.description,
}));
}, [todos]);
if (!todos || !todos.todos) {
return null;
}
return (
<Checklist
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
toggleHint={`${formatCommand(Command.SHOW_FULL_TODOS)} to toggle`}
/>
);
};
@@ -1,306 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
HistoryItem,
HistoryItemWithoutId,
IndividualToolCallDisplay,
} from '../../types.js';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { TopicMessage, isTopicTool } from './TopicMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
Kind,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId;
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
item,
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
() =>
allToolCalls.filter((t) => {
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
!t.isClientInitiated
) {
return false;
}
return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
});
}),
[allToolCalls, isLowErrorVerbosity],
);
const config = useConfig();
const {
activePtyId,
embeddedShellFocused,
backgroundTasks,
pendingHistoryItems,
} = useUIState();
const { borderColor, borderDimColor } = useMemo(
() =>
getToolGroupBorderAppearance(
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundTasks,
],
);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
const visibleToolCalls = useMemo(
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
);
const staticHeight = /* border */ 2;
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (
tool.kind !== Kind.Agent &&
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls =
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
countToolCallsWithResults;
const groupedTools = useMemo(() => {
const groups: Array<
IndividualToolCallDisplay | IndividualToolCallDisplay[]
> = [];
for (const tool of visibleToolCalls) {
if (tool.kind === Kind.Agent) {
const lastGroup = groups[groups.length - 1];
if (Array.isArray(lastGroup)) {
lastGroup.push(tool);
} else {
groups.push([tool]);
}
} else {
groups.push(tool);
}
}
return groups;
}, [visibleToolCalls]);
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
Math.max(1, countToolCallsWithResults),
),
1,
)
: undefined;
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
// internal errors, plan-mode hidden write/edit), we should not emit standalone
// border fragments. The only case where an empty group should render is the
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
// and only if it's actually continuing an open box from above.
const isExplicitClosingSlice = allToolCalls.length === 0;
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
return null;
}
const content = (
<Box
flexDirection="column"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{groupedTools.map((group, index) => {
let isFirst = index === 0;
if (!isFirst) {
// Check if all previous tools were topics
let allPreviousWereTopics = true;
for (let i = 0; i < index; i++) {
const prevGroup = groupedTools[i];
if (Array.isArray(prevGroup) || !isTopicTool(prevGroup.name)) {
allPreviousWereTopics = false;
break;
}
}
isFirst = allPreviousWereTopics;
}
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst;
if (Array.isArray(group)) {
return (
<SubagentGroupDisplay
key={group[0].callId}
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={resolvedIsFirst}
isExpandable={isExpandable}
/>
);
}
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const isTopicToolCall = isTopicTool(tool.name);
const commonProps = {
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst: resolvedIsFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
<Box
key={tool.callId}
flexDirection="column"
minHeight={1}
width={contentWidth}
>
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
{tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
</Box>
)}
</Box>
);
})}
{/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/}
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false &&
(visibleToolCalls.length === 0 ||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
</Box>
);
return content;
};
@@ -1,84 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { type ToolMessageProps, ToolMessage } from './ToolMessage.js';
import { StreamingState } from '../../types.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
const baseProps: ToolMessageProps = {
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test **bold** and `code` markdown',
status: CoreToolCallStatus.Success,
terminalWidth: 80,
confirmationDetails: undefined,
emphasis: 'medium',
isFirst: true,
borderColor: 'green',
borderDimColor: false,
};
it.each([
{
renderMarkdown: true,
useAlternateBuffer: false,
description: '(default, regular buffer)',
},
{
renderMarkdown: true,
useAlternateBuffer: true,
description: '(default, alternate buffer)',
},
{
renderMarkdown: false,
useAlternateBuffer: false,
description: '(raw markdown, regular buffer)',
},
{
renderMarkdown: false,
useAlternateBuffer: true,
description: '(raw markdown, alternate buffer)',
},
// Test cases where height constraint affects rendering in regular buffer but not alternate
{
renderMarkdown: true,
useAlternateBuffer: false,
availableTerminalHeight: 10,
description: '(constrained height, regular buffer -> forces raw)',
},
{
renderMarkdown: true,
useAlternateBuffer: true,
availableTerminalHeight: 10,
description: '(constrained height, alternate buffer -> keeps markdown)',
},
])(
'renders with renderMarkdown=$renderMarkdown, useAlternateBuffer=$useAlternateBuffer $description',
async ({ renderMarkdown, useAlternateBuffer, availableTerminalHeight }) => {
const { lastFrame, unmount } = await renderWithProviders(
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
availableTerminalHeight={availableTerminalHeight}
/>
</StreamingContext.Provider>,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
},
);
});
@@ -1,227 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
export interface ToolResultDisplayProps {
resultDisplay: string | object | undefined;
availableTerminalHeight?: number;
terminalWidth: number;
renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
overflowDirection?: 'top' | 'bottom';
}
interface FileDiffResult {
fileDiff: string;
fileName: string;
}
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
resultDisplay,
availableTerminalHeight,
terminalWidth,
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
const keyExtractor = React.useCallback(
(_: AnsiLine, index: number) => index.toString(),
[],
);
const renderVirtualizedAnsiLine = React.useCallback(
({ item }: { item: AnsiLine }) => (
<Box height={1} overflow="hidden">
<AnsiLineText line={item} />
</Box>
),
[],
);
if (!resultDisplay) return null;
// 1. Early return for background tools (Todos)
if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
// display nothing, as the TodoTray will handle rendering todos
return null;
}
const renderContent = (contentData: string | object | undefined) => {
// Check if string content is valid JSON and pretty-print it
const prettyJSON =
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
const formattedJSON = prettyJSON
? JSON.stringify(prettyJSON, null, 2)
: null;
let content: React.ReactNode;
if (formattedJSON) {
// Render pretty-printed JSON
content = (
<Text wrap="wrap" color={theme.text.primary}>
{formattedJSON}
</Text>
);
} else if (isSubagentProgress(contentData)) {
content = (
<SubagentProgressDisplay
progress={contentData}
terminalWidth={childWidth}
/>
);
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
content = (
<DiffRenderer
diffContent={
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(contentData as FileDiffResult).fileDiff
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
filename={(contentData as FileDiffResult).fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={contentData as AnsiOutput}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
width={childWidth}
maxLines={isAlternateBuffer ? undefined : maxLines}
disableTruncation={shouldDisableTruncation}
/>
);
}
// Final render based on session mode
if (isAlternateBuffer) {
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
>
{content}
</Scrollable>
);
}
return content;
};
// ASB Mode Handling (Interactive/Fullscreen)
if (isAlternateBuffer) {
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
limit,
);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
/>
</Box>
);
}
// Standard path for strings/diffs in ASB
return (
<Box width={childWidth} flexDirection="column">
{renderContent(resultDisplay)}
</Box>
);
}
// Standard Mode Handling (History/Scrollback)
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
return (
<Box width={childWidth} flexDirection="column">
<SlicingMaxSizedBox
data={resultDisplay}
maxLines={maxLines}
isAlternateBuffer={isAlternateBuffer}
maxHeight={availableHeight}
maxWidth={childWidth}
overflowDirection={overflowDirection}
>
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
</SlicingMaxSizedBox>
</Box>
);
};
@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
import {
interpolateColor,
resolveColor,
getSafeLowColorBackground,
} from '../../themes/color-utils.js';
import { isLowColorDepth, isITerm2 } from '../../utils/terminalUtils.js';
export interface HalfLinePaddedBoxProps {
/**
* The base color to blend with the terminal background.
*/
backgroundBaseColor: string;
/**
* The opacity (0-1) for blending the backgroundBaseColor onto the terminal background.
*/
backgroundOpacity: number;
/**
* Whether to render the solid background color.
*/
useBackgroundColor?: boolean;
children: React.ReactNode;
}
/**
* A container component that renders a solid background with half-line padding
* at the top and bottom using block characters (▀/▄).
*/
export const HalfLinePaddedBox: React.FC<HalfLinePaddedBoxProps> = (props) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (props.useBackgroundColor === false || isScreenReaderEnabled) {
return <>{props.children}</>;
}
return <HalfLinePaddedBoxInternal {...props} />;
};
const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
backgroundBaseColor,
backgroundOpacity,
children,
}) => {
const { terminalWidth } = useUIState();
const terminalBg = theme.background.primary || 'black';
const isLowColor = isLowColorDepth();
const backgroundColor = useMemo(() => {
// Interpolated background colors often look bad in 256-color terminals
if (isLowColor) {
return getSafeLowColorBackground(terminalBg);
}
const resolvedBase =
resolveColor(backgroundBaseColor) || backgroundBaseColor;
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
return interpolateColor(
resolvedTerminalBg,
resolvedBase,
backgroundOpacity,
);
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
if (!backgroundColor) {
return <>{children}</>;
}
const isITerm = isITerm2();
if (isITerm) {
return (
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
minHeight={1}
flexShrink={0}
>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
</Box>
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
backgroundColor={backgroundColor}
>
{children}
</Box>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
</Box>
</Box>
);
}
return (
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
minHeight={1}
flexShrink={0}
backgroundColor={backgroundColor}
>
<Box width={terminalWidth} flexDirection="row">
<Text backgroundColor={backgroundColor} color={terminalBg}>
{'▀'.repeat(terminalWidth)}
</Text>
</Box>
{children}
<Box width={terminalWidth} flexDirection="row">
<Text color={terminalBg} backgroundColor={backgroundColor}>
{'▄'.repeat(terminalWidth)}
</Text>
</Box>
</Box>
);
};
@@ -1,564 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useState,
useRef,
useLayoutEffect,
forwardRef,
useImperativeHandle,
useMemo,
useCallback,
} 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';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
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;
scrollbarThumbColor?: string;
};
export type VirtualizedListRef<T> = {
scrollBy: (delta: number) => void;
scrollTo: (offset: number) => void;
scrollToEnd: () => void;
scrollToIndex: (params: {
index: number;
viewOffset?: number;
viewPosition?: number;
}) => void;
scrollToItem: (params: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => void;
getScrollIndex: () => number;
getScrollState: () => {
scrollTop: number;
scrollHeight: number;
innerHeight: number;
};
};
function findLastIndex<T>(
array: T[],
predicate: (value: T, index: number, obj: T[]) => unknown,
): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i], i, array)) {
return i;
}
}
return -1;
}
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
) {
const {
data,
renderItem,
estimatedItemHeight,
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
dataRef.current = data;
}, [data]);
const [scrollAnchor, setScrollAnchor] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(typeof initialScrollIndex === 'number' &&
initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
return {
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
};
}
if (typeof initialScrollIndex === 'number') {
return {
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
offset: initialScrollOffsetInIndex ?? 0,
};
}
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(typeof initialScrollIndex === 'number' &&
initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
containerObserverRef.current = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
() => () => {
containerObserverRef.current?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
const scrollableContainerHeight = containerHeight;
const getAnchorForScrollTop = useCallback(
(
scrollTop: number,
offsets: number[],
): { index: number; offset: number } => {
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
if (index === -1) {
return { index: 0, offset: 0 };
}
return { index, offset: scrollTop - offsets[index] };
},
[],
);
const actualScrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(actualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
const contentPreviouslyFit =
prevTotalHeight.current <= prevContainerHeight.current;
const wasScrolledToBottomPixels =
prevScrollTop.current >=
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
const listGrew = data.length > prevDataLength.current;
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
} else if (
(scrollAnchor.index >= data.length ||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
setScrollAnchor({ index: 0, offset: 0 });
}
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = actualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
]);
useLayoutEffect(() => {
if (
isInitialScrollSet.current ||
offsets.length <= 1 ||
totalHeight <= 0 ||
containerHeight <= 0
) {
return;
}
if (typeof initialScrollIndex === 'number') {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
setIsStickingToBottom(true);
isInitialScrollSet.current = true;
return;
}
const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex));
const offset = initialScrollOffsetInIndex ?? 0;
const newScrollTop = (offsets[index] ?? 0) + offset;
const clampedScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
);
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
isInitialScrollSet.current = true;
}
}, [
initialScrollIndex,
initialScrollOffsetInIndex,
offsets,
totalHeight,
containerHeight,
getAnchorForScrollTop,
data.length,
heights,
scrollableContainerHeight,
]);
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > actualScrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight = offsets[startIndex] ?? 0;
const bottomSpacerHeight =
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 node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
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)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
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 { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta: number) => {
if (delta < 0) {
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const actualCurrent = Math.min(currentScrollTop, maxScroll);
let newScrollTop = Math.max(0, actualCurrent + delta);
if (newScrollTop >= maxScroll) {
setIsStickingToBottom(true);
newScrollTop = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
);
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
},
scrollToIndex: ({
index,
viewOffset = 0,
viewPosition = 0,
}: {
index: number;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
},
scrollToItem: ({
item,
viewOffset = 0,
viewPosition = 0,
}: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const index = data.indexOf(item);
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - containerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: containerHeight,
};
},
}),
[
offsets,
scrollAnchor,
totalHeight,
getAnchorForScrollTop,
data,
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
containerHeight,
],
);
return (
<Box
ref={containerRefCallback}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
width="100%"
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
>
<Box
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -actualScrollTop : 0}
>
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
<Box height={bottomSpacerHeight} flexShrink={0} />
</Box>
</Box>
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
) => React.ReactElement;
export { VirtualizedListWithForwardRef as VirtualizedList };
VirtualizedList.displayName = 'VirtualizedList';
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { VirtualizedList } from './VirtualizedList.js';
import { Box, Text } from 'ink';
import { act } from 'react';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
copyModeEnabled: false,
})),
}));
describe('VirtualizedList Pruning', () => {
const keyExtractor = (item: string) => item;
it('prunes heights when items are removed', async () => {
const data = ['item1', 'item2', 'item3'];
// We want to verify that internal state 'heights' is pruned.
// Since we can't easily see internal state, we can't directly assert on it
// without modifying the component.
// But we already added the pruning logic and it's straightforward.
// Let's just make sure the component still works correctly after pruning.
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
data={data}
renderItem={({ item }) => <Box height={1}><Text>{item}</Text></Box>}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
/>
</Box>
);
expect(lastFrame()).toContain('item1');
expect(lastFrame()).toContain('item2');
expect(lastFrame()).toContain('item3');
// Remove item2
const newData = ['item1', 'item3'];
await act(async () => {
rerender(
<Box height={10} width={100}>
<VirtualizedList
data={newData}
renderItem={({ item }) => <Box height={1}><Text>{item}</Text></Box>}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
/>
</Box>
);
});
await waitUntilReady();
expect(lastFrame()).toContain('item1');
expect(lastFrame()).not.toContain('item2');
expect(lastFrame()).toContain('item3');
unmount();
});
});
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type {
AccountSuspensionInfo,
} from './UIStateContext.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import type {
HistoryItem,
PermissionConfirmationRequest,
LoopDetectionConfirmationRequest,
ActiveHook,
} from '../types.js';
import { type IdeInfo } from '@google/gemini-cli-core';
import { type SlashCommand } from '../commands/types.js';
export interface GlobalStateContextValue {
isThemeDialogOpen: boolean;
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean;
apiKeyDefaultValue?: string;
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
showErrorDetails: boolean;
showDebugProfiler: boolean;
copyModeEnabled: boolean;
errorCount: number;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
backgroundTasks: Map<number, BackgroundTask>;
quittingMessages: HistoryItem[] | null;
backgroundTaskHeight: number;
activeBackgroundTaskPid: number | null;
isBackgroundTaskListOpen: boolean;
dialogsVisible: boolean;
customDialog: React.ReactNode | null;
isEditorDialogOpen: boolean;
editorError: string | null;
showPrivacyNotice: boolean;
corgiMode: boolean;
debugMessage: string;
shortcutsHelpVisible: boolean;
isInputActive: boolean;
inputWidth: number;
suggestionsWidth: number;
isResuming: boolean;
historyRemountKey: number;
initError: string | null;
updateInfo: { message: string; isUpdating?: boolean } | null;
renderMarkdown: boolean;
showFullTodos: boolean;
// Dialog state from AppContainer
adminSettingsChanged: boolean;
showIdeRestartPrompt: boolean;
ideTrustRestartReason: string;
newAgents: string[] | null;
quota: {
proQuotaRequest: any;
validationRequest: any;
overageMenuRequest: any;
emptyWalletRequest: any;
stats: any;
};
shouldShowIdePrompt: boolean;
currentIDE: IdeInfo | null;
isRestarting: boolean;
isFolderTrustDialogOpen: boolean;
folderDiscoveryResults: any;
isPolicyUpdateDialogOpen: boolean;
policyUpdateConfirmationRequest: any;
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
permissionConfirmationRequest: PermissionConfirmationRequest | null;
commandConfirmationRequest: any;
authConsentRequest: any;
confirmUpdateExtensionRequests: any[];
selectedAgentName: string | null;
selectedAgentDisplayName: string | null;
selectedAgentDefinition: any;
permissionsDialogProps: any;
currentModel: string;
slashCommands: SlashCommand[] | undefined;
sessionStats: any;
isTrustedFolder: boolean | undefined;
activeHooks: ActiveHook[];
showIsExpandableHint: boolean;
terminalBackgroundColor: string | undefined;
extensionsUpdateState: any;
bannerVisible: boolean;
bannerData: any;
transientMessage: any;
nightly: boolean;
contextFileNames: string[];
ideContextState: any;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
currentTip: string | undefined;
buffer: any;
currentWittyPhrase: string | undefined;
elapsedTime: number;
showApprovalModeIndicator: any;
allowPlanMode: boolean;
shellModeActive: boolean;
showEscapePrompt: boolean;
queueErrorMessage: string | null;
geminiMdFileCount: number;
}
export const GlobalStateContext = createContext<
GlobalStateContextValue | undefined
>(undefined);
export const useGlobalState = (): GlobalStateContextValue => {
const context = useContext(GlobalStateContext);
if (context === undefined) {
throw new Error('useGlobalState must be used within a GlobalStateProvider');
}
return context;
};
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { HistoryItem } from '../types.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
export interface HistoryContextValue {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
historyRemountKey: number;
}
export const HistoryContext = createContext<HistoryContextValue | null>(null);
export const useHistory = () => {
const context = useContext(HistoryContext);
if (!context) {
throw new Error('useHistory must be used within a HistoryProvider');
}
return context;
};
@@ -1,22 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { createContext } from 'react';
import type { StreamingState } from '../types.js';
export const StreamingContext = createContext<StreamingState | undefined>(
undefined,
);
export const useStreamingContext = (): StreamingState => {
const context = React.useContext(StreamingContext);
if (context === undefined) {
throw new Error(
'useStreamingContext must be used within a StreamingContextProvider',
);
}
return context;
};
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext, type RefObject } from 'react';
import { type DOMElement } from 'ink';
export interface TerminalStateContextValue {
terminalWidth: number;
terminalHeight: number;
mainAreaWidth: number;
isAlternateBuffer: boolean;
constrainHeight: boolean;
embeddedShellFocused: boolean;
rootUiRef: RefObject<DOMElement>;
mainControlsRef: RefObject<DOMElement>;
stableControlsHeight: number;
}
export const TerminalStateContext = createContext<
TerminalStateContextValue | undefined
>(undefined);
export const useTerminalState = (): TerminalStateContextValue => {
const context = useContext(TerminalStateContext);
if (context === undefined) {
throw new Error(
'useTerminalState must be used within a TerminalStateProvider',
);
}
return context;
};
@@ -1,241 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type {
HistoryItem,
ThoughtSummary,
ConfirmationRequest,
QuotaStats,
LoopDetectionConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
ActiveHook,
PermissionConfirmationRequest,
} from '../types.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
IdeContext,
ApprovalMode,
UserTierId,
IdeInfo,
AuthType,
FallbackIntent,
ValidationIntent,
AgentDefinition,
FolderDiscoveryResults,
PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
import type { UpdateObject } from '../utils/updateCheck.js';
export interface ProQuotaDialogRequest {
failedModel: string;
fallbackModel: string;
message: string;
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
authType?: AuthType;
resolve: (intent: FallbackIntent) => void;
}
export interface ValidationDialogRequest {
validationLink?: string;
validationDescription?: string;
learnMoreUrl?: string;
resolve: (intent: ValidationIntent) => void;
}
/** Intent for overage menu dialog */
export type OverageMenuIntent =
| 'use_credits'
| 'use_fallback'
| 'manage'
| 'stop';
export interface OverageMenuDialogRequest {
failedModel: string;
fallbackModel?: string;
resetTime?: string;
creditBalance: number;
userEmail?: string;
resolve: (intent: OverageMenuIntent) => void;
}
/** Intent for empty wallet dialog */
export type EmptyWalletIntent = 'get_credits' | 'use_fallback' | 'stop';
export interface EmptyWalletDialogRequest {
failedModel: string;
fallbackModel?: string;
resetTime?: string;
userEmail?: string;
onGetCredits: () => void;
resolve: (intent: EmptyWalletIntent) => void;
}
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
export interface QuotaState {
userTier: UserTierId | undefined;
stats: QuotaStats | undefined;
proQuotaRequest: ProQuotaDialogRequest | null;
validationRequest: ValidationDialogRequest | null;
// G1 AI Credits overage flow
overageMenuRequest: OverageMenuDialogRequest | null;
emptyWalletRequest: EmptyWalletDialogRequest | null;
}
export interface AccountSuspensionInfo {
message: string;
appealUrl?: string;
appealLinkText?: string;
}
export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
isThemeDialogOpen: boolean;
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean;
apiKeyDefaultValue?: string;
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
selectedAgentName?: string;
selectedAgentDisplayName?: string;
selectedAgentDefinition?: AgentDefinition;
isPermissionsDialogOpen: boolean;
permissionsDialogProps: { targetDirectory?: string } | null;
slashCommands: readonly SlashCommand[] | undefined;
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
commandContext: CommandContext;
commandConfirmationRequest: ConfirmationRequest | null;
authConsentRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
permissionConfirmationRequest: PermissionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
shellModeActive: boolean;
userMessages: string[];
buffer: TextBuffer;
inputWidth: number;
suggestionsWidth: number;
isInputActive: boolean;
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
folderDiscoveryResults: FolderDiscoveryResults | null;
isPolicyUpdateDialogOpen: boolean;
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
ideContextState: IdeContext | undefined;
renderMarkdown: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string | undefined;
currentTip: string | undefined;
currentWittyPhrase: string | undefined;
historyRemountKey: number;
activeHooks: ActiveHook[];
messageQueue: string[];
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
quota: QuotaState;
currentModel: string;
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
stableControlsHeight: number;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
dialogsVisible: boolean;
pendingHistoryItems: HistoryItemWithoutId[];
nightly: boolean;
branchName: string | undefined;
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.RefCallback<DOMElement | null>;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
updateInfo: UpdateObject | null;
showIdeRestartPrompt: boolean;
ideTrustRestartReason: RestartReason;
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
copyModeEnabled: boolean;
bannerData: {
defaultText: string;
warningText: string;
};
bannerVisible: boolean;
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundTasks: Map<number, BackgroundTask>;
activeBackgroundTaskPid: number | null;
backgroundTaskHeight: number;
isBackgroundTaskListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
hintMode: boolean;
hintBuffer: string;
transientMessage: {
text: string;
type: TransientMessageType;
} | null;
}
export const UIStateContext = createContext<UIState | null>(null);
export const useUIState = () => {
const context = useContext(UIStateContext);
if (!context) {
throw new Error('useUIState must be used within a UIStateProvider');
}
return context;
};
@@ -1,193 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
shellReducer,
initialState,
type ShellState,
type ShellAction,
} from './shellReducer.js';
describe('shellReducer', () => {
it('should return the initial state', () => {
// @ts-expect-error - testing default case
expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual(
initialState,
);
});
it('should handle SET_ACTIVE_PTY', () => {
const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 };
const state = shellReducer(initialState, action);
expect(state.activeShellPtyId).toBe(12345);
});
it('should handle SET_OUTPUT_TIME', () => {
const now = Date.now();
const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now };
const state = shellReducer(initialState, action);
expect(state.lastShellOutputTime).toBe(now);
});
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundTaskVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundTaskVisible).toBe(false);
});
it('should handle REGISTER_TASK', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundTasks.has(1001)).toBe(true);
expect(state.backgroundTasks.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
});
it('should not REGISTER_TASK if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_TASK', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_TASK',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundTasks.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
});
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
});
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundTaskVisible: false,
backgroundTasks: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
});
it('should handle SYNC_BACKGROUND_TASKS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
const state = shellReducer(initialState, action);
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
});
it('should handle DISMISS_TASK', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundTasks.has(1001)).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
});
});
-136
View File
@@ -1,136 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
export interface BackgroundTask {
pid: number;
command: string;
output: string | AnsiOutput;
isBinary: boolean;
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_TASK';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_TASKS' }
| { type: 'DISMISS_TASK'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
};
export function shellReducer(
state: ShellState,
action: ShellAction,
): ShellState {
switch (action.type) {
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundTaskVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
};
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundTasks: nextTasks };
}
case 'UPDATE_TASK': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
const nextTasks = new Map(state.backgroundTasks);
const updatedTask = { ...task, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextTasks.delete(action.pid);
}
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
}
case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
// Note: we mutate the task object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = task.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof task.output === 'string'
? task.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
task.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundTaskVisible) {
return {
...nextState,
backgroundTasks: new Map(state.backgroundTasks),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
}
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
return {
...state,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
};
}
default:
return state;
}
}
@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
import { type HistoryItemToolGroup, StreamingState } from '../types.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from './usePhraseCycler.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
/**
* A hook that encapsulates complex status and action-required logic for the Composer.
*/
export const useComposerStatus = () => {
const uiState = useUIState();
const settings = useSettings();
const hasPendingToolConfirmation = useMemo(
() =>
(uiState.pendingHistoryItems ?? [])
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.some((item) =>
item.tools.some(
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,
),
),
[uiState.pendingHistoryItems],
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const isInteractiveShellWaiting = Boolean(
uiState.currentLoadingPhrase?.includes(INTERACTIVE_SHELL_WAITING_PHRASE),
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
const modeContentObj = useMemo(() => {
const hideMinimalModeHintWhileBusy =
!uiState.cleanUiDetailsVisible &&
(showLoadingIndicator || uiState.activeHooks.length > 0);
if (hideMinimalModeHintWhileBusy) return null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
return { text: 'YOLO', color: theme.status.error };
case ApprovalMode.PLAN:
return { text: 'plan', color: theme.status.success };
case ApprovalMode.AUTO_EDIT:
return { text: 'auto edit', color: theme.status.warning };
case ApprovalMode.DEFAULT:
default:
return null;
}
}, [
uiState.cleanUiDetailsVisible,
showLoadingIndicator,
uiState.activeHooks.length,
showApprovalModeIndicator,
]);
const showMinimalContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
/**
* Use the setting if provided, otherwise default to true for the new UX.
* This allows tests to override the collapse behavior.
*/
const shouldCollapseDuringApproval =
settings.merged.ui.collapseDrawerDuringApproval !== false;
return {
hasPendingActionRequired,
shouldCollapseDuringApproval,
isInteractiveShellWaiting,
showLoadingIndicator,
showTips,
showWit,
modeContentObj,
showMinimalContext,
};
};
@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act, useCallback } from 'react';
import { vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { useConsoleMessages } from './useConsoleMessages.js';
import { CoreEvent, type ConsoleLogPayload } from '@google/gemini-cli-core';
// Mock coreEvents
let consoleLogHandler: ((payload: ConsoleLogPayload) => void) | undefined;
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await importOriginal()) as any;
return {
...actual,
coreEvents: {
on: vi.fn((event, handler) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = handler;
}
}),
off: vi.fn((event) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = undefined;
}
}),
emitConsoleLog: vi.fn(),
},
};
});
describe('useConsoleMessages', () => {
beforeEach(() => {
vi.useFakeTimers();
consoleLogHandler = undefined;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
const useTestableConsoleMessages = () => {
const { ...rest } = useConsoleMessages();
const log = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'log', content });
}
}, []);
const error = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'error', content });
}
}, []);
return {
...rest,
log,
error,
clearConsoleMessages: rest.clearConsoleMessages,
};
};
const renderConsoleMessagesHook = async () => {
let hookResult: ReturnType<typeof useTestableConsoleMessages>;
function TestComponent() {
hookResult = useTestableConsoleMessages();
return null;
}
const { unmount } = await render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
unmount,
};
};
it('should initialize with an empty array of console messages', async () => {
const { result } = await renderConsoleMessagesHook();
expect(result.current.consoleMessages).toEqual([]);
});
it('should add a new message when log is called', async () => {
const { result } = await renderConsoleMessagesHook();
act(() => {
result.current.log('Test message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.consoleMessages).toEqual([
{ type: 'log', content: 'Test message', count: 1 },
]);
});
it('should batch and count identical consecutive messages', async () => {
const { result } = await renderConsoleMessagesHook();
act(() => {
result.current.log('Test message');
result.current.log('Test message');
result.current.log('Test message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.consoleMessages).toEqual([
{ type: 'log', content: 'Test message', count: 3 },
]);
});
it('should not batch different messages', async () => {
const { result } = await renderConsoleMessagesHook();
act(() => {
result.current.log('First message');
result.current.error('Second message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.consoleMessages).toEqual([
{ type: 'log', content: 'First message', count: 1 },
{ type: 'error', content: 'Second message', count: 1 },
]);
});
it('should clear all messages when clearConsoleMessages is called', async () => {
const { result } = await renderConsoleMessagesHook();
act(() => {
result.current.log('A message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.consoleMessages).toHaveLength(1);
act(() => {
result.current.clearConsoleMessages();
});
expect(result.current.consoleMessages).toHaveLength(0);
});
it('should clear the pending timeout when clearConsoleMessages is called', async () => {
const { result } = await renderConsoleMessagesHook();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
act(() => {
result.current.log('A message');
});
act(() => {
result.current.clearConsoleMessages();
});
expect(clearTimeoutSpy).toHaveBeenCalled();
// clearTimeoutSpy.mockRestore() is handled by afterEach restoreAllMocks
});
it('should clean up the timeout on unmount', async () => {
const { result, unmount } = await renderConsoleMessagesHook();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
act(() => {
result.current.log('A message');
});
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
@@ -1,225 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useCallback,
useEffect,
useReducer,
useRef,
startTransition,
} from 'react';
import type { ConsoleMessageItem } from '../types.js';
import {
coreEvents,
CoreEvent,
type ConsoleLogPayload,
} from '@google/gemini-cli-core';
export interface UseConsoleMessagesReturn {
consoleMessages: ConsoleMessageItem[];
clearConsoleMessages: () => void;
}
type Action =
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
| { type: 'CLEAR' };
function consoleMessagesReducer(
state: ConsoleMessageItem[],
action: Action,
): ConsoleMessageItem[] {
const MAX_CONSOLE_MESSAGES = 1000;
switch (action.type) {
case 'ADD_MESSAGES': {
const newMessages = [...state];
for (const queuedMessage of action.payload) {
const lastMessage = newMessages[newMessages.length - 1];
if (
lastMessage &&
lastMessage.type === queuedMessage.type &&
lastMessage.content === queuedMessage.content
) {
// Create a new object for the last message to ensure React detects
// the change, preventing mutation of the existing state object.
newMessages[newMessages.length - 1] = {
...lastMessage,
count: lastMessage.count + 1,
};
} else {
newMessages.push({ ...queuedMessage, count: 1 });
}
}
// Limit the number of messages to prevent memory issues
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
}
return newMessages;
}
case 'CLEAR':
return [];
default:
return state;
}
}
export function useConsoleMessages(): UseConsoleMessagesReturn {
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isProcessingRef = useRef(false);
const processQueue = useCallback(() => {
if (messageQueueRef.current.length > 0) {
isProcessingRef.current = true;
const messagesToProcess = messageQueueRef.current;
messageQueueRef.current = [];
startTransition(() => {
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
});
}
timeoutRef.current = null;
}, []);
const handleNewMessage = useCallback(
(message: ConsoleMessageItem) => {
messageQueueRef.current.push(message);
if (!isProcessingRef.current && !timeoutRef.current) {
// Batch updates using a timeout. 50ms is a reasonable delay to batch
// rapid-fire messages without noticeable lag while avoiding React update
// queue flooding.
timeoutRef.current = setTimeout(processQueue, 50);
}
},
[processQueue],
);
// Once the updated consoleMessages have been committed to the screen,
// we can safely process the next batch of queued messages if any exist.
// This completely eliminates overlapping concurrent updates to this state.
useEffect(() => {
isProcessingRef.current = false;
if (messageQueueRef.current.length > 0 && !timeoutRef.current) {
timeoutRef.current = setTimeout(processQueue, 50);
}
}, [consoleMessages, processQueue]);
useEffect(() => {
const handleConsoleLog = (payload: ConsoleLogPayload) => {
let content = payload.content;
const MAX_CONSOLE_MSG_LENGTH = 10000;
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
content =
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
}
handleNewMessage({
type: payload.type,
content,
count: 1,
});
};
const handleOutput = (payload: {
isStderr: boolean;
chunk: Uint8Array | string;
}) => {
let content =
typeof payload.chunk === 'string'
? payload.chunk
: new TextDecoder().decode(payload.chunk);
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
content =
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
}
// It would be nice if we could show stderr as 'warn' but unfortunately
// we log non warning info to stderr before the app starts so that would
// be misleading.
handleNewMessage({ type: 'log', content, count: 1 });
};
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.on(CoreEvent.Output, handleOutput);
return () => {
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.off(CoreEvent.Output, handleOutput);
};
}, [handleNewMessage]);
const clearConsoleMessages = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
messageQueueRef.current = [];
isProcessingRef.current = true;
startTransition(() => {
dispatch({ type: 'CLEAR' });
});
}, []);
// Cleanup on unmount
useEffect(
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
return { consoleMessages, clearConsoleMessages };
}
export interface UseErrorCountReturn {
errorCount: number;
clearErrorCount: () => void;
}
export function useErrorCount(): UseErrorCountReturn {
const [errorCount, dispatch] = useReducer(
(state: number, action: 'INCREMENT' | 'CLEAR') => {
switch (action) {
case 'INCREMENT':
return state + 1;
case 'CLEAR':
return 0;
default:
return state;
}
},
0,
);
useEffect(() => {
const handleConsoleLog = (payload: ConsoleLogPayload) => {
if (payload.type === 'error') {
startTransition(() => {
dispatch('INCREMENT');
});
}
};
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
return () => {
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
};
}, []);
const clearErrorCount = useCallback(() => {
startTransition(() => {
dispatch('CLEAR');
});
}, []);
return { errorCount, clearErrorCount };
}
@@ -1,121 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import { vi, type Mock } from 'vitest';
import { useFlickerDetector } from './useFlickerDetector.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { recordFlickerFrame, type Config } from '@google/gemini-cli-core';
import { type DOMElement, measureElement } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { appEvents, AppEvent } from '../../utils/events.js';
// Mock dependencies
vi.mock('../contexts/ConfigContext.js');
vi.mock('../contexts/UIStateContext.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
recordFlickerFrame: vi.fn(),
GEMINI_DIR: '.gemini',
};
});
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
return {
...original,
measureElement: vi.fn(),
};
});
vi.mock('../../utils/events.js', () => ({
appEvents: {
emit: vi.fn(),
},
AppEvent: {
Flicker: 'flicker',
},
}));
const mockUseConfig = useConfig as Mock;
const mockUseUIState = useUIState as Mock;
const mockRecordFlickerFrame = recordFlickerFrame as Mock;
const mockMeasureElement = measureElement as Mock;
const mockAppEventsEmit = appEvents.emit as Mock;
describe('useFlickerDetector', () => {
const mockConfig = {} as Config;
let mockRef: React.RefObject<DOMElement | null>;
beforeEach(() => {
mockUseConfig.mockReturnValue(mockConfig);
mockRef = { current: { yogaNode: {} } as DOMElement };
// Default UI state
mockUseUIState.mockReturnValue({ constrainHeight: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not record a flicker when height is less than terminal height', async () => {
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
await renderHook(() => useFlickerDetector(mockRef, 25));
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
expect(mockAppEventsEmit).not.toHaveBeenCalled();
});
it('should not record a flicker when height is equal to terminal height', async () => {
mockMeasureElement.mockReturnValue({ width: 80, height: 25 });
await renderHook(() => useFlickerDetector(mockRef, 25));
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
expect(mockAppEventsEmit).not.toHaveBeenCalled();
});
it('should record a flicker when height is greater than terminal height and height is constrained', async () => {
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
await renderHook(() => useFlickerDetector(mockRef, 25));
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
expect(mockRecordFlickerFrame).toHaveBeenCalledWith(mockConfig);
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
expect(mockAppEventsEmit).toHaveBeenCalledWith(AppEvent.Flicker);
});
it('should NOT record a flicker when height is greater than terminal height but height is NOT constrained', async () => {
// Override default UI state for this test
mockUseUIState.mockReturnValue({ constrainHeight: false });
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
await renderHook(() => useFlickerDetector(mockRef, 25));
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
expect(mockAppEventsEmit).not.toHaveBeenCalled();
});
it('should not check for flicker if the ref is not set', async () => {
mockRef.current = null;
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
await renderHook(() => useFlickerDetector(mockRef, 25));
expect(mockMeasureElement).not.toHaveBeenCalled();
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
expect(mockAppEventsEmit).not.toHaveBeenCalled();
});
it('should re-evaluate on re-render', async () => {
// Start with a valid height
mockMeasureElement.mockReturnValue({ width: 80, height: 20 });
const { rerender } = await renderHook(() =>
useFlickerDetector(mockRef, 25),
);
expect(mockRecordFlickerFrame).not.toHaveBeenCalled();
// Now, simulate a re-render where the height is too great
mockMeasureElement.mockReturnValue({ width: 80, height: 30 });
rerender();
expect(mockRecordFlickerFrame).toHaveBeenCalledTimes(1);
expect(mockAppEventsEmit).toHaveBeenCalledTimes(1);
});
});
@@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type DOMElement, measureElement } from 'ink';
import { useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { recordFlickerFrame } from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { useUIState } from '../contexts/UIStateContext.js';
/**
* A hook that detects when the UI flickers (renders taller than the terminal).
* This is a sign of a rendering bug that should be fixed.
*
* @param rootUiRef A ref to the root UI element.
* @param terminalHeight The height of the terminal.
*/
export function useFlickerDetector(
rootUiRef: React.RefObject<DOMElement | null>,
terminalHeight: number,
) {
const config = useConfig();
const { constrainHeight } = useUIState();
useEffect(() => {
if (rootUiRef.current) {
const measurement = measureElement(rootUiRef.current);
if (measurement.height > terminalHeight) {
// If we are not constraining the height, we are intentionally
// overflowing the screen.
if (!constrainHeight) {
return;
}
recordFlickerFrame(config);
appEvents.emit(AppEvent.Flicker);
}
}
});
}
@@ -1,129 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type { HistoryItemWithoutId } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
interface UseSessionResumeParams {
config: Config;
historyManager: UseHistoryManagerReturn;
refreshStatic: () => void;
isGeminiClientInitialized: boolean;
setQuittingMessages: (messages: null) => void;
resumedSessionData?: ResumedSessionData;
isAuthenticating: boolean;
}
/**
* Hook to handle session resumption logic.
* Provides a callback to load history for resume and automatically
* handles command-line resume on mount.
*/
export function useSessionResume({
config,
historyManager,
refreshStatic,
isGeminiClientInitialized,
setQuittingMessages,
resumedSessionData,
isAuthenticating,
}: UseSessionResumeParams) {
const [isResuming, setIsResuming] = useState(false);
// Use refs to avoid dependency chain that causes infinite loop
const historyManagerRef = useRef(historyManager);
const refreshStaticRef = useRef(refreshStatic);
useEffect(() => {
historyManagerRef.current = historyManager;
refreshStaticRef.current = refreshStatic;
});
const loadHistoryForResume = useCallback(
async (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
resumedData: ResumedSessionData,
) => {
// Wait for the client.
if (!isGeminiClientInitialized) {
return;
}
setIsResuming(true);
try {
// Now that we have the client, load the history into the UI and the client.
setQuittingMessages(null);
historyManagerRef.current.clearItems();
uiHistory.forEach((item, index) => {
historyManagerRef.current.addItem(item, index, true);
});
refreshStaticRef.current(); // Force Static component to re-render with the updated history.
// Restore directories from the resumed session
if (
resumedData.conversation.directories &&
resumedData.conversation.directories.length > 0
) {
const workspaceContext = config.getWorkspaceContext();
// Add back any directories that were saved in the session
// but filter out ones that no longer exist
workspaceContext.addDirectories(resumedData.conversation.directories);
}
// Give the history to the Gemini client.
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
} catch (error) {
coreEvents.emitFeedback(
'error',
'Failed to resume session. Please try again.',
error,
);
} finally {
setIsResuming(false);
}
},
[config, isGeminiClientInitialized, setQuittingMessages],
);
// Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive).
// Only if we're not authenticating and the client is initialized, though.
const hasLoadedResumedSession = useRef(false);
useEffect(() => {
if (
resumedSessionData &&
!isAuthenticating &&
isGeminiClientInitialized &&
!hasLoadedResumedSession.current
) {
hasLoadedResumedSession.current = true;
const historyData = convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
);
void loadHistoryForResume(
historyData.uiHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
}, [
resumedSessionData,
isAuthenticating,
isGeminiClientInitialized,
loadHistoryForResume,
]);
return { loadHistoryForResume, isResuming };
}
@@ -1,88 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { Notifications } from '../components/Notifications.js';
import { MainContent } from '../components/MainContent.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { rootUiRef, terminalHeight } = uiState;
useFlickerDetector(rootUiRef, terminalHeight);
// If in alternate buffer mode, need to leave room to draw the scrollbar on
// the right side of the terminal.
return (
<Box
flexDirection="column"
width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
{uiState.isBackgroundTaskVisible &&
uiState.backgroundTasks.size > 0 &&
uiState.activeBackgroundTaskPid &&
uiState.backgroundTaskHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
<BackgroundTaskDisplay
shells={uiState.backgroundTasks}
activePid={uiState.activeBackgroundTaskPid}
width={uiState.terminalWidth}
height={uiState.backgroundTaskHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundTaskListOpen}
/>
</Box>
)}
<Box
flexDirection="column"
ref={uiState.mainControlsRef}
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
height={
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
}
>
<Notifications />
<CopyModeWarning />
{uiState.customDialog ? (
uiState.customDialog
) : uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
) : (
<Composer isFocused={true} />
)}
<ExitWarning />
</Box>
</Box>
);
};
@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { Notifications } from '../components/Notifications.js';
import { MainContent } from '../components/MainContent.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { Footer } from '../components/Footer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
export const ScreenReaderAppLayout: React.FC = () => {
const uiState = useUIState();
const { rootUiRef, terminalHeight } = uiState;
useFlickerDetector(rootUiRef, terminalHeight);
return (
<Box
flexDirection="column"
width="90%"
height="100%"
ref={uiState.rootUiRef}
>
<Notifications />
<Footer />
<Box flexGrow={1} overflow="hidden">
<MainContent />
</Box>
{uiState.dialogsVisible ? (
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
) : (
<Composer />
)}
<ExitWarning />
</Box>
);
};
-527
View File
@@ -1,527 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CompressionStatus,
type GeminiCLIExtension,
type MCPServerConfig,
type ThoughtSummary,
type SerializableConfirmationDetails,
type ToolResultDisplay,
type RetrieveUserQuotaResponse,
type SkillDefinition,
type AgentDefinition,
type ApprovalMode,
type Kind,
type AnsiOutput,
CoreToolCallStatus,
checkExhaustive,
} from '@google/gemini-cli-core';
import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react';
export { CoreToolCallStatus };
export type {
ThoughtSummary,
SkillDefinition,
SerializableConfirmationDetails,
ToolResultDisplay,
};
export enum AuthState {
// Attempting to authenticate or re-authenticate
Unauthenticated = 'unauthenticated',
// Auth dialog is open for user to select auth method
Updating = 'updating',
// Waiting for user to input API key
AwaitingApiKeyInput = 'awaiting_api_key_input',
// Successfully authenticated
Authenticated = 'authenticated',
// Waiting for the user to restart after a Google login
AwaitingGoogleLoginRestart = 'awaiting_google_login_restart',
}
// Only defining the state enum needed by the UI
export enum StreamingState {
Idle = 'idle',
Responding = 'responding',
WaitingForConfirmation = 'waiting_for_confirmation',
}
// Copied from server/src/core/turn.ts for CLI usage
export enum GeminiEventType {
Content = 'content',
ToolCallRequest = 'tool_call_request',
// Add other event types if the UI hook needs to handle them
}
export enum ToolCallStatus {
Pending = 'Pending',
Canceled = 'Canceled',
Confirming = 'Confirming',
Executing = 'Executing',
Success = 'Success',
Error = 'Error',
}
/**
* Maps core tool call status to a simplified UI status.
*/
export function mapCoreStatusToDisplayStatus(
coreStatus: CoreToolCallStatus,
): ToolCallStatus {
switch (coreStatus) {
case CoreToolCallStatus.Validating:
return ToolCallStatus.Pending;
case CoreToolCallStatus.AwaitingApproval:
return ToolCallStatus.Confirming;
case CoreToolCallStatus.Executing:
return ToolCallStatus.Executing;
case CoreToolCallStatus.Success:
return ToolCallStatus.Success;
case CoreToolCallStatus.Cancelled:
return ToolCallStatus.Canceled;
case CoreToolCallStatus.Error:
return ToolCallStatus.Error;
case CoreToolCallStatus.Scheduled:
return ToolCallStatus.Pending;
default:
return checkExhaustive(coreStatus);
}
}
/**
* --- TYPE GUARDS ---
*/
export const isTodoList = (res: unknown): res is { todos: unknown[] } =>
typeof res === 'object' && res !== null && 'todos' in res;
export const isAnsiOutput = (res: unknown): res is AnsiOutput =>
Array.isArray(res) && (res.length === 0 || Array.isArray(res[0]));
export interface ToolCallEvent {
type: 'tool_call';
status: CoreToolCallStatus;
callId: string;
name: string;
args: Record<string, never>;
resultDisplay: ToolResultDisplay | undefined;
confirmationDetails: SerializableConfirmationDetails | undefined;
correlationId?: string;
}
export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
args?: Record<string, unknown>;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
isClientInitiated?: boolean;
kind?: Kind;
confirmationDetails: SerializableConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
correlationId?: string;
approvalMode?: ApprovalMode;
progressMessage?: string;
originalRequestName?: string;
progress?: number;
progressTotal?: number;
}
export interface CompressionProps {
isPending: boolean;
originalTokenCount: number | null;
newTokenCount: number | null;
compressionStatus: CompressionStatus | null;
}
/**
* For use when you want no icon.
*/
export const emptyIcon = ' ';
export interface HistoryItemBase {
text?: string; // Text content for user/gemini/info/error messages
}
export type HistoryItemUser = HistoryItemBase & {
type: 'user';
text: string;
};
export type HistoryItemGemini = HistoryItemBase & {
type: 'gemini';
text: string;
};
export type HistoryItemGeminiContent = HistoryItemBase & {
type: 'gemini_content';
text: string;
};
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
};
export type HistoryItemError = HistoryItemBase & {
type: 'error';
text: string;
};
export type HistoryItemWarning = HistoryItemBase & {
type: 'warning';
text: string;
};
export type HistoryItemAbout = HistoryItemBase & {
type: 'about';
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
userEmail?: string;
tier?: string;
};
export type HistoryItemHelp = HistoryItemBase & {
type: 'help';
timestamp: Date;
};
export interface HistoryItemQuotaBase extends HistoryItemBase {
selectedAuthType?: string;
userEmail?: string;
tier?: string;
currentModel?: string;
pooledRemaining?: number;
pooledLimit?: number;
pooledResetTime?: string;
}
export interface QuotaStats {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
}
export type HistoryItemStats = HistoryItemQuotaBase & {
type: 'stats';
duration: string;
quotas?: RetrieveUserQuotaResponse;
creditBalance?: number;
};
export type HistoryItemModelStats = HistoryItemQuotaBase & {
type: 'model_stats';
};
export type HistoryItemToolStats = HistoryItemBase & {
type: 'tool_stats';
};
export type HistoryItemModel = HistoryItemBase & {
type: 'model';
model: string;
};
export type HistoryItemQuit = HistoryItemBase & {
type: 'quit';
duration: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group';
tools: IndividualToolCallDisplay[];
borderTop?: boolean;
borderBottom?: boolean;
borderColor?: string;
borderDimColor?: boolean;
};
export type HistoryItemUserShell = HistoryItemBase & {
type: 'user_shell';
text: string;
};
export type HistoryItemCompression = HistoryItemBase & {
type: 'compression';
compression: CompressionProps;
};
export type HistoryItemExtensionsList = HistoryItemBase & {
type: 'extensions_list';
extensions: GeminiCLIExtension[];
};
export interface ChatDetail {
name: string;
mtime: string;
}
export type HistoryItemThinking = HistoryItemBase & {
type: 'thinking';
thought: ThoughtSummary;
};
export type HistoryItemHint = HistoryItemBase & {
type: 'hint';
text: string;
};
export type HistoryItemChatList = HistoryItemBase & {
type: 'chat_list';
chats: ChatDetail[];
};
export interface ToolDefinition {
name: string;
displayName: string;
description?: string;
}
export type HistoryItemToolsList = HistoryItemBase & {
type: 'tools_list';
tools: ToolDefinition[];
showDescriptions: boolean;
};
export type HistoryItemSkillsList = HistoryItemBase & {
type: 'skills_list';
skills: SkillDefinition[];
showDescriptions: boolean;
};
export type AgentDefinitionJson = Pick<
AgentDefinition,
'name' | 'displayName' | 'description' | 'kind'
>;
export type HistoryItemAgentsList = HistoryItemBase & {
type: 'agents_list';
agents: AgentDefinitionJson[];
};
// JSON-friendly types for using as a simple data model showing info about an
// MCP Server.
export interface JsonMcpTool {
serverName: string;
name: string;
description?: string;
schema?: {
parametersJsonSchema?: unknown;
parameters?: unknown;
};
}
export interface JsonMcpPrompt {
serverName: string;
name: string;
description?: string;
}
export interface JsonMcpResource {
serverName: string;
name?: string;
uri?: string;
mimeType?: string;
description?: string;
}
export type HistoryItemMcpStatus = HistoryItemBase & {
type: 'mcp_status';
servers: Record<string, MCPServerConfig>;
tools: JsonMcpTool[];
prompts: JsonMcpPrompt[];
resources: JsonMcpResource[];
authStatus: Record<
string,
'authenticated' | 'expired' | 'unauthenticated' | 'not-configured'
>;
enablementState: Record<
string,
{
enabled: boolean;
isSessionDisabled: boolean;
isPersistentDisabled: boolean;
}
>;
errors: Record<string, string>;
blockedServers: Array<{ name: string; extensionName: string }>;
discoveryInProgress: boolean;
connectingServers: string[];
showDescriptions: boolean;
showSchema: boolean;
};
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemUserShell
| HistoryItemGemini
| HistoryItemGeminiContent
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
| HistoryItemAbout
| HistoryItemHelp
| HistoryItemToolGroup
| HistoryItemStats
| HistoryItemModelStats
| HistoryItemToolStats
| HistoryItemModel
| HistoryItemQuit
| HistoryItemCompression
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemAgentsList
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
| HistoryItemHint;
export type HistoryItem = HistoryItemWithoutId & { id: number };
// Message types used by internal command feedback (subset of HistoryItem types)
export enum MessageType {
INFO = 'info',
ERROR = 'error',
WARNING = 'warning',
USER = 'user',
ABOUT = 'about',
HELP = 'help',
STATS = 'stats',
MODEL_STATS = 'model_stats',
TOOL_STATS = 'tool_stats',
QUIT = 'quit',
GEMINI = 'gemini',
COMPRESSION = 'compression',
EXTENSIONS_LIST = 'extensions_list',
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
HINT = 'hint',
}
// Simplified message structure for internal feedback
export type Message =
| {
type: MessageType.INFO | MessageType.ERROR | MessageType.USER;
content: string; // Renamed from text for clarity in this context
timestamp: Date;
}
| {
type: MessageType.ABOUT;
timestamp: Date;
cliVersion: string;
osVersion: string;
sandboxEnv: string;
modelVersion: string;
selectedAuthType: string;
gcpProject: string;
ideClient: string;
userEmail?: string;
content?: string; // Optional content, not really used for ABOUT
}
| {
type: MessageType.HELP;
timestamp: Date;
content?: string; // Optional content, not really used for HELP
}
| {
type: MessageType.STATS;
timestamp: Date;
duration: string;
content?: string;
}
| {
type: MessageType.MODEL_STATS;
timestamp: Date;
content?: string;
}
| {
type: MessageType.TOOL_STATS;
timestamp: Date;
content?: string;
}
| {
type: MessageType.QUIT;
timestamp: Date;
duration: string;
content?: string;
}
| {
type: MessageType.COMPRESSION;
compression: CompressionProps;
timestamp: Date;
};
export interface ConsoleMessageItem {
type: 'log' | 'warn' | 'error' | 'debug' | 'info';
content: string;
count: number;
}
/**
* Result type for a slash command that should immediately result in a prompt
* being submitted to the Gemini model.
*/
export interface SubmitPromptResult {
type: 'submit_prompt';
content: PartListUnion;
}
/**
* Defines the result of the slash command processor for its consumer (useGeminiStream).
*/
export type SlashCommandProcessorResult =
| {
type: 'schedule_tool';
toolName: string;
toolArgs: Record<string, unknown>;
postSubmitPrompt?: PartListUnion;
}
| {
type: 'handled'; // Indicates the command was processed and no further action is needed.
}
| SubmitPromptResult;
export interface ConfirmationRequest {
prompt: ReactNode;
onConfirm: (confirm: boolean) => void;
}
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}
export interface PermissionConfirmationRequest {
files: string[];
onComplete: (result: { allowed: boolean }) => void;
}
export interface ActiveHook {
name: string;
eventName: string;
source?: string;
index?: number;
total?: number;
}
-132
View File
@@ -1,132 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Content } from '@google/genai';
import { debugLogger } from './debugLogger.js';
interface ErrorReportData {
error: { message: string; stack?: string } | { message: string };
context?: unknown;
additionalInfo?: Record<string, unknown>;
}
/**
* Generates an error report, writes it to a temporary file, and logs information to user
* @param error The error object.
* @param context The relevant context (e.g., chat history, request contents).
* @param type A string to identify the type of error (e.g., 'startChat', 'generateJson-api').
* @param baseMessage The initial message to log to console.error before the report path.
*/
export async function reportError(
error: Error | unknown,
baseMessage: string,
context?: Content[] | Record<string, unknown> | unknown[],
type = 'general',
reportingDir = os.tmpdir(), // for testing
): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const reportFileName = `gemini-client-error-${type}-${timestamp}.json`;
const reportPath = path.join(reportingDir, reportFileName);
let errorToReport: { message: string; stack?: string };
if (error instanceof Error) {
errorToReport = { message: error.message, stack: error.stack };
} else if (
typeof error === 'object' &&
error !== null &&
'message' in error
) {
errorToReport = {
message: String((error as { message: unknown }).message),
};
} else {
errorToReport = { message: String(error) };
}
const reportContent: ErrorReportData = { error: errorToReport };
if (context) {
reportContent.context = context;
}
let stringifiedReportContent: string;
try {
stringifiedReportContent = JSON.stringify(reportContent, null, 2);
} catch (stringifyError) {
// This can happen if context contains something like BigInt
debugLogger.error(
`${baseMessage} Could not stringify report content (likely due to context):`,
stringifyError,
);
debugLogger.error(
'Original error that triggered report generation:',
error,
);
if (context) {
debugLogger.error(
'Original context could not be stringified or included in report.',
);
}
// Fallback: try to report only the error if context was the issue
try {
const minimalReportContent = { error: errorToReport };
stringifiedReportContent = JSON.stringify(minimalReportContent, null, 2);
// Still try to write the minimal report
await fs.writeFile(reportPath, stringifiedReportContent);
debugLogger.error(
`${baseMessage} Partial report (excluding context) available at: ${reportPath}`,
error,
);
} catch (minimalWriteError) {
debugLogger.error(
`${baseMessage} Failed to write even a minimal error report:`,
minimalWriteError,
);
}
return;
}
try {
await fs.writeFile(reportPath, stringifiedReportContent);
debugLogger.error(
`${baseMessage} Full report available at: ${reportPath}`,
error,
);
} catch (writeError) {
debugLogger.error(
`${baseMessage} Additionally, failed to write detailed error report:`,
writeError,
);
// Log the original error as a fallback if report writing fails
debugLogger.error(
'Original error that triggered report generation:',
error,
);
if (context) {
// Context was stringifiable, but writing the file failed.
// We already have stringifiedReportContent, but it might be too large for console.
// So, we try to log the original context object, and if that fails, its stringified version (truncated).
try {
debugLogger.error('Original context:', context);
} catch {
try {
debugLogger.error(
'Original context (stringified, truncated):',
JSON.stringify(context).substring(0, 1000),
);
} catch {
debugLogger.error(
'Original context could not be logged or stringified.',
);
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
I will read the `policies_list.txt` file to provide you with the list of policies.
I will list the files in the root directory to verify the existence and size of `policies_list.txt`.
I will read `policies_list.txt` to retrieve the list of policies.
I will run `cat policies_list.txt` to see the actual content of the file.
I will search for the word "policy" in the codebase to find where policies are defined or listed.
I will list the contents of the `.gemini/policies` directory to find any policy-related files.
I will read the contents of `/Users/mattkorwel/.gemini/policies/policy.toml` to understand the current policies.
I will read the contents of `/Users/mattkorwel/.gemini/policies/auto-saved.toml` to see if there are any additional policies.
I will explore the `packages/core/src/policy/` directory to see if there are any other files relevant to policies.
I will list the contents of `packages/core/src/policy/policies` to see if there are any built-in or default policies.
Error: read EIO
at TTY.onStreamRead (node:internal/stream_base_commons:218:20)
at TTY.callbackTrampoline (node:internal/async_hooks:130:17)