mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat: prompt users to run /terminal-setup with yes/no (#16235)
Co-authored-by: Vedant Mahajan <Vedant.04.mahajan@gmail.com>
This commit is contained in:
@@ -150,6 +150,7 @@ import { useSettings } from './contexts/SettingsContext.js';
|
|||||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { useBanner } from './hooks/useBanner.js';
|
import { useBanner } from './hooks/useBanner.js';
|
||||||
|
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||||
import {
|
import {
|
||||||
@@ -606,6 +607,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
initializeFromLogger(logger);
|
initializeFromLogger(logger);
|
||||||
}, [logger, initializeFromLogger]);
|
}, [logger, initializeFromLogger]);
|
||||||
|
|
||||||
|
// One-time prompt to suggest running /terminal-setup when it would help.
|
||||||
|
useTerminalSetupPrompt({
|
||||||
|
addConfirmUpdateExtensionRequest,
|
||||||
|
addItem: historyManager.addItem,
|
||||||
|
});
|
||||||
|
|
||||||
const refreshStatic = useCallback(() => {
|
const refreshStatic = useCallback(() => {
|
||||||
if (!isAlternateBuffer) {
|
if (!isAlternateBuffer) {
|
||||||
stdout.write(ansiEscapes.clearTerminal);
|
stdout.write(ansiEscapes.clearTerminal);
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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
|
// Mock dependencies
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@@ -195,4 +200,51 @@ describe('terminalSetup', () => {
|
|||||||
expect(mocks.writeFile).toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ import { promisify } from 'node:util';
|
|||||||
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||||
|
|
||||||
import { debugLogger, homedir } from '@google/gemini-cli-core';
|
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';
|
||||||
|
|
||||||
|
type AddItemFn = UseHistoryManagerReturn['addItem'];
|
||||||
|
|
||||||
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
|
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
|
||||||
|
|
||||||
@@ -54,6 +61,56 @@ export interface TerminalSetupResult {
|
|||||||
|
|
||||||
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';
|
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 {
|
export function getTerminalProgram(): SupportedTerminal | null {
|
||||||
const termProgram = process.env['TERM_PROGRAM'];
|
const termProgram = process.env['TERM_PROGRAM'];
|
||||||
|
|
||||||
@@ -246,23 +303,17 @@ async function configureVSCodeStyle(
|
|||||||
|
|
||||||
const results = targetBindings.map((target) => {
|
const results = targetBindings.map((target) => {
|
||||||
const hasOurBinding = keybindings.some((kb) => {
|
const hasOurBinding = keybindings.some((kb) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
if (!isKeybinding(kb)) return false;
|
||||||
const binding = kb as {
|
|
||||||
command?: string;
|
|
||||||
args?: { text?: string };
|
|
||||||
key?: string;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
binding.key === target.key &&
|
kb.key === target.key &&
|
||||||
binding.command === target.command &&
|
kb.command === target.command &&
|
||||||
binding.args?.text === target.args.text
|
kb.args?.text === target.args.text
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingBinding = keybindings.find((kb) => {
|
const existingBinding = keybindings.find((kb) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
if (!isKeybinding(kb)) return false;
|
||||||
const binding = kb as { key?: string };
|
return kb.key === target.key;
|
||||||
return binding.key === target.key;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -316,22 +367,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<boolean> {
|
||||||
|
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function configureVSCode(): Promise<TerminalSetupResult> {
|
const terminal = await detectTerminal();
|
||||||
return configureVSCodeStyle('VS Code', 'Code');
|
if (!terminal) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function configureCursor(): Promise<TerminalSetupResult> {
|
const terminalData = getSupportedTerminalData(terminal);
|
||||||
return configureVSCodeStyle('Cursor', 'Cursor');
|
if (!terminalData) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function configureWindsurf(): Promise<TerminalSetupResult> {
|
const configDir = getVSCodeStyleConfigDir(terminalData.appName);
|
||||||
return configureVSCodeStyle('Windsurf', 'Windsurf');
|
if (!configDir) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function configureAntigravity(): Promise<TerminalSetupResult> {
|
const keybindingsFile = path.join(configDir, 'keybindings.json');
|
||||||
return configureVSCodeStyle('Antigravity', 'Antigravity');
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(keybindingsFile, 'utf8');
|
||||||
|
const cleanContent = stripJsonComments(content);
|
||||||
|
const parsedContent: unknown = JSON.parse(cleanContent) as unknown;
|
||||||
|
|
||||||
|
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 +459,79 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (terminal) {
|
const terminalData = getSupportedTerminalData(terminal);
|
||||||
case 'vscode':
|
if (!terminalData) {
|
||||||
return configureVSCode();
|
return {
|
||||||
case 'cursor':
|
success: false,
|
||||||
return configureCursor();
|
message: `Terminal "${terminal}" is not supported yet.`,
|
||||||
case 'windsurf':
|
};
|
||||||
return configureWindsurf();
|
|
||||||
case 'antigravity':
|
|
||||||
return configureAntigravity();
|
|
||||||
default:
|
|
||||||
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;
|
||||||
|
addItem: AddItemFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that shows a one-time prompt to run /terminal-setup when it would help.
|
||||||
|
*/
|
||||||
|
export function useTerminalSetupPrompt({
|
||||||
|
addConfirmUpdateExtensionRequest,
|
||||||
|
addItem,
|
||||||
|
}: 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;
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: result.success ? 'info' : 'error',
|
||||||
|
text: formatTerminalSetupResultMessage(result),
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [addConfirmUpdateExtensionRequest, addItem]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const STATE_FILENAME = 'state.json';
|
|||||||
|
|
||||||
interface PersistentStateData {
|
interface PersistentStateData {
|
||||||
defaultBannerShownCount?: Record<string, number>;
|
defaultBannerShownCount?: Record<string, number>;
|
||||||
|
terminalSetupPromptShown?: boolean;
|
||||||
tipsShown?: number;
|
tipsShown?: number;
|
||||||
hasSeenScreenReaderNudge?: boolean;
|
hasSeenScreenReaderNudge?: boolean;
|
||||||
focusUiEnabled?: boolean;
|
focusUiEnabled?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user