mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-09 04:41:19 -07:00
refactor(cli,core): foundational layout, identity management, and type safety (#23286)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: '',
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: true, expectedArrows: true },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
])(
|
||||
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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 │
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 █ │
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
83
packages/cli/src/ui/utils/historyUtils.ts
Normal file
83
packages/cli/src/ui/utils/historyUtils.ts
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user