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:
Ishaan Gupta
2026-02-25 03:18:28 +05:30
committed by GitHub
parent 16d3883642
commit 70b650122f
4 changed files with 245 additions and 39 deletions
+7
View File
@@ -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);
});
});
}); });
+184 -38
View File
@@ -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;