refactor(cli,core): foundational layout, identity management, and type safety (#23286)

This commit is contained in:
Jarrod Whelan
2026-03-23 18:49:51 -07:00
committed by GitHub
parent 57a66f5f0d
commit 89ca78837e
31 changed files with 477 additions and 182 deletions

View File

@@ -98,6 +98,12 @@ export async function runAcpClient(
}
export class GeminiAgent {
private static callIdCounter = 0;
static generateCallId(name: string): string {
return `${name}-${Date.now()}-${++GeminiAgent.callIdCounter}`;
}
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
@@ -897,7 +903,7 @@ export class Session {
promptId: string,
fc: FunctionCall,
): Promise<Part[]> {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
const args = fc.args ?? {};
const startTime = Date.now();
@@ -1391,7 +1397,7 @@ export class Session {
include: pathSpecsToRead,
};
const callId = `${readManyFilesTool.name}-${Date.now()}`;
const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
try {
const invocation = readManyFilesTool.build(toolArgs);

View File

@@ -30,8 +30,6 @@ import {
import { ConfigContext } from './contexts/ConfigContext.js';
import {
type HistoryItem,
type HistoryItemWithoutId,
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
type PermissionConfirmationRequest,
@@ -81,7 +79,6 @@ import {
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
ProjectIdRequiredError,
CoreToolCallStatus,
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
@@ -170,29 +167,11 @@ import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
if (item && item.type === 'tool_group') {
return item.tools.some(
(tool) => CoreToolCallStatus.Executing === tool.status,
);
}
return false;
});
}
function isToolAwaitingConfirmation(
pendingHistoryItems: HistoryItemWithoutId[],
) {
return pendingHistoryItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.some((item) =>
item.tools.some(
(tool) => CoreToolCallStatus.AwaitingApproval === tool.status,
),
);
}
import {
isToolExecuting,
isToolAwaitingConfirmation,
getAllToolCalls,
} from './utils/historyUtils.js';
interface AppContainerProps {
config: Config;
@@ -1151,6 +1130,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
consumePendingHints,
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
@@ -1222,10 +1211,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
];
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
return; // Don't clear - user may be composing a follow-up message
}
@@ -1259,8 +1244,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
inputHistory,
getQueuedMessagesText,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
pendingHistoryItems,
],
);
@@ -1296,10 +1280,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
streamingState === StreamingState.Responding ||
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]);
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
@@ -1361,8 +1342,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
pendingHistoryItems,
config,
constrainHeight,
setConstrainHeight,
@@ -1684,6 +1664,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1866,6 +1851,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
handleSuspend,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
@@ -2026,16 +2012,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
@@ -2125,12 +2101,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.flatMap((item) => item.tools),
() => getAllToolCalls(pendingHistoryItems),
[pendingHistoryItems],
);
@@ -2295,11 +2266,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
newAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]),
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
}),
[

View File

@@ -287,7 +287,7 @@ describe('AskUserDialog', () => {
});
describe.each([
{ useAlternateBuffer: true, expectedArrows: false },
{ useAlternateBuffer: true, expectedArrows: true },
{ useAlternateBuffer: false, expectedArrows: true },
])(
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',

View File

@@ -865,8 +865,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
: undefined;
const maxItemsToShow =
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
? Math.min(
selectionItems.length,
Math.max(
1,
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
),
)
: selectionItems.length;
return (

View File

@@ -97,7 +97,7 @@ describe('getToolGroupBorderAppearance', () => {
});
it('inspects only the last pending tool_group item if current has no tools', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const item = { type: 'tool_group' as const, tools: [], id: -1 };
const pendingItems = [
{
type: 'tool_group' as const,
@@ -158,7 +158,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -187,7 +187,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -276,7 +276,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -292,7 +292,7 @@ describe('getToolGroupBorderAppearance', () => {
});
it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const item = { type: 'tool_group' as const, tools: [], id: -1 };
// active shell turn
const result = getToolGroupBorderAppearance(
@@ -667,7 +667,7 @@ describe('MainContent', () => {
pendingHistoryItems: [
{
type: 'tool_group',
id: 1,
id: -1,
tools: [
{
callId: 'call_1',

View File

@@ -127,7 +127,7 @@ export const MainContent = () => {
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
<Box flexDirection="column" key="pending-items-group">
{pendingHistoryItems.map((item, i) => {
const prevType =
i === 0
@@ -140,12 +140,12 @@ export const MainContent = () => {
return (
<HistoryItemDisplay
key={i}
key={`pending-${i}`}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
item={{ ...item, id: -(i + 1) }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
@@ -154,7 +154,10 @@ export const MainContent = () => {
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
<ToolConfirmationQueue
key="confirmation-queue"
confirmingTool={confirmingTool}
/>
)}
</Box>
),

View File

@@ -77,37 +77,14 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel
"

View File

@@ -6,12 +6,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -28,12 +27,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14
│ Line 14
│ Line 15 █ │
│ Line 16 █ │
│ Line 17 █ │
@@ -49,8 +47,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 9 lines hidden (Ctrl+O to show) ...
│ Line 10 │
│ ... first 10 lines hidden (Ctrl+O to show) ... │
│ Line 11 │
│ Line 12 │
│ Line 13 │

View File

@@ -184,28 +184,28 @@ describe('<ShellToolMessage />', () => {
[
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
10,
8,
7,
false,
true,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES - 3,
ACTIVE_SHELL_MAX_LINES - 4,
false,
true,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
98,
97,
true,
false,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES - 3,
ACTIVE_SHELL_MAX_LINES - 4,
false,
false,
],
@@ -323,8 +323,8 @@ describe('<ShellToolMessage />', () => {
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to 12 (15 - 3) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(12);
// Should still be constrained to 11 (15 - 4) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(11);
});
expect(lastFrame()).toMatchSnapshot();
unmount();

View File

@@ -453,7 +453,6 @@ describe('ToolConfirmationMessage', () => {
cancel: vi.fn(),
isDiffingEnabled: false,
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -480,7 +479,6 @@ describe('ToolConfirmationMessage', () => {
cancel: vi.fn(),
isDiffingEnabled: false,
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -723,7 +721,6 @@ describe('ToolConfirmationMessage', () => {
cancel: vi.fn(),
isDiffingEnabled: false,
});
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',

View File

@@ -4,7 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
@@ -14,7 +13,7 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99
│ Line 99
│ Line 100 █ │
"
`;
@@ -130,7 +129,6 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
@@ -145,7 +143,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
@@ -155,7 +152,7 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99
│ Line 99
│ Line 100 █ │
"
`;
@@ -164,7 +161,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
@@ -174,7 +170,7 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99
│ Line 99
│ Line 100 █ │
"
`;
@@ -183,10 +179,9 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 3 │
│ Line 4 │
│ Line 5
│ Line 6
│ Line 5
│ Line 6
│ Line 7 █ │
│ Line 8 █ │
│ Line 9 █ │

View File

@@ -37,8 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 248 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"... 249 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

View File

@@ -39,6 +39,56 @@ describe('useHistoryManager', () => {
expect(result.current.history[0].id).toBeGreaterThanOrEqual(timestamp);
});
it('should generate strictly increasing IDs even if baseTimestamp goes backwards', async () => {
const { result } = await renderHook(() => useHistory());
const timestamp = 1000000;
const itemData: Omit<HistoryItem, 'id'> = { type: 'info', text: 'First' };
let id1!: number;
let id2!: number;
act(() => {
id1 = result.current.addItem(itemData, timestamp);
// Try to add with a smaller timestamp
id2 = result.current.addItem(itemData, timestamp - 500);
});
expect(id1).toBe(timestamp);
expect(id2).toBe(id1 + 1);
expect(result.current.history[1].id).toBe(id2);
});
it('should ensure new IDs start after existing IDs when resuming a session', async () => {
const initialItems: HistoryItem[] = [
{ id: 5000, type: 'info', text: 'Existing' },
];
const { result } = await renderHook(() => useHistory({ initialItems }));
let newId!: number;
act(() => {
// Try to add with a timestamp smaller than the highest existing ID
newId = result.current.addItem({ type: 'info', text: 'New' }, 2000);
});
expect(newId).toBe(5001);
expect(result.current.history[1].id).toBe(5001);
});
it('should update lastIdRef when loading new history', async () => {
const { result } = await renderHook(() => useHistory());
act(() => {
result.current.loadHistory([{ id: 8000, type: 'info', text: 'Loaded' }]);
});
let newId!: number;
act(() => {
newId = result.current.addItem({ type: 'info', text: 'New' }, 1000);
});
expect(newId).toBe(8001);
});
it('should generate unique IDs for items added with the same base timestamp', async () => {
const { result } = await renderHook(() => useHistory());
const timestamp = Date.now();
@@ -215,8 +265,8 @@ describe('useHistoryManager', () => {
const after = Date.now();
expect(result.current.history).toHaveLength(1);
// ID should be >= before + 1 (since counter starts at 0 and increments to 1)
expect(result.current.history[0].id).toBeGreaterThanOrEqual(before + 1);
// ID should be >= before (since baseTimestamp defaults to Date.now())
expect(result.current.history[0].id).toBeGreaterThanOrEqual(before);
expect(result.current.history[0].id).toBeLessThanOrEqual(after + 1);
});

View File

@@ -42,16 +42,22 @@ export function useHistory({
initialItems?: HistoryItem[];
} = {}): UseHistoryManagerReturn {
const [history, setHistory] = useState<HistoryItem[]>(initialItems);
const messageIdCounterRef = useRef(0);
const lastIdRef = useRef(
initialItems.reduce((max, item) => Math.max(max, item.id), 0),
);
// Generates a unique message ID based on a timestamp and a counter.
// Generates a unique message ID based on a timestamp, ensuring it is always
// greater than any previously assigned ID.
const getNextMessageId = useCallback((baseTimestamp: number): number => {
messageIdCounterRef.current += 1;
return baseTimestamp + messageIdCounterRef.current;
const nextId = Math.max(baseTimestamp, lastIdRef.current + 1);
lastIdRef.current = nextId;
return nextId;
}, []);
const loadHistory = useCallback((newHistory: HistoryItem[]) => {
setHistory(newHistory);
const maxId = newHistory.reduce((max, item) => Math.max(max, item.id), 0);
lastIdRef.current = Math.max(lastIdRef.current, maxId);
}, []);
// Adds a new item to the history state with a unique ID.
@@ -153,7 +159,7 @@ export function useHistory({
// Clears the entire history state and resets the ID counter.
const clearItems = useCallback(() => {
setHistory([]);
messageIdCounterRef.current = 0;
lastIdRef.current = 0;
}, []);
return useMemo(

View File

@@ -6,17 +6,30 @@
import { renderHook } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { useInlineEditBuffer } from './useInlineEditBuffer.js';
describe('useEditBuffer', () => {
let mockOnCommit: Mock;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockOnCommit = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
});
it('should initialize with empty state', async () => {
const { result } = await renderHook(() =>
useInlineEditBuffer({ onCommit: mockOnCommit }),

View File

@@ -16,13 +16,20 @@ import {
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 type { ThoughtSummary, SkillDefinition };
export { CoreToolCallStatus };
export type {
ThoughtSummary,
SkillDefinition,
SerializableConfirmationDetails,
ToolResultDisplay,
};
export enum AuthState {
// Attempting to authenticate or re-authenticate
@@ -86,6 +93,16 @@ export function mapCoreStatusToDisplayStatus(
}
}
/**
* --- 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;
@@ -352,10 +369,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showSchema: boolean;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemUserShell

View File

@@ -79,4 +79,28 @@ describe('colorizeCode', () => {
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('returns an array of lines when returnLines is true', () => {
const code = 'line 1\nline 2\nline 3';
const settings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
const result = colorizeCode({
code,
language: 'javascript',
maxWidth: 80,
settings,
hideLineNumbers: true,
returnLines: true,
});
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
});
});

View File

@@ -21,8 +21,8 @@ import {
MaxSizedBox,
MINIMUM_MAX_HEIGHT,
} from '../components/shared/MaxSizedBox.js';
import type { LoadedSettings } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
@@ -117,7 +117,11 @@ export function colorizeLine(
line: string,
language: string | null,
theme?: Theme,
disableColor = false,
): React.ReactNode {
if (disableColor) {
return <Text>{line}</Text>;
}
const activeTheme = theme || themeManager.getActiveTheme();
return highlightAndRenderLine(line, language, activeTheme);
}
@@ -130,6 +134,8 @@ export interface ColorizeCodeOptions {
theme?: Theme | null;
settings: LoadedSettings;
hideLineNumbers?: boolean;
disableColor?: boolean;
returnLines?: boolean;
}
/**
@@ -138,6 +144,12 @@ export interface ColorizeCodeOptions {
* @param options The options for colorizing the code.
* @returns A React.ReactNode containing Ink <Text> elements for the highlighted code.
*/
export function colorizeCode(
options: ColorizeCodeOptions & { returnLines: true },
): React.ReactNode[];
export function colorizeCode(
options: ColorizeCodeOptions & { returnLines?: false },
): React.ReactNode;
export function colorizeCode({
code,
language = null,
@@ -146,13 +158,16 @@ export function colorizeCode({
theme = null,
settings,
hideLineNumbers = false,
}: ColorizeCodeOptions): React.ReactNode {
disableColor = false,
returnLines = false,
}: ColorizeCodeOptions): React.ReactNode | React.ReactNode[] {
const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = hideLineNumbers
? false
: settings.merged.ui.showLineNumbers;
const useMaxSizedBox = !settings.merged.ui.useAlternateBuffer && !returnLines;
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
@@ -162,7 +177,7 @@ export function colorizeCode({
let hiddenLinesCount = 0;
// Optimization to avoid highlighting lines that cannot possibly be displayed.
if (availableHeight !== undefined) {
if (availableHeight !== undefined && useMaxSizedBox) {
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
if (lines.length > availableHeight) {
const sliceIndex = lines.length - availableHeight;
@@ -172,11 +187,9 @@ export function colorizeCode({
}
const renderedLines = lines.map((line, index) => {
const contentToRender = highlightAndRenderLine(
line,
language,
activeTheme,
);
const contentToRender = disableColor
? line
: highlightAndRenderLine(line, language, activeTheme);
return (
<Box key={index} minHeight={1}>
@@ -188,19 +201,26 @@ export function colorizeCode({
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.colors.Gray}>
<Text color={disableColor ? undefined : activeTheme.colors.Gray}>
{`${index + 1 + hiddenLinesCount}`}
</Text>
</Box>
)}
<Text color={activeTheme.defaultColor} wrap="wrap">
<Text
color={disableColor ? undefined : activeTheme.defaultColor}
wrap="wrap"
>
{contentToRender}
</Text>
</Box>
);
});
if (availableHeight !== undefined) {
if (returnLines) {
return renderedLines;
}
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}
@@ -237,14 +257,22 @@ export function colorizeCode({
alignItems="flex-start"
justifyContent="flex-end"
>
<Text color={activeTheme.defaultColor}>{`${index + 1}`}</Text>
<Text color={disableColor ? undefined : activeTheme.defaultColor}>
{`${index + 1}`}
</Text>
</Box>
)}
<Text color={activeTheme.colors.Gray}>{stripAnsi(line)}</Text>
<Text color={disableColor ? undefined : activeTheme.colors.Gray}>
{stripAnsi(line)}
</Text>
</Box>
));
if (availableHeight !== undefined) {
if (returnLines) {
return fallbackLines;
}
if (useMaxSizedBox) {
return (
<MaxSizedBox
maxHeight={availableHeight}

View File

@@ -6,10 +6,10 @@
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import {
type HistoryItemToolGroup,
type HistoryItemWithoutId,
type IndividualToolCallDisplay,
} from '../types.js';
import { getAllToolCalls } from './historyUtils.js';
export interface ConfirmingToolState {
tool: IndividualToolCallDisplay;
@@ -23,9 +23,7 @@ export interface ConfirmingToolState {
export function getConfirmingToolState(
pendingHistoryItems: HistoryItemWithoutId[],
): ConfirmingToolState | null {
const allPendingTools = pendingHistoryItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.flatMap((group) => group.tools);
const allPendingTools = getAllToolCalls(pendingHistoryItems);
const confirmingTools = allPendingTools.filter(
(tool) => tool.status === CoreToolCallStatus.AwaitingApproval,

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CoreToolCallStatus } from '../types.js';
import type {
HistoryItem,
HistoryItemWithoutId,
HistoryItemToolGroup,
IndividualToolCallDisplay,
} from '../types.js';
export function getLastTurnToolCallIds(
history: HistoryItem[],
pendingHistoryItems: HistoryItemWithoutId[],
): string[] {
const targetToolCallIds: string[] = [];
// Find the boundary of the last user prompt
let lastUserPromptIndex = -1;
for (let i = history.length - 1; i >= 0; i--) {
const type = history[i].type;
if (type === 'user' || type === 'user_shell') {
lastUserPromptIndex = i;
break;
}
}
// Collect IDs from history after last user prompt
history.forEach((item, index) => {
if (index > lastUserPromptIndex && item.type === 'tool_group') {
item.tools.forEach((t) => {
if (t.callId) targetToolCallIds.push(t.callId);
});
}
});
// Collect IDs from pending items
pendingHistoryItems.forEach((item) => {
if (item.type === 'tool_group') {
item.tools.forEach((t) => {
if (t.callId) targetToolCallIds.push(t.callId);
});
}
});
return targetToolCallIds;
}
export function isToolExecuting(
pendingHistoryItems: HistoryItemWithoutId[],
): boolean {
return pendingHistoryItems.some((item) => {
if (item && item.type === 'tool_group') {
return item.tools.some(
(tool) => CoreToolCallStatus.Executing === tool.status,
);
}
return false;
});
}
export function isToolAwaitingConfirmation(
pendingHistoryItems: HistoryItemWithoutId[],
): boolean {
return pendingHistoryItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.some((item) =>
item.tools.some(
(tool) => CoreToolCallStatus.AwaitingApproval === tool.status,
),
);
}
export function getAllToolCalls(
historyItems: HistoryItemWithoutId[],
): IndividualToolCallDisplay[] {
return historyItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.flatMap((group) => group.tools);
}

View File

@@ -9,6 +9,10 @@ import {
calculateToolContentMaxLines,
calculateShellMaxLines,
SHELL_CONTENT_OVERHEAD,
TOOL_RESULT_STATIC_HEIGHT,
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
TOOL_RESULT_ASB_RESERVED_LINE_COUNT,
TOOL_RESULT_MIN_LINES_SHOWN,
} from './toolLayoutUtils.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import {
@@ -48,7 +52,7 @@ describe('toolLayoutUtils', () => {
availableTerminalHeight: 2,
isAlternateBuffer: false,
},
expected: 3,
expected: TOOL_RESULT_MIN_LINES_SHOWN + 1,
},
{
desc: 'returns available space directly in constrained terminal (ASB mode)',
@@ -56,7 +60,7 @@ describe('toolLayoutUtils', () => {
availableTerminalHeight: 4,
isAlternateBuffer: true,
},
expected: 3,
expected: TOOL_RESULT_MIN_LINES_SHOWN + 1,
},
{
desc: 'returns remaining space if sufficient space exists (Standard mode)',
@@ -64,7 +68,10 @@ describe('toolLayoutUtils', () => {
availableTerminalHeight: 20,
isAlternateBuffer: false,
},
expected: 17,
expected:
20 -
TOOL_RESULT_STATIC_HEIGHT -
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
},
{
desc: 'returns remaining space if sufficient space exists (ASB mode)',
@@ -72,7 +79,8 @@ describe('toolLayoutUtils', () => {
availableTerminalHeight: 20,
isAlternateBuffer: true,
},
expected: 13,
expected:
20 - TOOL_RESULT_STATIC_HEIGHT - TOOL_RESULT_ASB_RESERVED_LINE_COUNT,
},
];
@@ -148,7 +156,7 @@ describe('toolLayoutUtils', () => {
constrainHeight: true,
isExpandable: false,
},
expected: 4,
expected: 6 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
},
{
desc: 'handles negative availableTerminalHeight gracefully',
@@ -172,7 +180,7 @@ describe('toolLayoutUtils', () => {
constrainHeight: false,
isExpandable: false,
},
expected: 28,
expected: 30 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
},
{
desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows',

View File

@@ -17,7 +17,7 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core';
*/
export const TOOL_RESULT_STATIC_HEIGHT = 1;
export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2;
export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 3;
export const TOOL_RESULT_MIN_LINES_SHOWN = 2;
/**