Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion packages/cli/src/ui/utils/terminalSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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);
});
});
});
220 changes: 182 additions & 38 deletions packages/cli/src/ui/utils/terminalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
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';

Expand All @@ -54,6 +59,56 @@

type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';

/**
* Terminal metadata used for configuration.
*/
interface TerminalData {
terminalName: string;
appName: string;
}
const TERMINAL_DATA: Record<SupportedTerminal, TerminalData> = {
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'];

Expand Down Expand Up @@ -246,23 +301,17 @@

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 {
Expand Down Expand Up @@ -316,22 +365,57 @@
}
}

// 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<boolean> {
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
return false;
}

async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
const terminal = await detectTerminal();
if (!terminal) {
return false;
}

async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
const terminalData = getSupportedTerminalData(terminal);
if (!terminalData) {
return false;
}

async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
const configDir = getVSCodeStyleConfigDir(terminalData.appName);
if (!configDir) {
return false;
}

const keybindingsFile = path.join(configDir, 'keybindings.json');

async function configureAntigravity(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Antigravity', 'Antigravity');
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
const cleanContent = stripJsonComments(content);
const parsedContent = JSON.parse(cleanContent);

Check failure on line 403 in packages/cli/src/ui/utils/terminalSetup.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value

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;
}
}

/**
Expand Down Expand Up @@ -373,19 +457,79 @@
};
}

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]);
}
1 change: 1 addition & 0 deletions packages/cli/src/utils/persistentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const STATE_FILENAME = 'state.json';

interface PersistentStateData {
defaultBannerShownCount?: Record<string, number>;
terminalSetupPromptShown?: boolean;
tipsShown?: number;
hasSeenScreenReaderNudge?: boolean;
focusUiEnabled?: boolean;
Expand Down
Loading