From 344ee29f7713b6a249e510674c7410d0fb8ec2f8 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Fri, 8 Aug 2025 17:26:11 -0400 Subject: [PATCH] Use slash command instead of context drawer to display open files in editor to reduce flickering in the UI (#5858) --- packages/cli/src/ui/App.tsx | 15 +--- .../cli/src/ui/commands/ideCommand.test.ts | 28 ++++--- packages/cli/src/ui/commands/ideCommand.ts | 75 ++++++++++++++++++- .../IDEContextDetailDisplay.test.tsx | 66 ---------------- .../ui/components/IDEContextDetailDisplay.tsx | 66 ---------------- 5 files changed, 90 insertions(+), 160 deletions(-) delete mode 100644 packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx delete mode 100644 packages/cli/src/ui/components/IDEContextDetailDisplay.tsx diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 9550faa2..9f18fe55 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -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(false); const [showToolDescriptions, setShowToolDescriptions] = useState(false); - const [showIDEContextDetail, setShowIDEContextDetail] = - useState(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 && } - {showIDEContextDetail && ( - - )} + {showErrorDetails && ( diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 9898b1e8..10a97e2a 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -93,13 +93,14 @@ describe('ideCommand', () => { } as unknown as ReturnType); }); - 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', diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index fe9f764a..29e264d4 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -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(); + 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 => { + const { messageType, content } = + await getIdeStatusMessageWithFiles(ideClient); return { type: 'message', messageType, diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx deleted file mode 100644 index 629d6c2e..00000000 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx +++ /dev/null @@ -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( - , - ); - 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( - , - ); - 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( - , - ); - const output = lastFrame(); - expect(output).toMatchSnapshot(); - }); -}); diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx deleted file mode 100644 index ec3c2dad..00000000 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx +++ /dev/null @@ -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(); - for (const file of openFiles) { - const basename = path.basename(file.path); - basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1); - } - - return ( - - - {detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to - toggle) - - {openFiles.length > 0 && ( - - Open files: - {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)' : ''} - - ); - })} - - )} - - ); -}