From 024b8207eb75bdc0c031f6380d6759b9e342e502 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Wed, 6 Aug 2025 15:47:58 -0400 Subject: [PATCH] Add hint to enable IDE integration for users running in VS Code (#5610) --- packages/cli/src/config/settings.ts | 3 + packages/cli/src/ui/App.test.tsx | 8 +- packages/cli/src/ui/App.tsx | 45 ++++++++- packages/cli/src/ui/IdeIntegrationNudge.tsx | 70 +++++++++++++ packages/vscode-ide-companion/package.json | 4 +- .../src/extension.test.ts | 99 +++++++++++++++++++ .../vscode-ide-companion/src/extension.ts | 20 ++++ 7 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/ui/IdeIntegrationNudge.tsx create mode 100644 packages/vscode-ide-companion/src/extension.test.ts diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index bb8c87b8..93641ae0 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -115,6 +115,9 @@ export interface Settings { /// IDE mode setting configured via slash command toggle. ideMode?: boolean; + // Setting to track if the user has seen the IDE integration nudge. + hasSeenIdeIntegrationNudge?: boolean; + // Setting for disabling auto-update. disableAutoUpdate?: boolean; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index fc6dbb5a..a5c2a9c6 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -16,6 +16,7 @@ import { SandboxConfig, GeminiClient, ideContext, + type AuthType, } from '@google/gemini-cli-core'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import process from 'node:process'; @@ -84,6 +85,7 @@ interface MockServerConfig { getAllGeminiMdFilenames: Mock<() => string[]>; getGeminiClient: Mock<() => GeminiClient | undefined>; getUserTier: Mock<() => Promise>; + getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>; } // Mock @google/gemini-cli-core and its Config class @@ -157,6 +159,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { getWorkspaceContext: vi.fn(() => ({ getDirectories: vi.fn(() => []), })), + getIdeClient: vi.fn(() => ({ + getCurrentIde: vi.fn(() => 'vscode'), + })), }; }); @@ -182,6 +187,7 @@ vi.mock('./hooks/useGeminiStream', () => ({ submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], + thought: null, })), })); @@ -233,7 +239,7 @@ vi.mock('./utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(), })); -vi.mock('./config/auth.js', () => ({ +vi.mock('../config/auth.js', () => ({ validateAuthMethod: vi.fn(), })); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f2dcc79e..2be681e5 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -39,7 +39,7 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; -import { LoadedSettings } from '../config/settings.js'; +import { LoadedSettings, SettingScope } from '../config/settings.js'; import { Tips } from './components/Tips.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup } from '../utils/cleanup.js'; @@ -62,6 +62,10 @@ import { type IdeContext, ideContext, } from '@google/gemini-cli-core'; +import { + IdeIntegrationNudge, + IdeIntegrationNudgeResult, +} from './IdeIntegrationNudge.js'; import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.js'; import { StreamingContext } from './contexts/StreamingContext.js'; @@ -115,6 +119,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const nightly = version.includes('nightly'); const { history, addItem, clearItems, loadHistory } = useHistory(); + const [idePromptAnswered, setIdePromptAnswered] = useState(false); + const currentIDE = config.getIdeClient().getCurrentIde(); + const shouldShowIdePrompt = + config.getIdeModeFeature() && + currentIDE && + !config.getIdeMode() && + !settings.merged.hasSeenIdeIntegrationNudge && + !idePromptAnswered; + useEffect(() => { const cleanup = setUpdateHandler(addItem, setUpdateInfo); return cleanup; @@ -538,6 +551,27 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [submitQuery], ); + const handleIdePromptComplete = useCallback( + (result: IdeIntegrationNudgeResult) => { + if (result === 'yes') { + handleSlashCommand('/ide install'); + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } else if (result === 'dismiss') { + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } + setIdePromptAnswered(true); + }, + [handleSlashCommand, settings], + ); + const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; pendingHistoryItems.push(...pendingGeminiHistoryItems); @@ -768,6 +802,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ); } + const mainAreaWidth = Math.floor(terminalWidth * 0.9); const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5)); // Arbitrary threshold to ensure that items in the static area are large @@ -859,7 +894,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { )} - {shellConfirmationRequest ? ( + {shouldShowIdePrompt ? ( + + ) : shellConfirmationRequest ? ( ) : isThemeDialogOpen ? ( diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx new file mode 100644 index 00000000..72cd1756 --- /dev/null +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text, useInput } from 'ink'; +import { + RadioButtonSelect, + RadioSelectItem, +} from './components/shared/RadioButtonSelect.js'; + +export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss'; + +interface IdeIntegrationNudgeProps { + question: string; + description?: string; + onComplete: (result: IdeIntegrationNudgeResult) => void; +} + +export function IdeIntegrationNudge({ + question, + description, + onComplete, +}: IdeIntegrationNudgeProps) { + useInput((_input, key) => { + if (key.escape) { + onComplete('no'); + } + }); + + const OPTIONS: Array> = [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No (esc)', + value: 'no', + }, + { + label: "No, don't ask again", + value: 'dismiss', + }, + ]; + + return ( + + + + {'> '} + {question} + + {description && {description}} + + + + ); +} diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 263f1b18..aee14e32 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -39,12 +39,12 @@ "commands": [ { "command": "gemini.diff.accept", - "title": "Gemini CLI: Accept Current Diff", + "title": "Gemini CLI: Accept Diff", "icon": "$(check)" }, { "command": "gemini.diff.cancel", - "title": "Cancel", + "title": "Gemini CLI: Close Diff Editor", "icon": "$(close)" }, { diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts new file mode 100644 index 00000000..89d1821f --- /dev/null +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { activate } from './extension.js'; + +vi.mock('vscode', () => ({ + window: { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + })), + showInformationMessage: vi.fn(), + createTerminal: vi.fn(() => ({ + show: vi.fn(), + sendText: vi.fn(), + })), + }, + workspace: { + workspaceFolders: [], + onDidCloseTextDocument: vi.fn(), + registerTextDocumentContentProvider: vi.fn(), + onDidChangeWorkspaceFolders: vi.fn(), + }, + commands: { + registerCommand: vi.fn(), + executeCommand: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, + ExtensionMode: { + Development: 1, + Production: 2, + }, + EventEmitter: vi.fn(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), +})); + +describe('activate', () => { + let context: vscode.ExtensionContext; + + beforeEach(() => { + context = { + subscriptions: [], + environmentVariableCollection: { + replace: vi.fn(), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + extensionUri: { + fsPath: '/path/to/extension', + }, + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should show the info message on first activation', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue(undefined as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'Gemini CLI Companion extension successfully installed. Please restart your terminal to enable full IDE integration.', + 'Re-launch Gemini CLI', + ); + }); + + it('should not show the info message on subsequent activations', async () => { + vi.mocked(context.globalState.get).mockReturnValue(true); + await activate(context); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it('should launch the Gemini CLI when the user clicks the button', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue('Re-launch Gemini CLI' as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalled(); + await new Promise(process.nextTick); // Wait for the promise to resolve + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'gemini-cli.runGeminiCLI', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index b31e15b8..08389731 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -9,6 +9,7 @@ import { IDEServer } from './ide-server.js'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; import { createLogger } from './utils/logger.js'; +const INFO_MESSAGE_SHOWN_KEY = 'geminiCliInfoMessageShown'; const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; export const DIFF_SCHEME = 'gemini-diff'; @@ -81,6 +82,25 @@ export async function activate(context: vscode.ExtensionContext) { log(`Failed to start IDE server: ${message}`); } + if (!context.globalState.get(INFO_MESSAGE_SHOWN_KEY)) { + void vscode.window + .showInformationMessage( + 'Gemini CLI Companion extension successfully installed. Please restart your terminal to enable full IDE integration.', + 'Re-launch Gemini CLI', + ) + .then( + (selection) => { + if (selection === 'Re-launch Gemini CLI') { + void vscode.commands.executeCommand('gemini-cli.runGeminiCLI'); + } + }, + (err) => { + log(`Failed to show information message: ${String(err)}`); + }, + ); + context.globalState.update(INFO_MESSAGE_SHOWN_KEY, true); + } + context.subscriptions.push( vscode.workspace.onDidChangeWorkspaceFolders(() => { updateWorkspacePath(context);