Display open IDE file in context section above input box rather than in the footer (#4470)

This commit is contained in:
Shreya Keshive 2025-07-18 18:14:46 -04:00 committed by GitHub
parent 4915050ad4
commit 73745ecd03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 66 deletions

View File

@ -15,6 +15,7 @@ import {
AccessibilitySettings, AccessibilitySettings,
SandboxConfig, SandboxConfig,
GeminiClient, GeminiClient,
ideContext,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import process from 'node:process'; import process from 'node:process';
@ -146,11 +147,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getIdeMode: vi.fn(() => false), getIdeMode: vi.fn(() => false),
}; };
}); });
const ideContextMock = {
getActiveFileContext: vi.fn(),
subscribeToActiveFile: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
};
return { return {
...actualCore, ...actualCore,
Config: ConfigClassMock, Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig, MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
ideContext: ideContextMock,
}; };
}); });
@ -257,6 +265,7 @@ describe('App UI', () => {
// Ensure a theme is set so the theme dialog does not appear. // Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } }); mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
vi.mocked(ideContext.getActiveFileContext).mockReturnValue(undefined);
}); });
afterEach(() => { afterEach(() => {
@ -267,6 +276,64 @@ describe('App UI', () => {
vi.clearAllMocks(); // Clear mocks after each test vi.clearAllMocks(); // Clear mocks after each test
}); });
it('should display active file when available', async () => {
vi.mocked(ideContext.getActiveFileContext).mockReturnValue({
filePath: '/path/to/my-file.ts',
content: 'const a = 1;',
cursor: 0,
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Open File (my-file.ts)');
});
it('should not display active file when not available', async () => {
vi.mocked(ideContext.getActiveFileContext).mockReturnValue({
filePath: '',
content: '',
cursor: 0,
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).not.toContain('Open File');
});
it('should display active file and other context', async () => {
vi.mocked(ideContext.getActiveFileContext).mockReturnValue({
filePath: '/path/to/my-file.ts',
content: 'const a = 1;',
cursor: 0,
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Open File (my-file.ts) | 1 GEMINI.md File');
});
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => { it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getGeminiMdFileCount.mockReturnValue(1);
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that // For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
@ -282,7 +349,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using 1 GEMINI.md file'); expect(lastFrame()).toContain('Using: 1 GEMINI.md File');
}); });
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => { it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
@ -299,7 +366,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Using 2 GEMINI.md files'); expect(lastFrame()).toContain('Using: 2 GEMINI.md Files');
}); });
it('should display custom contextFileName in footer when set and count is 1', async () => { it('should display custom contextFileName in footer when set and count is 1', async () => {
@ -319,7 +386,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Using 1 AGENTS.md file'); expect(lastFrame()).toContain('Using: 1 AGENTS.md File');
}); });
it('should display a generic message when multiple context files with different names are provided', async () => { it('should display a generic message when multiple context files with different names are provided', async () => {
@ -342,7 +409,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Using 2 context files'); expect(lastFrame()).toContain('Using: 2 Context Files');
}); });
it('should display custom contextFileName with plural when set and count is > 1', async () => { it('should display custom contextFileName with plural when set and count is > 1', async () => {
@ -362,7 +429,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Using 3 MY_NOTES.TXT files'); expect(lastFrame()).toContain('Using: 3 MY_NOTES.TXT Files');
}); });
it('should not display context file message if count is 0, even if contextFileName is set', async () => { it('should not display context file message if count is 0, even if contextFileName is set', async () => {
@ -402,7 +469,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('server'); expect(lastFrame()).toContain('1 MCP Server');
}); });
it('should display only MCP server count when GEMINI.md count is 0', async () => { it('should display only MCP server count when GEMINI.md count is 0', async () => {
@ -423,7 +490,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Using 2 MCP servers'); expect(lastFrame()).toContain('Using: 2 MCP Servers');
}); });
it('should display Tips component by default', async () => { it('should display Tips component by default', async () => {

View File

@ -58,6 +58,8 @@ import {
FlashFallbackEvent, FlashFallbackEvent,
logFlashFallback, logFlashFallback,
AuthType, AuthType,
type ActiveFile,
ideContext,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js'; import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
@ -158,6 +160,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] = const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false); useState<boolean>(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined); const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [activeFile, setActiveFile] = useState<ActiveFile | undefined>();
useEffect(() => {
const unsubscribe = ideContext.subscribeToActiveFile(setActiveFile);
// Set the initial value
setActiveFile(ideContext.getActiveFileContext());
return unsubscribe;
}, []);
const openPrivacyNotice = useCallback(() => { const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true); setShowPrivacyNotice(true);
@ -883,6 +893,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Text> </Text>
) : ( ) : (
<ContextSummaryDisplay <ContextSummaryDisplay
activeFile={activeFile}
geminiMdFileCount={geminiMdFileCount} geminiMdFileCount={geminiMdFileCount}
contextFileNames={contextFileNames} contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()} mcpServers={config.getMcpServers()}

View File

@ -7,7 +7,8 @@
import React from 'react'; import React from 'react';
import { Text } from 'ink'; import { Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { type MCPServerConfig } from '@google/gemini-cli-core'; import { type ActiveFile, type MCPServerConfig } from '@google/gemini-cli-core';
import path from 'path';
interface ContextSummaryDisplayProps { interface ContextSummaryDisplayProps {
geminiMdFileCount: number; geminiMdFileCount: number;
@ -15,6 +16,7 @@ interface ContextSummaryDisplayProps {
mcpServers?: Record<string, MCPServerConfig>; mcpServers?: Record<string, MCPServerConfig>;
blockedMcpServers?: Array<{ name: string; extensionName: string }>; blockedMcpServers?: Array<{ name: string; extensionName: string }>;
showToolDescriptions?: boolean; showToolDescriptions?: boolean;
activeFile?: ActiveFile;
} }
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@ -23,6 +25,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServers, mcpServers,
blockedMcpServers, blockedMcpServers,
showToolDescriptions, showToolDescriptions,
activeFile,
}) => { }) => {
const mcpServerCount = Object.keys(mcpServers || {}).length; const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0; const blockedMcpServerCount = blockedMcpServers?.length || 0;
@ -30,18 +33,26 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
if ( if (
geminiMdFileCount === 0 && geminiMdFileCount === 0 &&
mcpServerCount === 0 && mcpServerCount === 0 &&
blockedMcpServerCount === 0 blockedMcpServerCount === 0 &&
!activeFile?.filePath
) { ) {
return <Text> </Text>; // Render an empty space to reserve height return <Text> </Text>; // Render an empty space to reserve height
} }
const activeFileText = (() => {
if (!activeFile?.filePath) {
return '';
}
return `Open File (${path.basename(activeFile.filePath)})`;
})();
const geminiMdText = (() => { const geminiMdText = (() => {
if (geminiMdFileCount === 0) { if (geminiMdFileCount === 0) {
return ''; return '';
} }
const allNamesTheSame = new Set(contextFileNames).size < 2; const allNamesTheSame = new Set(contextFileNames).size < 2;
const name = allNamesTheSame ? contextFileNames[0] : 'context'; const name = allNamesTheSame ? contextFileNames[0] : 'Context';
return `${geminiMdFileCount} ${name} file${ return `${geminiMdFileCount} ${name} File${
geminiMdFileCount > 1 ? 's' : '' geminiMdFileCount > 1 ? 's' : ''
}`; }`;
})(); })();
@ -54,36 +65,39 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
const parts = []; const parts = [];
if (mcpServerCount > 0) { if (mcpServerCount > 0) {
parts.push( parts.push(
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`, `${mcpServerCount} MCP Server${mcpServerCount > 1 ? 's' : ''}`,
); );
} }
if (blockedMcpServerCount > 0) { if (blockedMcpServerCount > 0) {
let blockedText = `${blockedMcpServerCount} blocked`; let blockedText = `${blockedMcpServerCount} Blocked`;
if (mcpServerCount === 0) { if (mcpServerCount === 0) {
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`; blockedText += ` MCP Server${blockedMcpServerCount > 1 ? 's' : ''}`;
} }
parts.push(blockedText); parts.push(blockedText);
} }
return parts.join(', '); return parts.join(', ');
})(); })();
let summaryText = 'Using '; let summaryText = 'Using: ';
if (geminiMdText) { const summaryParts = [];
summaryText += geminiMdText; if (activeFileText) {
summaryParts.push(activeFileText);
} }
if (geminiMdText && mcpText) { if (geminiMdText) {
summaryText += ' and '; summaryParts.push(geminiMdText);
} }
if (mcpText) { if (mcpText) {
summaryText += mcpText; summaryParts.push(mcpText);
// Add ctrl+t hint when MCP servers are available }
if (mcpServers && Object.keys(mcpServers).length > 0) { summaryText += summaryParts.join(' | ');
if (showToolDescriptions) {
summaryText += ' (ctrl+t to toggle)'; // Add ctrl+t hint when MCP servers are available
} else { if (mcpServers && Object.keys(mcpServers).length > 0) {
summaryText += ' (ctrl+t to view)'; if (showToolDescriptions) {
} summaryText += ' (ctrl+t to toggle)';
} else {
summaryText += ' (ctrl+t to view)';
} }
} }

View File

@ -4,16 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useEffect, useState } from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { import { shortenPath, tildeifyPath, tokenLimit } from '@google/gemini-cli-core';
shortenPath,
tildeifyPath,
tokenLimit,
ideContext,
ActiveFile,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process'; import process from 'node:process';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
@ -49,24 +43,6 @@ export const Footer: React.FC<FooterProps> = ({
const limit = tokenLimit(model); const limit = tokenLimit(model);
const percentage = promptTokenCount / limit; const percentage = promptTokenCount / limit;
const [activeFile, setActiveFile] = useState<ActiveFile | undefined>(
undefined,
);
useEffect(() => {
const updateActiveFile = () => {
const currentActiveFile = ideContext.getActiveFileContext();
setActiveFile(currentActiveFile);
};
updateActiveFile();
const unsubscribe = ideContext.subscribeToActiveFile(setActiveFile);
return () => {
unsubscribe();
};
}, []);
return ( return (
<Box marginTop={1} justifyContent="space-between" width="100%"> <Box marginTop={1} justifyContent="space-between" width="100%">
<Box> <Box>
@ -83,19 +59,6 @@ export const Footer: React.FC<FooterProps> = ({
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>} {branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
</Text> </Text>
)} )}
{activeFile && activeFile.filePath && (
<Text>
<Text color={Colors.Gray}> | </Text>
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(activeFile.filePath), 70)}
</Text>
{activeFile.cursor && (
<Text color={Colors.Gray}>
:{activeFile.cursor.line}:{activeFile.cursor.character}
</Text>
)}
</Text>
)}
{debugMode && ( {debugMode && (
<Text color={Colors.AccentRed}> <Text color={Colors.AccentRed}>
{' ' + (debugMessage || '--debug')} {' ' + (debugMessage || '--debug')}