Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
Expand Down Expand Up @@ -551,6 +552,9 @@ export const AppContainer = (props: AppContainerProps) => {
initializeFromLogger(logger);
}, [logger, initializeFromLogger]);

// Prompt user to run /terminal-setup if applicable (one-time)
useTerminalSetupPrompt(addConfirmUpdateExtensionRequest, historyManager);

const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
Expand Down
132 changes: 131 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,11 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
import {
terminalSetup,
shouldPromptForTerminalSetup,
VSCODE_SHIFT_ENTER_SEQUENCE,
} from './terminalSetup.js';

// Mock dependencies
const mocks = vi.hoisted(() => ({
Expand All @@ -22,6 +26,25 @@ const mocks = vi.hoisted(() => ({
},
}));

// Mock React (needed because terminalSetup.ts imports useEffect/useRef)
vi.mock('react', () => ({
useEffect: vi.fn(),
useRef: vi.fn(() => ({ current: false })),
}));

// Mock persistentState
vi.mock('../../utils/persistentState.js', () => ({
persistentState: {
get: vi.fn().mockReturnValue(undefined),
set: vi.fn(),
},
}));

// Mock consent module
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentInteractive: vi.fn(),
}));

vi.mock('node:child_process', () => ({
exec: mocks.exec,
execFile: vi.fn(),
Expand All @@ -48,6 +71,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
homedir: mocks.homedir,
debugLogger: {
...actual.debugLogger,
debug: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
};
});

Expand All @@ -57,6 +86,9 @@ vi.mock('./terminalCapabilityManager.js', () => ({
},
}));

// Import the mock so we can control isKittyProtocolEnabled per test
import { terminalCapabilityManager } from './terminalCapabilityManager.js';

describe('terminalSetup', () => {
beforeEach(() => {
vi.resetAllMocks();
Expand All @@ -71,6 +103,9 @@ describe('terminalSetup', () => {
mocks.mkdir.mockResolvedValue(undefined);
mocks.copyFile.mockResolvedValue(undefined);
mocks.exec.mockImplementation((cmd, cb) => cb(null, { stdout: '' }));
vi.mocked(terminalCapabilityManager.isKittyProtocolEnabled).mockReturnValue(
false,
);
});

afterEach(() => {
Expand Down Expand Up @@ -195,4 +230,99 @@ describe('terminalSetup', () => {
expect(mocks.writeFile).toHaveBeenCalled();
});
});

describe('shouldPromptForTerminalSetup', () => {
it('should return false when kitty protocol is enabled', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(true);
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});

it('should return false when no supported terminal is detected', async () => {
// No terminal env vars set (defaults from beforeEach clear them)
mocks.exec.mockImplementation(
(cmd: string, cb: (err: null, result: { stdout: string }) => void) =>
cb(null, { stdout: 'bash\n' }),
);
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});

it('should return true when in VS Code with no keybindings file', async () => {
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockRejectedValue(new Error('ENOENT'));

const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});

it('should return true when in VS Code with empty keybindings', async () => {
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockResolvedValue('[]');

const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});

it('should return false when both shift+enter and ctrl+enter bindings exist', async () => {
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 only shift+enter binding exists', async () => {
process.env['TERM_PROGRAM'] = 'vscode';
const existingBindings = [
{
key: 'shift+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(true);
});

it('should return true when keybindings file contains invalid JSON', async () => {
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockResolvedValue('{ invalid json');

const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});

it('should return true when keybindings file is not an array', async () => {
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockResolvedValue('{}');

const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});

it('should work for Cursor terminal', async () => {
process.env['CURSOR_TRACE_ID'] = 'some-id';
mocks.readFile.mockRejectedValue(new Error('ENOENT'));

const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});
});
});
Loading