Add drawer for active files in IDE mode (#4682)

Co-authored-by: Shreya <shreyakeshive@google.com>
This commit is contained in:
christine betts 2025-07-25 14:50:34 +00:00 committed by GitHub
parent 5d4b02ca85
commit 1d3ad9d075
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 112 additions and 23 deletions

View File

@ -281,6 +281,7 @@ describe('App UI', () => {
it('should display active file when available', async () => { it('should display active file when available', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts', activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello', selectedText: 'hello',
}); });
@ -293,7 +294,7 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Open File (my-file.ts)'); expect(lastFrame()).toContain('1 recent file (ctrl+e to view)');
}); });
it('should not display active file when not available', async () => { it('should not display active file when not available', async () => {
@ -316,9 +317,11 @@ describe('App UI', () => {
it('should display active file and other context', async () => { it('should display active file and other context', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({ vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts', activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello', selectedText: 'hello',
}); });
mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
const { lastFrame, unmount } = render( const { lastFrame, unmount } = render(
<App <App
@ -329,11 +332,14 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('Open File (my-file.ts) | 1 GEMINI.md File'); expect(lastFrame()).toContain(
'Using: 1 recent file (ctrl+e to view) | 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);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
// 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
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -347,11 +353,15 @@ 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 () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2); mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -364,7 +374,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 () => {
@ -372,6 +382,7 @@ describe('App UI', () => {
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' }, workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
}); });
mockConfig.getGeminiMdFileCount.mockReturnValue(1); mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -384,7 +395,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 () => {
@ -395,6 +406,10 @@ describe('App UI', () => {
}, },
}); });
mockConfig.getGeminiMdFileCount.mockReturnValue(2); mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'AGENTS.md',
'CONTEXT.md',
]);
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -407,7 +422,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 () => {
@ -415,6 +430,11 @@ describe('App UI', () => {
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' }, workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
}); });
mockConfig.getGeminiMdFileCount.mockReturnValue(3); mockConfig.getGeminiMdFileCount.mockReturnValue(3);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'MY_NOTES.TXT',
'MY_NOTES.TXT',
'MY_NOTES.TXT',
]);
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -427,7 +447,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 () => {
@ -435,6 +455,7 @@ describe('App UI', () => {
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' }, workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
}); });
mockConfig.getGeminiMdFileCount.mockReturnValue(0); mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getDebugMode.mockReturnValue(false); mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false); mockConfig.getShowMemoryUsage.mockReturnValue(false);
@ -452,6 +473,10 @@ describe('App UI', () => {
it('should display GEMINI.md and MCP server count when both are present', async () => { it('should display GEMINI.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2); mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getMcpServers.mockReturnValue({ mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig, server1: {} as MCPServerConfig,
}); });
@ -467,11 +492,12 @@ describe('App UI', () => {
); );
currentUnmount = unmount; currentUnmount = unmount;
await Promise.resolve(); await Promise.resolve();
expect(lastFrame()).toContain('1 MCP 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 () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0); mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getMcpServers.mockReturnValue({ mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig, server1: {} as MCPServerConfig,
server2: {} as MCPServerConfig, server2: {} as MCPServerConfig,
@ -488,7 +514,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 (ctrl+t to view)');
}); });
it('should display Tips component by default', async () => { it('should display Tips component by default', async () => {

View File

@ -46,6 +46,7 @@ import { registerCleanup } from '../utils/cleanup.js';
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js'; import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js'; import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process'; import process from 'node:process';
import { import {
@ -148,6 +149,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false); const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] = const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false); useState<boolean>(false);
const [showIDEContextDetail, setShowIDEContextDetail] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState< const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null HistoryItem[] | null
@ -465,6 +468,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (Object.keys(mcpServers || {}).length > 0) { if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc'); handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
} }
} else if (key.ctrl && input === 'e' && ideContext) {
setShowIDEContextDetail((prev) => !prev);
} else if (key.ctrl && (input === 'c' || input === 'C')) { } else if (key.ctrl && (input === 'c' || input === 'C')) {
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (key.ctrl && (input === 'd' || input === 'D')) { } else if (key.ctrl && (input === 'd' || input === 'D')) {
@ -861,6 +866,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
} }
elapsedTime={elapsedTime} elapsedTime={elapsedTime}
/> />
<Box <Box
marginTop={1} marginTop={1}
display="flex" display="flex"
@ -900,7 +906,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{shellModeActive && <ShellModeIndicator />} {shellModeActive && <ShellModeIndicator />}
</Box> </Box>
</Box> </Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay openFiles={openFiles} />
)}
{showErrorDetails && ( {showErrorDetails && (
<OverflowProvider> <OverflowProvider>
<Box flexDirection="column"> <Box flexDirection="column">

View File

@ -8,7 +8,6 @@ import React from 'react';
import { Text } from 'ink'; import { Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { type OpenFiles, type MCPServerConfig } from '@google/gemini-cli-core'; import { type OpenFiles, type MCPServerConfig } from '@google/gemini-cli-core';
import path from 'path';
interface ContextSummaryDisplayProps { interface ContextSummaryDisplayProps {
geminiMdFileCount: number; geminiMdFileCount: number;
@ -34,16 +33,17 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
geminiMdFileCount === 0 && geminiMdFileCount === 0 &&
mcpServerCount === 0 && mcpServerCount === 0 &&
blockedMcpServerCount === 0 && blockedMcpServerCount === 0 &&
!openFiles?.activeFile (openFiles?.recentOpenFiles?.length ?? 0) === 0
) { ) {
return <Text> </Text>; // Render an empty space to reserve height return <Text> </Text>; // Render an empty space to reserve height
} }
const activeFileText = (() => { const recentFilesText = (() => {
if (!openFiles?.activeFile) { const count = openFiles?.recentOpenFiles?.length ?? 0;
if (count === 0) {
return ''; return '';
} }
return `Open File (${path.basename(openFiles.activeFile)})`; return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
})(); })();
const geminiMdText = (() => { const geminiMdText = (() => {
@ -51,8 +51,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
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' : ''
}`; }`;
})(); })();
@ -65,14 +65,14 @@ 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);
} }
@ -81,8 +81,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
let summaryText = 'Using: '; let summaryText = 'Using: ';
const summaryParts = []; const summaryParts = [];
if (activeFileText) { if (recentFilesText) {
summaryParts.push(activeFileText); summaryParts.push(recentFilesText);
} }
if (geminiMdText) { if (geminiMdText) {
summaryParts.push(geminiMdText); summaryParts.push(geminiMdText);

View File

@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { type OpenFiles } from '@google/gemini-cli-core';
import { Colors } from '../colors.js';
import path from 'node:path';
interface IDEContextDetailDisplayProps {
openFiles: OpenFiles | undefined;
}
export function IDEContextDetailDisplay({
openFiles,
}: IDEContextDetailDisplayProps) {
if (
!openFiles ||
!openFiles.recentOpenFiles ||
openFiles.recentOpenFiles.length === 0
) {
return null;
}
const recentFiles = openFiles.recentOpenFiles || [];
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.AccentCyan}
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
IDE Context (ctrl+e to toggle)
</Text>
{recentFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Recent files:</Text>
{recentFiles.map((file) => (
<Text key={file.filePath}>
- {path.basename(file.filePath)}
{file.filePath === openFiles.activeFile ? ' (active)' : ''}
</Text>
))}
</Box>
)}
</Box>
);
}

View File

@ -252,8 +252,11 @@ const createMcpServer = () => {
inputSchema: {}, inputSchema: {},
}, },
async () => { async () => {
const activeEditor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
const filePath = activeEditor ? activeEditor.document.uri.fsPath : ''; const filePath =
editor && editor.document.uri.scheme === 'file'
? editor.document.uri.fsPath
: '';
if (filePath) { if (filePath) {
return { return {
content: [{ type: 'text', text: `Active file: ${filePath}` }], content: [{ type: 'text', text: `Active file: ${filePath}` }],