diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d0ba22d4559..74ab01320bd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -150,6 +150,7 @@ import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; +import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; import { @@ -606,6 +607,12 @@ export const AppContainer = (props: AppContainerProps) => { initializeFromLogger(logger); }, [logger, initializeFromLogger]); + // One-time prompt to suggest running /terminal-setup when it would help. + useTerminalSetupPrompt({ + addConfirmUpdateExtensionRequest, + historyManager, + }); + const refreshStatic = useCallback(() => { if (!isAlternateBuffer) { stdout.write(ansiEscapes.clearTerminal); diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts index dc570edaff5..ecd32f07add 100644 --- a/packages/cli/src/ui/utils/terminalSetup.test.ts +++ b/packages/cli/src/ui/utils/terminalSetup.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js'; +import { + terminalSetup, + VSCODE_SHIFT_ENTER_SEQUENCE, + shouldPromptForTerminalSetup, +} from './terminalSetup.js'; +import { terminalCapabilityManager } from './terminalCapabilityManager.js'; // Mock dependencies const mocks = vi.hoisted(() => ({ @@ -195,4 +200,51 @@ describe('terminalSetup', () => { expect(mocks.writeFile).toHaveBeenCalled(); }); }); + + describe('shouldPromptForTerminalSetup', () => { + it('should return false when kitty protocol is already enabled', async () => { + vi.mocked( + terminalCapabilityManager.isKittyProtocolEnabled, + ).mockReturnValue(true); + + const result = await shouldPromptForTerminalSetup(); + expect(result).toBe(false); + }); + + it('should return false when both Shift+Enter and Ctrl+Enter bindings already exist', async () => { + vi.mocked( + terminalCapabilityManager.isKittyProtocolEnabled, + ).mockReturnValue(false); + process.env['TERM_PROGRAM'] = 'vscode'; + + const existingBindings = [ + { + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, + }, + { + key: 'ctrl+enter', + command: 'workbench.action.terminal.sendSequence', + args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, + }, + ]; + mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings)); + + const result = await shouldPromptForTerminalSetup(); + expect(result).toBe(false); + }); + + it('should return true when keybindings file does not exist', async () => { + vi.mocked( + terminalCapabilityManager.isKittyProtocolEnabled, + ).mockReturnValue(false); + process.env['TERM_PROGRAM'] = 'vscode'; + + mocks.readFile.mockRejectedValue(new Error('ENOENT')); + + const result = await shouldPromptForTerminalSetup(); + expect(result).toBe(true); + }); + }); }); diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index 5132df49960..5d4f231d73b 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -32,6 +32,11 @@ import { promisify } from 'node:util'; import { terminalCapabilityManager } from './terminalCapabilityManager.js'; import { debugLogger, homedir } from '@google/gemini-cli-core'; +import { useEffect } from 'react'; +import { persistentState } from '../../utils/persistentState.js'; +import { requestConsentInteractive } from '../../config/extensions/consent.js'; +import type { ConfirmationRequest } from '../types.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; @@ -54,6 +59,56 @@ export interface TerminalSetupResult { type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity'; +/** + * Terminal metadata used for configuration. + */ +interface TerminalData { + terminalName: string; + appName: string; +} +const TERMINAL_DATA: Record = { + vscode: { terminalName: 'VS Code', appName: 'Code' }, + cursor: { terminalName: 'Cursor', appName: 'Cursor' }, + windsurf: { terminalName: 'Windsurf', appName: 'Windsurf' }, + antigravity: { terminalName: 'Antigravity', appName: 'Antigravity' }, +}; + +/** + * Maps a supported terminal ID to its display name and config folder name. + */ +function getSupportedTerminalData( + terminal: SupportedTerminal, +): TerminalData | null { + return TERMINAL_DATA[terminal] || null; +} + +type Keybinding = { + key?: string; + command?: string; + args?: { text?: string }; +}; + +function isKeybinding(kb: unknown): kb is Keybinding { + return typeof kb === 'object' && kb !== null; +} + +/** + * Checks if a keybindings array contains our specific binding for a given key. + */ +function hasOurBinding( + keybindings: unknown[], + key: 'shift+enter' | 'ctrl+enter', +): boolean { + return keybindings.some((kb) => { + if (!isKeybinding(kb)) return false; + return ( + kb.key === key && + kb.command === 'workbench.action.terminal.sendSequence' && + kb.args?.text === VSCODE_SHIFT_ENTER_SEQUENCE + ); + }); +} + export function getTerminalProgram(): SupportedTerminal | null { const termProgram = process.env['TERM_PROGRAM']; @@ -246,23 +301,17 @@ async function configureVSCodeStyle( const results = targetBindings.map((target) => { const hasOurBinding = keybindings.some((kb) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const binding = kb as { - command?: string; - args?: { text?: string }; - key?: string; - }; + if (!isKeybinding(kb)) return false; return ( - binding.key === target.key && - binding.command === target.command && - binding.args?.text === target.args.text + kb.key === target.key && + kb.command === target.command && + kb.args?.text === target.args.text ); }); const existingBinding = keybindings.find((kb) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const binding = kb as { key?: string }; - return binding.key === target.key; + if (!isKeybinding(kb)) return false; + return kb.key === target.key; }); return { @@ -316,22 +365,57 @@ async function configureVSCodeStyle( } } -// Terminal-specific configuration functions +/** + * Determines whether it is useful to prompt the user to run /terminal-setup + * in the current environment. + * + * Returns true when: + * - Kitty/modifyOtherKeys keyboard protocol is not already enabled, and + * - We're running inside a supported terminal (VS Code, Cursor, Windsurf, Antigravity), and + * - The keybindings file either does not exist or does not already contain both + * of our Shift+Enter and Ctrl+Enter bindings. + */ +export async function shouldPromptForTerminalSetup(): Promise { + if (terminalCapabilityManager.isKittyProtocolEnabled()) { + return false; + } -async function configureVSCode(): Promise { - return configureVSCodeStyle('VS Code', 'Code'); -} + const terminal = await detectTerminal(); + if (!terminal) { + return false; + } -async function configureCursor(): Promise { - return configureVSCodeStyle('Cursor', 'Cursor'); -} + const terminalData = getSupportedTerminalData(terminal); + if (!terminalData) { + return false; + } -async function configureWindsurf(): Promise { - return configureVSCodeStyle('Windsurf', 'Windsurf'); -} + const configDir = getVSCodeStyleConfigDir(terminalData.appName); + if (!configDir) { + return false; + } + + const keybindingsFile = path.join(configDir, 'keybindings.json'); -async function configureAntigravity(): Promise { - return configureVSCodeStyle('Antigravity', 'Antigravity'); + try { + const content = await fs.readFile(keybindingsFile, 'utf8'); + const cleanContent = stripJsonComments(content); + const parsedContent = JSON.parse(cleanContent); + + if (!Array.isArray(parsedContent)) { + return true; + } + + const hasOurShiftEnter = hasOurBinding(parsedContent, 'shift+enter'); + const hasOurCtrlEnter = hasOurBinding(parsedContent, 'ctrl+enter'); + + return !(hasOurShiftEnter && hasOurCtrlEnter); + } catch (error) { + debugLogger.debug( + `Failed to read or parse keybindings, assuming prompt is needed: ${error}`, + ); + return true; + } } /** @@ -373,19 +457,79 @@ export async function terminalSetup(): Promise { }; } - switch (terminal) { - case 'vscode': - return configureVSCode(); - case 'cursor': - return configureCursor(); - case 'windsurf': - return configureWindsurf(); - case 'antigravity': - return configureAntigravity(); - default: - return { - success: false, - message: `Terminal "${terminal}" is not supported yet.`, - }; + const terminalData = getSupportedTerminalData(terminal); + if (!terminalData) { + return { + success: false, + message: `Terminal "${terminal}" is not supported yet.`, + }; } + + return configureVSCodeStyle(terminalData.terminalName, terminalData.appName); +} + +export const TERMINAL_SETUP_CONSENT_MESSAGE = + 'Gemini CLI works best with Shift+Enter/Ctrl+Enter for multiline input. ' + + 'Would you like to automatically configure your terminal keybindings?'; + +export function formatTerminalSetupResultMessage( + result: TerminalSetupResult, +): string { + let content = result.message; + if (result.requiresRestart) { + content += + '\n\nPlease restart your terminal for the changes to take effect.'; + } + return content; +} + +interface UseTerminalSetupPromptParams { + addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; + historyManager: UseHistoryManagerReturn; +} + +/** + * Hook that shows a one-time prompt to run /terminal-setup when it would help. + */ +export function useTerminalSetupPrompt({ + addConfirmUpdateExtensionRequest, + historyManager, +}: UseTerminalSetupPromptParams): void { + useEffect(() => { + const hasBeenPrompted = persistentState.get('terminalSetupPromptShown'); + if (hasBeenPrompted) { + return; + } + + let cancelled = false; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + const shouldPrompt = await shouldPromptForTerminalSetup(); + if (!shouldPrompt || cancelled) return; + + persistentState.set('terminalSetupPromptShown', true); + + const confirmed = await requestConsentInteractive( + TERMINAL_SETUP_CONSENT_MESSAGE, + addConfirmUpdateExtensionRequest, + ); + + if (!confirmed || cancelled) return; + + const result = await terminalSetup(); + if (cancelled) return; + historyManager.addItem( + { + type: result.success ? 'info' : 'error', + text: formatTerminalSetupResultMessage(result), + }, + Date.now(), + ); + })(); + + return () => { + cancelled = true; + }; + }, [addConfirmUpdateExtensionRequest, historyManager]); } diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index 6ad56950976..e886181704a 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -12,6 +12,7 @@ const STATE_FILENAME = 'state.json'; interface PersistentStateData { defaultBannerShownCount?: Record; + terminalSetupPromptShown?: boolean; tipsShown?: number; hasSeenScreenReaderNudge?: boolean; focusUiEnabled?: boolean;