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

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