Use slash command instead of context drawer to display open files in editor to reduce flickering in the UI (#5858)

This commit is contained in:
Shreya Keshive 2025-08-08 17:26:11 -04:00 committed by GitHub
parent 60bde58f29
commit 344ee29f77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 90 additions and 160 deletions

View File

@ -48,7 +48,6 @@ import { registerCleanup } from '../utils/cleanup.js';
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process';
import {
@ -174,8 +173,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [showIDEContextDetail, setShowIDEContextDetail] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@ -640,7 +638,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config.getIdeMode() &&
ideContextState
) {
setShowIDEContextDetail((prev) => !prev);
handleSlashCommand('/ide status');
} else if (key.ctrl && (input === 'c' || input === 'C')) {
if (isAuthenticating) {
// Let AuthInProgress component handle the input.
@ -1040,14 +1038,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">

View File

@ -93,13 +93,14 @@ describe('ideCommand', () => {
} as unknown as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', () => {
it('should show connected status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@ -108,13 +109,14 @@ describe('ideCommand', () => {
});
});
it('should show connecting status', () => {
it('should show connecting status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@ -122,13 +124,14 @@ describe('ideCommand', () => {
content: `🟡 Connecting...`,
});
});
it('should show disconnected status', () => {
it('should show disconnected status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@ -137,15 +140,16 @@ describe('ideCommand', () => {
});
});
it('should show disconnected status with details', () => {
it('should show disconnected status with details', async () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',

View File

@ -11,7 +11,10 @@ import {
getIdeDisplayName,
getIdeInstaller,
IdeClient,
type File,
ideContext,
} from '@google/gemini-cli-core';
import path from 'node:path';
import {
CommandContext,
SlashCommand,
@ -49,6 +52,70 @@ function getIdeStatusMessage(ideClient: IdeClient): {
}
}
function formatFileList(openFiles: File[]): string {
const basenameCounts = new Map<string, number>();
for (const file of openFiles) {
const basename = path.basename(file.path);
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
}
const fileList = openFiles
.map((file: File) => {
const basename = path.basename(file.path);
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
const parentDir = path.basename(path.dirname(file.path));
const displayName = isDuplicate
? `${basename} (/${parentDir})`
: basename;
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
})
.join('\n');
return `\n\nOpen files:\n${fileList}`;
}
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
messageType: 'info' | 'error';
content: string;
}> {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
try {
const context = await ideContext.getIdeContext();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
}
} catch (_e) {
// Ignore
}
return {
messageType: 'info',
content,
};
}
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
return null;
@ -66,8 +133,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
).map((ide) => getIdeDisplayName(ide))}
.join(', ')}`,
}) as const,
};
@ -84,8 +150,9 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => {
const { messageType, content } = getIdeStatusMessage(ideClient);
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
return {
type: 'message',
messageType,

View File

@ -1,66 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { IDEContextDetailDisplay } from './IDEContextDetailDisplay.js';
import { type IdeContext } from '@google/gemini-cli-core';
describe('IDEContextDetailDisplay', () => {
it('renders an empty string when there are no open files', () => {
const ideContext: IdeContext = {
workspaceState: {
openFiles: [],
},
};
const { lastFrame } = render(
<IDEContextDetailDisplay
ideContext={ideContext}
detectedIdeDisplay="VS Code"
/>,
);
expect(lastFrame()).toBe('');
});
it('renders a list of open files with active status', () => {
const ideContext: IdeContext = {
workspaceState: {
openFiles: [
{ path: '/foo/bar.txt', isActive: true },
{ path: '/foo/baz.txt', isActive: false },
],
},
};
const { lastFrame } = render(
<IDEContextDetailDisplay
ideContext={ideContext}
detectedIdeDisplay="VS Code"
/>,
);
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('handles duplicate basenames by showing path hints', () => {
const ideContext: IdeContext = {
workspaceState: {
openFiles: [
{ path: '/foo/bar.txt', isActive: true },
{ path: '/qux/bar.txt', isActive: false },
{ path: '/foo/unique.txt', isActive: false },
],
},
};
const { lastFrame } = render(
<IDEContextDetailDisplay
ideContext={ideContext}
detectedIdeDisplay="VS Code"
/>,
);
const output = lastFrame();
expect(output).toMatchSnapshot();
});
});

View File

@ -1,66 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type File, type IdeContext } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import path from 'node:path';
import { Colors } from '../colors.js';
interface IDEContextDetailDisplayProps {
ideContext: IdeContext | undefined;
detectedIdeDisplay: string | undefined;
}
export function IDEContextDetailDisplay({
ideContext,
detectedIdeDisplay,
}: IDEContextDetailDisplayProps) {
const openFiles = ideContext?.workspaceState?.openFiles;
if (!openFiles || openFiles.length === 0) {
return null;
}
const basenameCounts = new Map<string, number>();
for (const file of openFiles) {
const basename = path.basename(file.path);
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.AccentCyan}
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
toggle)
</Text>
{openFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Open files:</Text>
{openFiles.map((file: File) => {
const basename = path.basename(file.path);
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
const parentDir = path.basename(path.dirname(file.path));
const displayName = isDuplicate
? `${basename} (/${parentDir})`
: basename;
return (
<Text key={file.path}>
- {displayName}
{file.isActive ? ' (active)' : ''}
</Text>
);
})}
</Box>
)}
</Box>
);
}