Merge tag 'v0.1.21' of github.com:google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.21

This commit is contained in:
mingholy.lmh
2025-08-20 22:24:50 +08:00
163 changed files with 8812 additions and 4098 deletions

View File

@@ -155,13 +155,13 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
getIdeModeFeature: vi.fn(() => false),
getIdeMode: vi.fn(() => false),
getIdeMode: vi.fn(() => true),
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
getDetectedIdeDisplayName: vi.fn(() => 'VSCode'),
})),
};
});

View File

@@ -82,6 +82,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js';
@@ -132,7 +133,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
registerCleanup(() => config.getIdeClient().disconnect());
}, [config]);
const shouldShowIdePrompt =
config.getIdeModeFeature() &&
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
@@ -254,8 +254,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
useFolderTrust(settings);
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
config,
);
const {
isAuthDialogOpen,
@@ -608,14 +610,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result === 'yes') {
handleSlashCommand('/ide install');
if (result.userSelection === 'yes') {
if (result.isExtensionPreInstalled) {
handleSlashCommand('/ide enable');
} else {
handleSlashCommand('/ide install');
}
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
} else if (result === 'dismiss') {
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
@@ -634,6 +640,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const kittyProtocolStatus = useKittyKeyboardProtocol();
const handleExit = useCallback(
(
@@ -726,7 +733,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
useEffect(() => {
if (config) {
@@ -974,9 +985,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
)}
{shouldShowIdePrompt ? (
{shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
ide={currentIDE}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (

View File

@@ -4,44 +4,78 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { DetectedIde, getIdeInfo } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss';
export type IdeIntegrationNudgeResult = {
userSelection: 'yes' | 'no' | 'dismiss';
isExtensionPreInstalled: boolean;
};
interface IdeIntegrationNudgeProps {
ideName?: string;
ide: DetectedIde;
onComplete: (result: IdeIntegrationNudgeResult) => void;
}
export function IdeIntegrationNudge({
ideName,
ide,
onComplete,
}: IdeIntegrationNudgeProps) {
useInput((_input, key) => {
if (key.escape) {
onComplete('no');
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onComplete({
userSelection: 'no',
isExtensionPreInstalled: false,
});
}
},
{ isActive: true },
);
const { displayName: ideName } = getIdeInfo(ide);
// Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled =
!!process.env.GEMINI_CLI_IDE_SERVER_PORT &&
!!process.env.GEMINI_CLI_IDE_WORKSPACE_PATH;
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
{
label: 'Yes',
value: 'yes',
value: {
userSelection: 'yes',
isExtensionPreInstalled,
},
},
{
label: 'No (esc)',
value: 'no',
value: {
userSelection: 'no',
isExtensionPreInstalled,
},
},
{
label: "No, don't ask again",
value: 'dismiss',
value: {
userSelection: 'dismiss',
isExtensionPreInstalled,
},
},
];
const installText = isExtensionPreInstalled
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
return (
<Box
flexDirection="column"
@@ -54,11 +88,9 @@ export function IdeIntegrationNudge({
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`}
{`Do you want to connect ${ideName ?? 'your'} editor to Gemini CLI?`}
</Text>
<Text
dimColor
>{`If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ideName ?? 'your editor'}.`}</Text>
<Text dimColor>{installText}</Text>
</Box>
<RadioButtonSelect
items={OPTIONS}

View File

@@ -138,13 +138,11 @@ export const directoryCommand: SlashCommand = {
if (errors.length > 0) {
addItem(
{
type: MessageType.ERROR,
text: errors.join('\n'),
},
{ type: MessageType.ERROR, text: errors.join('\n') },
Date.now(),
);
}
return;
},
},
{

View File

@@ -40,7 +40,6 @@ describe('ideCommand', () => {
} as unknown as CommandContext;
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
@@ -60,14 +59,12 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if ideModeFeature is not enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(false);
const command = ideCommand(mockConfig);
it('should return null if config is not provided', () => {
const command = ideCommand(null);
expect(command).toBeNull();
});
it('should return the ide command if ideModeFeature is enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
it('should return the ide command', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
@@ -85,7 +82,6 @@ describe('ideCommand', () => {
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
getCurrentIde: () => DetectedIde.VSCode,
@@ -162,7 +158,6 @@ describe('ideCommand', () => {
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,

View File

@@ -9,7 +9,7 @@ import {
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeDisplayName,
getIdeInfo,
getIdeInstaller,
IdeClient,
type File,
@@ -116,7 +116,7 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
if (!config) {
return null;
}
const ideClient = config.getIdeClient();
@@ -133,7 +133,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
.map((ide) => getIdeInfo(ide).displayName)
.join(', ')}`,
}) as const,
};

View File

@@ -881,9 +881,14 @@ describe('mcpCommand', () => {
}),
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
removePromptsByServer: vi.fn(),
}),
},
},
});
// Mock the reloadCommands function
context.ui.reloadCommands = vi.fn();
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
@@ -901,6 +906,7 @@ describe('mcpCommand', () => {
'test-server',
);
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
@@ -985,6 +991,8 @@ describe('mcpCommand', () => {
},
},
});
// Mock the reloadCommands function, which is new logic.
context.ui.reloadCommands = vi.fn();
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
@@ -1002,6 +1010,7 @@ describe('mcpCommand', () => {
);
expect(mockToolRegistry.discoverMcpTools).toHaveBeenCalled();
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {

View File

@@ -417,6 +417,9 @@ const authCommand: SlashCommand = {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return {
type: 'message',
messageType: 'info',
@@ -507,6 +510,9 @@ const refreshCommand: SlashCommand = {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return getMcpStatus(context, false, false, false);
},
};

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { terminalSetupCommand } from './terminalSetupCommand.js';
import * as terminalSetupModule from '../utils/terminalSetup.js';
import { CommandContext } from './types.js';
vi.mock('../utils/terminalSetup.js');
describe('terminalSetupCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should have correct metadata', () => {
expect(terminalSetupCommand.name).toBe('terminal-setup');
expect(terminalSetupCommand.description).toContain('multiline input');
expect(terminalSetupCommand.kind).toBe('built-in');
});
it('should return success message when terminal setup succeeds', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: true,
message: 'Terminal configured successfully',
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Terminal configured successfully',
messageType: 'info',
});
});
it('should append restart message when terminal setup requires restart', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: true,
message: 'Terminal configured successfully',
requiresRestart: true,
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content:
'Terminal configured successfully\n\nPlease restart your terminal for the changes to take effect.',
messageType: 'info',
});
});
it('should return error message when terminal setup fails', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: false,
message: 'Failed to detect terminal',
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Failed to detect terminal',
messageType: 'error',
});
});
it('should handle exceptions from terminal setup', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockRejectedValue(
new Error('Unexpected error'),
);
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Failed to configure terminal: Error: Unexpected error',
messageType: 'error',
});
});
});

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageActionReturn, SlashCommand, CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
/**
* Command to configure terminal keybindings for multiline input support.
*
* This command automatically detects and configures VS Code, Cursor, and Windsurf
* to support Shift+Enter and Ctrl+Enter for multiline input.
*/
export const terminalSetupCommand: SlashCommand = {
name: 'terminal-setup',
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
kind: CommandKind.BUILT_IN,
action: async (): Promise<MessageActionReturn> => {
try {
const result = await terminalSetup();
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
}
return {
type: 'message',
content,
messageType: result.success ? 'info' : 'error',
};
} catch (error) {
return {
type: 'message',
content: `Failed to configure terminal: ${error}`,
messageType: 'error',
};
}
},
};

View File

@@ -61,6 +61,7 @@ export interface CommandContext {
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
};
// Session-specific data
session: {

View File

@@ -4,19 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import {
validateAuthMethod,
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
@@ -108,27 +109,31 @@ export function AuthDialog({
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useInput((_input, key) => {
if (showOpenAIKeyPrompt) {
return;
}
useKeypress(
(key) => {
if (key.escape) {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
if (showOpenAIKeyPrompt) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
onSelect(undefined, SettingScope.User);
}
});
},
{ isActive: true },
);
if (showOpenAIKeyPrompt) {
return (

View File

@@ -5,9 +5,10 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => void;
@@ -18,11 +19,14 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((input, key) => {
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout();
}
});
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout();
}
},
{ isActive: true },
);
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { Text } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
@@ -16,11 +17,14 @@ export const DebugProfiler = () => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
useKeypress(
(key) => {
if (key.ctrl && key.name === 'b') {
setShowNumRenders((prev) => !prev);
}
},
{ isActive: true },
);
if (!showNumRenders) {
return null;

View File

@@ -5,7 +5,7 @@
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
@@ -15,6 +15,7 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
@@ -33,14 +34,17 @@ export function EditorSettingsDialog({
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
useInput((_, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays();
@@ -49,8 +53,8 @@ export function EditorSettingsDialog({
settings.forScope(selectedScope).settings.preferredEditor;
let editorIndex = currentPreference
? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference,
)
(item: EditorDisplay) => item.type === currentPreference,
)
: 0;
if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`);

View File

@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
@@ -18,12 +19,14 @@ describe('FolderTrustDialog', () => {
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
stdin.write('\x1b');
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});
});

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -25,11 +26,14 @@ interface FolderTrustDialogProps {
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{

View File

@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -66,6 +67,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -525,7 +527,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
useKeypress(handleInput, { isActive: true });
useKeypress(handleInput, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
@@ -31,6 +31,7 @@ import {
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -256,107 +257,111 @@ export function SettingsDialog({
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'tab') {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (name === 'up' || name === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (name === 'down' || name === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (name === 'return' || name === 'space') {
items[activeSettingIndex]?.toggle();
} else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (showRestartPrompt && name === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
if (name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
return (
<Box

View File

@@ -5,9 +5,10 @@
*/
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -30,11 +31,14 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => {
const { commands, onConfirm } = request;
useInput((_, key) => {
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {

View File

@@ -5,7 +5,7 @@
*/
import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -16,6 +16,7 @@ import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -111,14 +112,17 @@ export function ThemeDialog({
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(

View File

@@ -5,7 +5,7 @@
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import {
@@ -20,6 +20,7 @@ import {
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -44,7 +45,7 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
@@ -56,12 +57,15 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
useInput((input, key) => {
if (!isFocused) return;
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
@@ -132,7 +136,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedAlways,
},
);
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,

View File

@@ -5,8 +5,9 @@
*/
import React, { useEffect, useState, useRef } from 'react';
import { Text, Box, useInput } from 'ink';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
/**
* Represents a single option for the RadioButtonSelect.
@@ -85,9 +86,10 @@ export function RadioButtonSelect<T>({
[],
);
useInput(
(input, key) => {
const isNumeric = showNumbers && /^[0-9]$/.test(input);
useKeypress(
(key) => {
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
@@ -95,21 +97,21 @@ export function RadioButtonSelect<T>({
setNumberInput('');
}
if (input === 'k' || key.upArrow) {
if (name === 'k' || name === 'up') {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (input === 'j' || key.downArrow) {
if (name === 'j' || name === 'down') {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (key.return) {
if (name === 'return') {
onSelect(items[activeIndex]!.value);
return;
}
@@ -120,7 +122,7 @@ export function RadioButtonSelect<T>({
clearTimeout(numberInputTimer.current);
}
const newNumberInput = numberInput + input;
const newNumberInput = numberInput + sequence;
setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
@@ -154,7 +156,7 @@ export function RadioButtonSelect<T>({
}
}
},
{ isActive: isFocused && items.length > 0 },
{ isActive: !!(isFocused && items.length > 0) },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import stripAnsi from 'strip-ansi';
import { renderHook, act } from '@testing-library/react';
import {
useTextBuffer,
@@ -1278,6 +1279,45 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
);
expect(getBufferState(result).text).toBe('Pasted Text');
});
it('should not strip popular emojis', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const emojis = '🐍🐳🦀🦄';
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: emojis,
}),
);
expect(getBufferState(result).text).toBe(emojis);
});
});
describe('stripAnsi', () => {
it('should correctly strip ANSI escape codes', () => {
const textWithAnsi = '\x1B[31mHello\x1B[0m World';
expect(stripAnsi(textWithAnsi)).toBe('Hello World');
});
it('should handle multiple ANSI codes', () => {
const textWithMultipleAnsi = '\x1B[1m\x1B[34mBold Blue\x1B[0m Text';
expect(stripAnsi(textWithMultipleAnsi)).toBe('Bold Blue Text');
});
it('should not modify text without ANSI codes', () => {
const plainText = 'Plain text';
expect(stripAnsi(plainText)).toBe('Plain text');
});
it('should handle empty string', () => {
expect(stripAnsi('')).toBe('');
});
});
});

View File

@@ -5,6 +5,7 @@
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
@@ -496,21 +497,44 @@ export const replaceRangeInternal = (
/**
* Strip characters that can break terminal rendering.
*
* Strip ANSI escape codes and control characters except for line breaks.
* Control characters such as delete break terminal UI rendering.
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
function stripUnsafeCharacters(str: string): string {
const stripped = stripAnsi(str);
return toCodePoints(stripped)
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
if (char.length > 1) return false;
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
const isUnsafe =
code === 127 || (code <= 31 && code !== 13 && code !== 10);
return !isUnsafe;
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@@ -19,6 +19,7 @@ import {
findWordEndInLine,
} from './text-buffer.js';
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
import { assumeExhaustive } from '../../../utils/checks.js';
// Check if we're at the end of a base word (on the last base character)
// Returns true if current position has a base character followed only by combining marks until non-word
@@ -806,7 +807,7 @@ export function handleVimAction(
default: {
// This should never happen if TypeScript is working correctly
const _exhaustiveCheck: never = action;
assumeExhaustive(action);
return state;
}
}

View File

@@ -4,9 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({
const { logSlashCommand } = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
@@ -15,7 +14,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
return {
...original,
logSlashCommand,
SlashCommandEvent,
getIdeInstaller: vi.fn().mockReturnValue(null),
};
});
@@ -25,10 +23,10 @@ const { mockProcessExit } = vi.hoisted(() => ({
}));
vi.mock('node:process', () => {
const mockProcess = {
const mockProcess: Partial<NodeJS.Process> = {
exit: mockProcessExit,
platform: 'test-platform',
};
platform: 'sunos',
} as unknown as NodeJS.Process;
return {
...mockProcess,
default: mockProcess,
@@ -68,31 +66,37 @@ vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
import {
SlashCommandStatus,
ToolConfirmationOutcome,
makeFakeConfig,
} from '@qwen-code/qwen-code-core';
import { act, renderHook, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { LoadedSettings } from '../../config/settings.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
CommandContext,
CommandKind,
ConfirmShellCommandsActionReturn,
SlashCommand,
} from '../commands/types.js';
import { Config, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
const createTestCommand = (
function createTestCommand(
overrides: Partial<SlashCommand>,
kind: CommandKind = CommandKind.BUILT_IN,
): SlashCommand => ({
name: 'test',
description: 'a test command',
kind,
...overrides,
});
): SlashCommand {
return {
name: 'test',
description: 'a test command',
kind,
...overrides,
};
}
describe('useSlashCommandProcessor', () => {
const mockAddItem = vi.fn();
@@ -102,15 +106,7 @@ describe('useSlashCommandProcessor', () => {
const mockOpenAuthDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = {
getProjectRoot: vi.fn(() => '/mock/cwd'),
getSessionId: vi.fn(() => 'test-session'),
getGeminiClient: vi.fn(() => ({
setHistory: vi.fn().mockResolvedValue(undefined),
})),
getExtensions: vi.fn(() => []),
getIdeMode: vi.fn(() => false),
} as unknown as Config;
const mockConfig = makeFakeConfig({});
const mockSettings = {} as LoadedSettings;
@@ -314,6 +310,39 @@ describe('useSlashCommandProcessor', () => {
);
});
it('sets isProcessing to false if the the input is not a command', async () => {
const setMockIsProcessing = vi.fn();
const result = setupProcessorHook([], [], [], setMockIsProcessing);
await act(async () => {
await result.current.handleSlashCommand('imnotacommand');
});
expect(setMockIsProcessing).not.toHaveBeenCalled();
});
it('sets isProcessing to false if the command has an error', async () => {
const setMockIsProcessing = vi.fn();
const failCommand = createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
});
const result = setupProcessorHook(
[failCommand],
[],
[],
setMockIsProcessing,
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(setMockIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setMockIsProcessing).toHaveBeenNthCalledWith(2, false);
});
it('should set isProcessing to true during execution and false afterwards', async () => {
const mockSetIsProcessing = vi.fn();
const command = createTestCommand({
@@ -329,14 +358,14 @@ describe('useSlashCommandProcessor', () => {
});
// It should be true immediately after starting
expect(mockSetIsProcessing).toHaveBeenCalledWith(true);
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(1, true);
// It should not have been called with false yet
expect(mockSetIsProcessing).not.toHaveBeenCalledWith(false);
await executionPromise;
// After the promise resolves, it should be called with false
expect(mockSetIsProcessing).toHaveBeenCalledWith(false);
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(mockSetIsProcessing).toHaveBeenCalledTimes(2);
});
});
@@ -884,7 +913,9 @@ describe('useSlashCommandProcessor', () => {
const loggingTestCommands: SlashCommand[] = [
createTestCommand({
name: 'logtest',
action: mockCommandAction,
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
@@ -895,6 +926,10 @@ describe('useSlashCommandProcessor', () => {
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
@@ -905,7 +940,6 @@ describe('useSlashCommandProcessor', () => {
beforeEach(() => {
mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear();
vi.mocked(SlashCommandEvent).mockClear();
});
it('should log a simple slash command', async () => {
@@ -917,8 +951,45 @@ describe('useSlashCommandProcessor', () => {
await result.current.handleSlashCommand('/logtest');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined);
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logtest',
subcommand: undefined,
status: SlashCommandStatus.SUCCESS,
}),
);
});
it('logs nothing for a bogus command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/bogusbogusbogus');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
it('logs a failure event for a failed command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'fail',
status: 'error',
subcommand: undefined,
}),
);
});
it('should log a slash command with a subcommand', async () => {
@@ -930,8 +1001,13 @@ describe('useSlashCommandProcessor', () => {
await result.current.handleSlashCommand('/logwithsub sub');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub');
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logwithsub',
subcommand: 'sub',
}),
);
});
it('should log the command path when an alias is used', async () => {
@@ -942,8 +1018,12 @@ describe('useSlashCommandProcessor', () => {
await act(async () => {
await result.current.handleSlashCommand('/la');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined);
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logalias',
}),
);
});
it('should not log for unknown commands', async () => {

View File

@@ -14,7 +14,8 @@ import {
GitService,
Logger,
logSlashCommand,
SlashCommandEvent,
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -57,6 +58,11 @@ export const useSlashCommandProcessor = (
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [reloadTrigger, setReloadTrigger] = useState(0);
const reloadCommands = useCallback(() => {
setReloadTrigger((v) => v + 1);
}, []);
const [shellConfirmationRequest, setShellConfirmationRequest] =
useState<null | {
commands: string[];
@@ -172,6 +178,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
},
session: {
stats: session.stats,
@@ -197,6 +204,7 @@ export const useSlashCommandProcessor = (
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
reloadCommands,
],
);
@@ -222,7 +230,7 @@ export const useSlashCommandProcessor = (
return () => {
controller.abort();
};
}, [config, ideMode]);
}, [config, ideMode, reloadTrigger]);
const handleSlashCommand = useCallback(
async (
@@ -230,77 +238,71 @@ export const useSlashCommandProcessor = (
oneTimeShellAllowlist?: Set<string>,
overwriteConfirmed?: boolean,
): Promise<SlashCommandProcessorResult | false> => {
if (typeof rawQuery !== 'string') {
return false;
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
setIsProcessing(true);
try {
if (typeof rawQuery !== 'string') {
return false;
const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
let hasError = false;
const canonicalPath: string[] = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
// search could be replaced. A more optimal approach would be to
// pre-compute a single lookup map in `CommandService.ts` that resolves
// all name and alias conflicts during the initial loading phase. The
// processor would then perform a single, fast lookup on that map.
// First pass: check for an exact match on the primary command name.
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
// Second pass: if no primary name matches, check for an alias.
if (!foundCommand) {
foundCommand = currentCommands.find((cmd) =>
cmd.altNames?.includes(part),
);
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
const userMessageTimestamp = Date.now();
addItem(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
// search could be replaced. A more optimal approach would be to
// pre-compute a single lookup map in `CommandService.ts` that resolves
// all name and alias conflicts during the initial loading phase. The
// processor would then perform a single, fast lookup on that map.
// First pass: check for an exact match on the primary command name.
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
// Second pass: if no primary name matches, check for an alias.
if (!foundCommand) {
foundCommand = currentCommands.find((cmd) =>
cmd.altNames?.includes(part),
);
}
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const resolvedCommandPath = canonicalPath;
const subcommand =
resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined;
try {
if (commandToExecute) {
const args = parts.slice(pathIndex).join(' ');
if (commandToExecute.action) {
if (config) {
const resolvedCommandPath = canonicalPath;
const event = new SlashCommandEvent(
resolvedCommandPath[0],
resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined,
);
logSlashCommand(config, event);
}
const fullCommandContext: CommandContext = {
...commandContext,
invocation: {
@@ -322,7 +324,6 @@ export const useSlashCommandProcessor = (
]),
};
}
const result = await commandToExecute.action(
fullCommandContext,
args,
@@ -495,8 +496,18 @@ export const useSlashCommandProcessor = (
content: `Unknown command: ${trimmed}`,
timestamp: new Date(),
});
return { type: 'handled' };
} catch (e) {
} catch (e: unknown) {
hasError = true;
if (config) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.ERROR,
});
logSlashCommand(config, event);
}
addItem(
{
type: MessageType.ERROR,
@@ -506,6 +517,14 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config && resolvedCommandPath[0] && !hasError) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.SUCCESS,
});
logSlashCommand(config, event);
}
setIsProcessing(false);
}
},

View File

@@ -21,9 +21,9 @@ import {
Config as ActualConfigType,
ApprovalMode,
} from '@qwen-code/qwen-code-core';
import { useInput, type Key as InkKey } from 'ink';
import { useKeypress, Key } from './useKeypress.js';
vi.mock('ink');
vi.mock('./useKeypress.js');
vi.mock('@qwen-code/qwen-code-core', async () => {
const actualServerModule = (await vi.importActual(
@@ -53,13 +53,12 @@ interface MockConfigInstanceShape {
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
}
type UseInputKey = InkKey;
type UseInputHandler = (input: string, key: UseInputKey) => void;
type UseKeypressHandler = (key: Key) => void;
describe('useAutoAcceptIndicator', () => {
let mockConfigInstance: MockConfigInstanceShape;
let capturedUseInputHandler: UseInputHandler;
let mockedInkUseInput: MockedFunction<typeof useInput>;
let capturedUseKeypressHandler: UseKeypressHandler;
let mockedUseKeypress: MockedFunction<typeof useKeypress>;
beforeEach(() => {
vi.resetAllMocks();
@@ -111,10 +110,12 @@ describe('useAutoAcceptIndicator', () => {
return instance;
});
mockedInkUseInput = useInput as MockedFunction<typeof useInput>;
mockedInkUseInput.mockImplementation((handler: UseInputHandler) => {
capturedUseInputHandler = handler;
});
mockedUseKeypress = useKeypress as MockedFunction<typeof useKeypress>;
mockedUseKeypress.mockImplementation(
(handler: UseKeypressHandler, _options) => {
capturedUseKeypressHandler = handler;
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
@@ -163,7 +164,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
@@ -171,7 +175,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -179,7 +183,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
@@ -187,7 +191,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -195,7 +199,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
@@ -203,7 +210,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
@@ -220,37 +230,51 @@ describe('useAutoAcceptIndicator', () => {
);
act(() => {
capturedUseInputHandler('', { tab: true, shift: false } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('', { tab: false, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'unknown',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { tab: false, shift: false } as InkKey);
capturedUseKeypressHandler({
name: 'a',
shift: false,
ctrl: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('y', { tab: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: false } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'a', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('y', { shift: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'a',
ctrl: true,
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
});

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { useInput } from 'ink';
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
import { useEffect, useState } from 'react';
import { useKeypress } from './useKeypress.js';
export interface UseAutoAcceptIndicatorArgs {
config: Config;
@@ -23,27 +23,30 @@ export function useAutoAcceptIndicator({
setShowAutoAcceptIndicator(currentConfigValue);
}, [currentConfigValue]);
useInput((input, key) => {
let nextApprovalMode: ApprovalMode | undefined;
useKeypress(
(key) => {
let nextApprovalMode: ApprovalMode | undefined;
if (key.ctrl && input === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.tab && key.shift) {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
}
if (key.ctrl && key.name === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.shift && key.name === 'tab') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
}
if (nextApprovalMode) {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
}
});
if (nextApprovalMode) {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
}
},
{ isActive: true },
);
return showAutoAcceptIndicator;
}

View File

@@ -8,12 +8,12 @@ import { useStdin, useStdout } from 'ink';
import { useEffect, useState } from 'react';
// ANSI escape codes to enable/disable terminal focus reporting
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
// ANSI escape codes for focus events
const FOCUS_IN = '\x1b[I';
const FOCUS_OUT = '\x1b[O';
export const FOCUS_IN = '\x1b[I';
export const FOCUS_OUT = '\x1b[O';
export const useFocus = () => {
const { stdin } = useStdin();

View File

@@ -4,15 +4,33 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFolderTrust } from './useFolderTrust.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type Config } from '@google/gemini-cli-core';
import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
LoadedTrustedFolders,
TrustLevel,
} from '../../config/trustedFolders.js';
import * as process from 'process';
import * as trustedFolders from '../../config/trustedFolders.js';
vi.mock('process', () => ({
cwd: vi.fn(),
platform: 'linux',
}));
describe('useFolderTrust', () => {
it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => {
const settings = {
let mockSettings: LoadedSettings;
let mockConfig: Config;
let mockTrustedFolders: LoadedTrustedFolders;
let loadTrustedFoldersSpy: vi.SpyInstance;
beforeEach(() => {
mockSettings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
@@ -20,59 +38,110 @@ describe('useFolderTrust', () => {
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
mockTrustedFolders = {
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders;
loadTrustedFoldersSpy = vi
.spyOn(trustedFolders, 'loadTrustedFolders')
.mockReturnValue(mockTrustedFolders);
(process.cwd as vi.Mock).mockReturnValue('/test/path');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not open dialog when folder is already trusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should not open dialog when folder is already untrusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(false);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should open dialog when folder trust is undefined', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
});
it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => {
const settings = {
merged: {
folderTrustFeature: false,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: true,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
it('should handle TRUST_FOLDER choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
expect(settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'folderTrust',
true,
expect(loadTrustedFoldersSpy).toHaveBeenCalled();
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_FOLDER,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should handle TRUST_PARENT choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_PARENT,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should handle DO_NOT_TRUST choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.DO_NOT_TRUST,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should do nothing for default choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(
'invalid_choice' as FolderTrustChoice,
);
});
expect(mockTrustedFolders.setValue).not.toHaveBeenCalled();
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(result.current.isFolderTrustDialogOpen).toBe(true);
});
});

View File

@@ -5,24 +5,39 @@
*/
import { useState, useCallback } from 'react';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type Config } from '@google/gemini-cli-core';
import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import * as process from 'process';
export const useFolderTrust = (settings: LoadedSettings) => {
export const useFolderTrust = (settings: LoadedSettings, config: Config) => {
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
!!settings.merged.folderTrustFeature &&
// TODO: Update to avoid showing dialog for folders that are trusted.
settings.merged.folderTrust === undefined,
config.isTrustedFolder() === undefined,
);
const handleFolderTrustSelect = useCallback(
(_choice: FolderTrustChoice) => {
// TODO: Store folderPath in the trusted folders config file based on the choice.
settings.setValue(SettingScope.User, 'folderTrust', true);
setIsFolderTrustDialogOpen(false);
},
[settings],
);
const handleFolderTrustSelect = useCallback((choice: FolderTrustChoice) => {
const trustedFolders = loadTrustedFolders();
const cwd = process.cwd();
let trustLevel: TrustLevel;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
break;
case FolderTrustChoice.TRUST_PARENT:
trustLevel = TrustLevel.TRUST_PARENT;
break;
case FolderTrustChoice.DO_NOT_TRUST:
trustLevel = TrustLevel.DO_NOT_TRUST;
break;
default:
return;
}
trustedFolders.setValue(cwd, trustLevel);
setIsFolderTrustDialogOpen(false);
}, []);
return {
isFolderTrustDialogOpen,

View File

@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js';
import { useInput } from 'ink';
import { useKeypress } from './useKeypress.js';
import {
useReactToolScheduler,
TrackedToolCall,
@@ -51,6 +51,7 @@ const MockedGeminiClientClass = vi.hoisted(() =>
const MockedUserPromptEvent = vi.hoisted(() =>
vi.fn().mockImplementation(() => {}),
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
@@ -59,6 +60,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
GitService: vi.fn(),
GeminiClient: MockedGeminiClientClass,
UserPromptEvent: MockedUserPromptEvent,
parseAndFormatApiError: mockParseAndFormatApiError,
};
});
@@ -71,10 +73,9 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
};
});
vi.mock('ink', async (importOriginal) => {
const actualInkModule = (await importOriginal()) as any;
return { ...(actualInkModule || {}), useInput: vi.fn() };
});
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
@@ -128,11 +129,6 @@ vi.mock('./slashCommandProcessor.js', () => ({
handleSlashCommand: vi.fn().mockReturnValue(false),
}));
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
vi.mock('../utils/errorParsing.js', () => ({
parseAndFormatApiError: mockParseAndFormatApiError,
}));
// --- END MOCKS ---
describe('mergePartListUnions', () => {
@@ -903,19 +899,23 @@ describe('useGeminiStream', () => {
});
describe('User Cancellation', () => {
let useInputCallback: (input: string, key: any) => void;
const mockUseInput = useInput as Mock;
let keypressCallback: (key: any) => void;
const mockUseKeypress = useKeypress as Mock;
beforeEach(() => {
// Capture the callback passed to useInput
mockUseInput.mockImplementation((callback) => {
useInputCallback = callback;
// Capture the callback passed to useKeypress
mockUseKeypress.mockImplementation((callback, options) => {
if (options.isActive) {
keypressCallback = callback;
} else {
keypressCallback = () => {};
}
});
});
const simulateEscapeKeyPress = () => {
act(() => {
useInputCallback('', { escape: true });
keypressCallback({ name: 'escape' });
});
};

View File

@@ -4,57 +4,57 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useInput } from 'ink';
import {
Config,
GeminiClient,
GeminiEventType as ServerGeminiEventType,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiErrorEvent as ErrorEvent,
ServerGeminiChatCompressedEvent,
ServerGeminiFinishedEvent,
getErrorMessage,
isNodeError,
MessageSenderType,
ToolCallRequestInfo,
logUserPrompt,
GitService,
EditorType,
ThoughtSummary,
UnauthorizedError,
UserPromptEvent,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@qwen-code/qwen-code-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import {
StreamingState,
Config,
ServerGeminiContentEvent as ContentEvent,
DEFAULT_GEMINI_FLASH_MODEL,
EditorType,
ServerGeminiErrorEvent as ErrorEvent,
GeminiClient,
ServerGeminiStreamEvent as GeminiEvent,
getErrorMessage,
GitService,
isNodeError,
logUserPrompt,
MessageSenderType,
parseAndFormatApiError,
ServerGeminiChatCompressedEvent,
GeminiEventType as ServerGeminiEventType,
ServerGeminiFinishedEvent,
ThoughtSummary,
ToolCallRequestInfo,
UnauthorizedError,
UserPromptEvent,
} from '@qwen-code/qwen-code-core';
import { promises as fs } from 'fs';
import path from 'path';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
HistoryItem,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemWithoutId,
MessageType,
SlashCommandProcessorResult,
StreamingState,
ToolCallStatus,
} from '../types.js';
import { isAtCommand } from '../utils/commandUtils.js';
import { parseAndFormatApiError } from '../utils/errorParsing.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useKeypress } from './useKeypress.js';
import { useLogger } from './useLogger.js';
import { promises as fs } from 'fs';
import path from 'path';
import {
useReactToolScheduler,
mapToDisplay as mapTrackedToolCallsToDisplay,
TrackedToolCall,
TrackedCompletedToolCall,
TrackedCancelledToolCall,
TrackedCompletedToolCall,
TrackedToolCall,
useReactToolScheduler,
} from './useReactToolScheduler.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js';
export function mergePartListUnions(list: PartListUnion[]): PartListUnion {
const resultParts: PartListUnion = [];
@@ -214,11 +214,14 @@ export const useGeminiStream = (
pendingHistoryItemRef,
]);
useInput((_input, key) => {
if (key.escape) {
cancelOngoingRequest();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
cancelOngoingRequest();
}
},
{ isActive: streamingState === StreamingState.Responding },
);
const prepareQueryForGemini = useCallback(
async (

View File

@@ -134,9 +134,14 @@ describe('useKeypress', () => {
expect(onKeypress).not.toHaveBeenCalled();
});
it('should listen for keypress when active', () => {
it.each([
{ key: { name: 'a', sequence: 'a' } },
{ key: { name: 'left', sequence: '\x1b[D' } },
{ key: { name: 'right', sequence: '\x1b[C' } },
{ key: { name: 'up', sequence: '\x1b[A' } },
{ key: { name: 'down', sequence: '\x1b[B' } },
])('should listen for keypress when active for key $key.name', ({ key }) => {
renderHook(() => useKeypress(onKeypress, { isActive: true }));
const key = { name: 'a', sequence: 'a' };
act(() => stdin.pressKey(key));
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
});
@@ -187,7 +192,7 @@ describe('useKeypress', () => {
},
isLegacy: true,
},
])('Paste Handling in $description', ({ setup, isLegacy }) => {
])('in $description', ({ setup, isLegacy }) => {
beforeEach(() => {
setup();
stdin.setLegacy(isLegacy);

View File

@@ -8,6 +8,21 @@ import { useEffect, useRef } from 'react';
import { useStdin } from 'ink';
import readline from 'readline';
import { PassThrough } from 'stream';
import {
KITTY_CTRL_C,
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
MAX_KITTY_SEQUENCE_LENGTH,
} from '../utils/platformConstants.js';
import {
KittySequenceOverflowEvent,
logKittySequenceOverflow,
Config,
} from '@google/gemini-cli-core';
import { FOCUS_IN, FOCUS_OUT } from './useFocus.js';
const ESC = '\u001B';
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
export interface Key {
name: string;
@@ -16,6 +31,7 @@ export interface Key {
shift: boolean;
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
}
/**
@@ -30,10 +46,16 @@ export interface Key {
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
* @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled.
* @param options.config - Optional config for telemetry logging.
*/
export function useKeypress(
onKeypress: (key: Key) => void,
{ isActive }: { isActive: boolean },
{
isActive,
kittyProtocolEnabled = false,
config,
}: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config },
) {
const { stdin, setRawMode } = useStdin();
const onKeypressRef = useRef(onKeypress);
@@ -64,8 +86,210 @@ export function useKeypress(
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
let kittySequenceBuffer = '';
let backslashTimeout: NodeJS.Timeout | null = null;
let waitingForEnterAfterBackslash = false;
// Parse Kitty protocol sequences
const parseKittySequence = (sequence: string): Key | null => {
// Match CSI <number> ; <modifiers> u or ~
// Format: ESC [ <keycode> ; <modifiers> u/~
const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
const match = sequence.match(kittyPattern);
if (!match) return null;
const keyCode = parseInt(match[1], 10);
const modifiers = match[3] ? parseInt(match[3], 10) : 1;
// Decode modifiers (subtract 1 as per Kitty protocol spec)
const modifierBits = modifiers - 1;
const shift = (modifierBits & 1) === 1;
const alt = (modifierBits & 2) === 2;
const ctrl = (modifierBits & 4) === 4;
// Handle Escape key (code 27)
if (keyCode === 27) {
return {
name: 'escape',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle Enter key (code 13)
if (keyCode === 13) {
return {
name: 'return',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle Ctrl+letter combinations (a-z)
// ASCII codes: a=97, b=98, c=99, ..., z=122
if (keyCode >= 97 && keyCode <= 122 && ctrl) {
const letter = String.fromCharCode(keyCode);
return {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle other keys as needed
return null;
};
const handleKeypress = (_: unknown, key: Key) => {
// Handle VS Code's backslash+return pattern (Shift+Enter)
if (key.name === 'return' && waitingForEnterAfterBackslash) {
// Cancel the timeout since we got the Enter
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
// Convert to Shift+Enter
onKeypressRef.current({
...key,
shift: true,
sequence: '\\\r', // VS Code's Shift+Enter representation
});
return;
}
// Handle backslash - hold it to see if Enter follows
if (key.sequence === '\\' && !key.name) {
// Don't pass through the backslash yet - wait to see if Enter follows
waitingForEnterAfterBackslash = true;
// Set up a timeout to pass through the backslash if no Enter follows
backslashTimeout = setTimeout(() => {
waitingForEnterAfterBackslash = false;
backslashTimeout = null;
// Pass through the backslash since no Enter followed
onKeypressRef.current(key);
}, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
return;
}
// If we're waiting for Enter after backslash but got something else,
// pass through the backslash first, then the new key
if (waitingForEnterAfterBackslash && key.name !== 'return') {
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
// Pass through the backslash that was held
onKeypressRef.current({
name: '',
sequence: '\\',
ctrl: false,
meta: false,
shift: false,
paste: false,
});
// Then continue processing the current key normally
}
// If readline has already identified an arrow key, pass it through
// immediately, bypassing the Kitty protocol sequence buffering.
if (['up', 'down', 'left', 'right'].includes(key.name)) {
onKeypressRef.current(key);
return;
}
// Always pass through Ctrl+C immediately, regardless of protocol state
// Check both standard format and Kitty protocol sequence
if (
(key.ctrl && key.name === 'c') ||
key.sequence === `${ESC}${KITTY_CTRL_C}`
) {
kittySequenceBuffer = '';
// If it's the Kitty sequence, create a proper key object
if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
onKeypressRef.current({
name: 'c',
ctrl: true,
meta: false,
shift: false,
paste: false,
sequence: key.sequence,
kittyProtocol: true,
});
} else {
onKeypressRef.current(key);
}
return;
}
// If Kitty protocol is enabled, handle CSI sequences
if (kittyProtocolEnabled) {
// If we have a buffer or this starts a CSI sequence
if (
kittySequenceBuffer ||
(key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
!key.sequence.startsWith(FOCUS_OUT))
) {
kittySequenceBuffer += key.sequence;
// Try to parse the buffer as a Kitty sequence
const kittyKey = parseKittySequence(kittySequenceBuffer);
if (kittyKey) {
kittySequenceBuffer = '';
onKeypressRef.current(kittyKey);
return;
}
if (config?.getDebugMode()) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
);
// Unless the user is sshing over a slow connection, this likely
// indicates this is not a kitty sequence but we have incorrectly
// interpreted it as such. See the examples above for sequences
// such as FOCUS_IN that are not Kitty sequences.
console.warn('Kitty sequence buffer has char codes:', codes);
}
// If buffer doesn't match expected pattern and is getting long, flush it
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
// Log telemetry for buffer overflow
if (config) {
const event = new KittySequenceOverflowEvent(
kittySequenceBuffer.length,
kittySequenceBuffer,
);
logKittySequenceOverflow(config, event);
}
// Not a Kitty sequence, treat as regular key
kittySequenceBuffer = '';
} else {
// Wait for more characters
return;
}
}
}
if (key.name === 'paste-start') {
isPaste = true;
} else if (key.name === 'paste-end') {
@@ -84,7 +308,7 @@ export function useKeypress(
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
} else {
// Handle special keys
if (key.name === 'return' && key.sequence === '\x1B\r') {
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
key.meta = true;
}
onKeypressRef.current({ ...key, paste: isPaste });
@@ -93,13 +317,13 @@ export function useKeypress(
};
const handleRawKeypress = (data: Buffer) => {
const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~');
const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~');
const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
let pos = 0;
while (pos < data.length) {
const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos);
const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos);
const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
// Determine which marker comes first, if any.
const isPrefixNext =
@@ -115,7 +339,7 @@ export function useKeypress(
} else if (isSuffixNext) {
nextMarkerPos = suffixPos;
}
markerLength = PASTE_MODE_SUFFIX.length;
markerLength = pasteModeSuffixBuffer.length;
if (nextMarkerPos === -1) {
keypressStream.write(data.slice(pos));
@@ -170,6 +394,12 @@ export function useKeypress(
rl.close();
setRawMode(false);
// Clean up any pending backslash timeout
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
// If we are in the middle of a paste, send what we have.
if (isPaste) {
onKeypressRef.current({
@@ -183,5 +413,5 @@ export function useKeypress(
pasteBuffer = Buffer.alloc(0);
}
};
}, [isActive, stdin, setRawMode]);
}, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]);
}

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import {
isKittyProtocolEnabled,
isKittyProtocolSupported,
} from '../utils/kittyProtocolDetector.js';
export interface KittyProtocolStatus {
supported: boolean;
enabled: boolean;
checking: boolean;
}
/**
* Hook that returns the cached Kitty keyboard protocol status.
* Detection is done once at app startup to avoid repeated queries.
*/
export function useKittyKeyboardProtocol(): KittyProtocolStatus {
const [status] = useState<KittyProtocolStatus>({
supported: isKittyProtocolSupported(),
enabled: isKittyProtocolEnabled(),
checking: false,
});
return status;
}

View File

@@ -23,7 +23,7 @@ import {
ToolCall, // Import from core
Status as ToolCallStatusType,
ApprovalMode,
Icon,
Kind,
BaseTool,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -67,7 +67,7 @@ class MockTool extends BaseTool<object, ToolResult> {
name,
displayName,
'A mock tool for testing',
Icon.Hammer,
Kind.Other,
{},
isOutputMarkdown,
canUpdateOutput,

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { usePrivacySettings } from '../hooks/usePrivacySettings.js';
import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js';
import { Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudFreePrivacyNoticeProps {
config: Config;
@@ -23,11 +24,14 @@ export const CloudFreePrivacyNotice = ({
const { privacyState, updateDataCollectionOptIn } =
usePrivacySettings(config);
useInput((input, key) => {
if (privacyState.error && key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (privacyState.error && key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
if (privacyState.isLoading) {
return <Text color={Colors.Gray}>Loading...</Text>;

View File

@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudPaidPrivacyNoticeProps {
onExit: () => void;
@@ -14,11 +15,14 @@ interface CloudPaidPrivacyNoticeProps {
export const CloudPaidPrivacyNotice = ({
onExit,
}: CloudPaidPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column" marginBottom={1}>

View File

@@ -4,19 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface GeminiPrivacyNoticeProps {
onExit: () => void;
}
export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column" marginBottom={1}>

View File

@@ -1,378 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseAndFormatApiError } from './errorParsing.js';
import {
AuthType,
UserTierId,
DEFAULT_GEMINI_FLASH_MODEL,
isProQuotaExceededError,
} from '@qwen-code/qwen-code-core';
describe('parseAndFormatApiError', () => {
const _enterpriseMessage =
'upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits';
const vertexMessage = 'request a quota increase through Vertex';
const geminiMessage = 'request a quota increase through AI Studio';
it('should format a valid API error JSON', () => {
const errorMessage =
'got status: 400 Bad Request. {"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}';
const expected =
'[API Error: API key not valid. Please pass a valid API key. (Status: INVALID_ARGUMENT)]';
expect(parseAndFormatApiError(errorMessage)).toBe(expected);
});
it('should format a 429 API error with the default message', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
undefined,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain('[API Error: Rate limit exceeded');
expect(result).toContain(
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
);
});
it('should format a 429 API error with the personal message', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain('[API Error: Rate limit exceeded');
expect(result).toContain(
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
);
});
it('should format a 429 API error with the vertex message', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(errorMessage, AuthType.USE_VERTEX_AI);
expect(result).toContain('[API Error: Rate limit exceeded');
expect(result).toContain(vertexMessage);
});
it('should return the original message if it is not a JSON error', () => {
const errorMessage = 'This is a plain old error message';
expect(parseAndFormatApiError(errorMessage)).toBe(
`[API Error: ${errorMessage}]`,
);
});
it('should return the original message for malformed JSON', () => {
const errorMessage = '[Stream Error: {"error": "malformed}';
expect(parseAndFormatApiError(errorMessage)).toBe(
`[API Error: ${errorMessage}]`,
);
});
it('should handle JSON that does not match the ApiError structure', () => {
const errorMessage = '[Stream Error: {"not_an_error": "some other json"}]';
expect(parseAndFormatApiError(errorMessage)).toBe(
`[API Error: ${errorMessage}]`,
);
});
it('should format a nested API error', () => {
const nestedErrorMessage = JSON.stringify({
error: {
code: 429,
message:
"Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.",
status: 'RESOURCE_EXHAUSTED',
},
});
const errorMessage = JSON.stringify({
error: {
code: 429,
message: nestedErrorMessage,
status: 'Too Many Requests',
},
});
const result = parseAndFormatApiError(errorMessage, AuthType.USE_GEMINI);
expect(result).toContain('Gemini 2.5 Pro Preview');
expect(result).toContain(geminiMessage);
});
it('should format a StructuredError', () => {
const error: StructuredError = {
message: 'A structured error occurred',
status: 500,
};
const expected = '[API Error: A structured error occurred]';
expect(parseAndFormatApiError(error)).toBe(expected);
});
it('should format a 429 StructuredError with the vertex message', () => {
const error: StructuredError = {
message: 'Rate limit exceeded',
status: 429,
};
const result = parseAndFormatApiError(error, AuthType.USE_VERTEX_AI);
expect(result).toContain('[API Error: Rate limit exceeded]');
expect(result).toContain(vertexMessage);
});
it('should handle an unknown error type', () => {
const error = 12345;
const expected = '[API Error: An unknown error occurred.]';
expect(parseAndFormatApiError(error)).toBe(expected);
});
it('should format a 429 API error with Pro quota exceeded message for Google auth (Free tier)', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
);
expect(result).toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
expect(result).toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
it('should format a regular 429 API error with standard message for Google auth', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain('[API Error: Rate limit exceeded');
expect(result).toContain(
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
);
expect(result).not.toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
});
it('should format a 429 API error with generic quota exceeded message for Google auth', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'GenerationRequests'",
);
expect(result).toContain('You have reached your daily quota limit');
expect(result).not.toContain(
'You have reached your daily Gemini 2.5 Pro quota limit',
);
});
it('should prioritize Pro quota message over generic quota message for Google auth', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
);
expect(result).toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
expect(result).not.toContain('You have reached your daily quota limit');
});
it('should format a 429 API error with Pro quota exceeded message for Google auth (Standard tier)', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
UserTierId.STANDARD,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
);
expect(result).toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
expect(result).toContain(
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
);
expect(result).not.toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
it('should format a 429 API error with Pro quota exceeded message for Google auth (Legacy tier)', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
UserTierId.LEGACY,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
);
expect(result).toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
expect(result).toContain(
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
);
expect(result).not.toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
it('should handle different Gemini 2.5 version strings in Pro quota exceeded errors', () => {
const errorMessage25 =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const errorMessagePreview =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5-preview Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result25 = parseAndFormatApiError(
errorMessage25,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
const resultPreview = parseAndFormatApiError(
errorMessagePreview,
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'gemini-2.5-preview-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result25).toContain(
'You have reached your daily gemini-2.5-pro quota limit',
);
expect(resultPreview).toContain(
'You have reached your daily gemini-2.5-preview-pro quota limit',
);
expect(result25).toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
expect(resultPreview).toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
it('should not match non-Pro models with similar version strings', () => {
// Test that Flash models with similar version strings don't match
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'Gemini 2.5 Flash Requests' and limit",
),
).toBe(false);
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'Gemini 2.5-preview Flash Requests' and limit",
),
).toBe(false);
// Test other model types
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'Gemini 2.5 Ultra Requests' and limit",
),
).toBe(false);
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'Gemini 2.5 Standard Requests' and limit",
),
).toBe(false);
// Test generic quota messages
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'GenerationRequests' and limit",
),
).toBe(false);
expect(
isProQuotaExceededError(
"Quota exceeded for quota metric 'EmbeddingRequests' and limit",
),
).toBe(false);
});
it('should format a generic quota exceeded message for Google auth (Standard tier)', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
UserTierId.STANDARD,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain(
"[API Error: Quota exceeded for quota metric 'GenerationRequests'",
);
expect(result).toContain('You have reached your daily quota limit');
expect(result).toContain(
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
);
expect(result).not.toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
it('should format a regular 429 API error with standard message for Google auth (Standard tier)', () => {
const errorMessage =
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
const result = parseAndFormatApiError(
errorMessage,
AuthType.LOGIN_WITH_GOOGLE,
UserTierId.STANDARD,
'gemini-2.5-pro',
DEFAULT_GEMINI_FLASH_MODEL,
);
expect(result).toContain('[API Error: Rate limit exceeded');
expect(result).toContain(
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
);
expect(result).not.toContain(
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
);
});
});

View File

@@ -1,164 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
UserTierId,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
isProQuotaExceededError,
isGenericQuotaExceededError,
isApiError,
isStructuredError,
} from '@qwen-code/qwen-code-core';
// Free Tier message functions
const getRateLimitErrorMessageGoogleFree = (
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
) =>
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`;
const getRateLimitErrorMessageGoogleProQuotaFree = (
currentModel: string = DEFAULT_GEMINI_MODEL,
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
) =>
`\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
const getRateLimitErrorMessageGoogleGenericQuotaFree = () =>
`\nYou have reached your daily quota limit. To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
// Legacy/Standard Tier message functions
const getRateLimitErrorMessageGooglePaid = (
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
) =>
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI.`;
const getRateLimitErrorMessageGoogleProQuotaPaid = (
currentModel: string = DEFAULT_GEMINI_MODEL,
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
) =>
`\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
const getRateLimitErrorMessageGoogleGenericQuotaPaid = (
currentModel: string = DEFAULT_GEMINI_MODEL,
) =>
`\nYou have reached your daily quota limit. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
const RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI =
'\nPlease wait and try again later. To increase your limits, request a quota increase through AI Studio, or switch to another /auth method';
const RATE_LIMIT_ERROR_MESSAGE_VERTEX =
'\nPlease wait and try again later. To increase your limits, request a quota increase through Vertex, or switch to another /auth method';
const getRateLimitErrorMessageDefault = (
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
) =>
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`;
function getRateLimitMessage(
authType?: AuthType,
error?: unknown,
userTier?: UserTierId,
currentModel?: string,
fallbackModel?: string,
): string {
switch (authType) {
case AuthType.LOGIN_WITH_GOOGLE: {
// Determine if user is on a paid tier (Legacy or Standard) - default to FREE if not specified
const isPaidTier =
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
if (isProQuotaExceededError(error)) {
return isPaidTier
? getRateLimitErrorMessageGoogleProQuotaPaid(
currentModel || DEFAULT_GEMINI_MODEL,
fallbackModel,
)
: getRateLimitErrorMessageGoogleProQuotaFree(
currentModel || DEFAULT_GEMINI_MODEL,
fallbackModel,
);
} else if (isGenericQuotaExceededError(error)) {
return isPaidTier
? getRateLimitErrorMessageGoogleGenericQuotaPaid(
currentModel || DEFAULT_GEMINI_MODEL,
)
: getRateLimitErrorMessageGoogleGenericQuotaFree();
} else {
return isPaidTier
? getRateLimitErrorMessageGooglePaid(fallbackModel)
: getRateLimitErrorMessageGoogleFree(fallbackModel);
}
}
case AuthType.USE_GEMINI:
return RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI;
case AuthType.USE_VERTEX_AI:
return RATE_LIMIT_ERROR_MESSAGE_VERTEX;
default:
return getRateLimitErrorMessageDefault(fallbackModel);
}
}
export function parseAndFormatApiError(
error: unknown,
authType?: AuthType,
userTier?: UserTierId,
currentModel?: string,
fallbackModel?: string,
): string {
if (isStructuredError(error)) {
let text = `[API Error: ${error.message}]`;
if (error.status === 429) {
text += getRateLimitMessage(
authType,
error,
userTier,
currentModel,
fallbackModel,
);
}
return text;
}
// The error message might be a string containing a JSON object.
if (typeof error === 'string') {
const jsonStart = error.indexOf('{');
if (jsonStart === -1) {
return `[API Error: ${error}]`; // Not a JSON error, return as is.
}
const jsonString = error.substring(jsonStart);
try {
const parsedError = JSON.parse(jsonString) as unknown;
if (isApiError(parsedError)) {
let finalMessage = parsedError.error.message;
try {
// See if the message is a stringified JSON with another error
const nestedError = JSON.parse(finalMessage) as unknown;
if (isApiError(nestedError)) {
finalMessage = nestedError.error.message;
}
} catch (_e) {
// It's not a nested JSON error, so we just use the message as is.
}
let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`;
if (parsedError.error.code === 429) {
text += getRateLimitMessage(
authType,
parsedError,
userTier,
currentModel,
fallbackModel,
);
}
return text;
}
} catch (_e) {
// Not a valid JSON, fall through and return the original message.
}
return `[API Error: ${error}]`;
}
return '[API Error: An unknown error occurred.]';
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
let detectionComplete = false;
let protocolSupported = false;
let protocolEnabled = false;
/**
* Detects Kitty keyboard protocol support.
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
* This function should be called once at app startup.
*/
export async function detectAndEnableKittyProtocol(): Promise<boolean> {
if (detectionComplete) {
return protocolSupported;
}
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
detectionComplete = true;
resolve(false);
return;
}
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
process.stdin.setRawMode(true);
}
let responseBuffer = '';
let progressiveEnhancementReceived = false;
let checkFinished = false;
const handleData = (data: Buffer) => {
responseBuffer += data.toString();
// Check for progressive enhancement response (CSI ? <flags> u)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
progressiveEnhancementReceived = true;
}
// Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
if (!checkFinished) {
checkFinished = true;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) {
// Enable the protocol
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
}
detectionComplete = true;
resolve(protocolSupported);
}
}
};
process.stdin.on('data', handleData);
// Send queries
process.stdout.write('\x1b[?u'); // Query progressive enhancement
process.stdout.write('\x1b[c'); // Query device attributes
// Timeout after 50ms
setTimeout(() => {
if (!checkFinished) {
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}
}, 50);
});
}
function disableProtocol() {
if (protocolEnabled) {
process.stdout.write('\x1b[<u');
protocolEnabled = false;
}
}
export function isKittyProtocolEnabled(): boolean {
return protocolEnabled;
}
export function isKittyProtocolSupported(): boolean {
return protocolSupported;
}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal Platform Constants
*
* This file contains terminal-related constants used throughout the application,
* specifically for handling keyboard inputs and terminal protocols.
*/
/**
* Kitty keyboard protocol sequences for enhanced keyboard input.
* @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/
*/
export const KITTY_CTRL_C = '[99;5u';
/**
* Timing constants for terminal interactions
*/
export const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
/**
* VS Code terminal integration constants
*/
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
/**
* Backslash + Enter detection window in milliseconds.
* Used to detect Shift+Enter pattern where backslash
* is followed by Enter within this timeframe.
*/
export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5;
/**
* Maximum expected length of a Kitty keyboard protocol sequence.
* Format: ESC [ <keycode> ; <modifiers> u/~
* Example: \x1b[13;2u (Shift+Enter) = 8 chars
* Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers)
* We use 12 to provide a small buffer.
*/
export const MAX_KITTY_SEQUENCE_LENGTH = 12;

View File

@@ -0,0 +1,340 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal setup utility for configuring Shift+Enter and Ctrl+Enter support.
*
* This module provides automatic detection and configuration of various terminal
* emulators to support multiline input through modified Enter keys.
*
* Supported terminals:
* - VS Code: Configures keybindings.json to send \\\r\n
* - Cursor: Configures keybindings.json to send \\\r\n (VS Code fork)
* - Windsurf: Configures keybindings.json to send \\\r\n (VS Code fork)
*
* For VS Code and its forks:
* - Shift+Enter: Sends \\\r\n (backslash followed by CRLF)
* - Ctrl+Enter: Sends \\\r\n (backslash followed by CRLF)
*
* The module will not modify existing shift+enter or ctrl+enter keybindings
* to avoid conflicts with user customizations.
*/
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';
const execAsync = promisify(exec);
/**
* Removes single-line JSON comments (// ...) from a string to allow parsing
* VS Code style JSON files that may contain comments.
*/
function stripJsonComments(content: string): string {
// Remove single-line comments (// ...)
return content.replace(/^\s*\/\/.*$/gm, '');
}
export interface TerminalSetupResult {
success: boolean;
message: string;
requiresRestart?: boolean;
}
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
const termProgram = process.env.TERM_PROGRAM;
// Check VS Code and its forks - check forks first to avoid false positives
// Check for Cursor-specific indicators
if (
process.env.CURSOR_TRACE_ID ||
process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('cursor')
) {
return 'cursor';
}
// Check for Windsurf-specific indicators
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('windsurf')) {
return 'windsurf';
}
// Check VS Code last since forks may also set VSCODE env vars
if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) {
return 'vscode';
}
// Check parent process name
if (os.platform() !== 'win32') {
try {
const { stdout } = await execAsync('ps -o comm= -p $PPID');
const parentName = stdout.trim();
// Check forks before VS Code to avoid false positives
if (parentName.includes('windsurf') || parentName.includes('Windsurf'))
return 'windsurf';
if (parentName.includes('cursor') || parentName.includes('Cursor'))
return 'cursor';
if (parentName.includes('code') || parentName.includes('Code'))
return 'vscode';
} catch (error) {
// Continue detection even if process check fails
console.debug('Parent process detection failed:', error);
}
}
return null;
}
// Backup file helper
async function backupFile(filePath: string): Promise<void> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${filePath}.backup.${timestamp}`;
await fs.copyFile(filePath, backupPath);
} catch (error) {
// Log backup errors but continue with operation
console.warn(`Failed to create backup of ${filePath}:`, error);
}
}
// Helper function to get VS Code-style config directory
function getVSCodeStyleConfigDir(appName: string): string | null {
const platform = os.platform();
if (platform === 'darwin') {
return path.join(
os.homedir(),
'Library',
'Application Support',
appName,
'User',
);
} else if (platform === 'win32') {
if (!process.env.APPDATA) {
return null;
}
return path.join(process.env.APPDATA, appName, 'User');
} else {
return path.join(os.homedir(), '.config', appName, 'User');
}
}
// Generic VS Code-style terminal configuration
async function configureVSCodeStyle(
terminalName: string,
appName: string,
): Promise<TerminalSetupResult> {
const configDir = getVSCodeStyleConfigDir(appName);
if (!configDir) {
return {
success: false,
message: `Could not determine ${terminalName} config path on Windows: APPDATA environment variable is not set.`,
};
}
const keybindingsFile = path.join(configDir, 'keybindings.json');
try {
await fs.mkdir(configDir, { recursive: true });
let keybindings: unknown[] = [];
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
await backupFile(keybindingsFile);
try {
const cleanContent = stripJsonComments(content);
const parsedContent = JSON.parse(cleanContent);
if (!Array.isArray(parsedContent)) {
return {
success: false,
message:
`${terminalName} keybindings.json exists but is not a valid JSON array. ` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}`,
};
}
keybindings = parsedContent;
} catch (parseError) {
return {
success: false,
message:
`Failed to parse ${terminalName} keybindings.json. The file contains invalid JSON.\n` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}\n` +
`Error: ${parseError}`,
};
}
} catch {
// File doesn't exist, will create new one
}
const shiftEnterBinding = {
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
const ctrlEnterBinding = {
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
// Check if ANY shift+enter or ctrl+enter bindings already exist
const existingShiftEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'shift+enter';
});
const existingCtrlEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'ctrl+enter';
});
if (existingShiftEnter || existingCtrlEnter) {
const messages: string[] = [];
if (existingShiftEnter) {
messages.push(`- Shift+Enter binding already exists`);
}
if (existingCtrlEnter) {
messages.push(`- Ctrl+Enter binding already exists`);
}
return {
success: false,
message:
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
messages.join('\n') +
'\n' +
`Please check and modify manually if needed: ${keybindingsFile}`,
};
}
// Check if our specific bindings already exist
const hasOurShiftEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'shift+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
const hasOurCtrlEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'ctrl+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
if (!hasOurShiftEnter || !hasOurCtrlEnter) {
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
return {
success: true,
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
requiresRestart: true,
};
} else {
return {
success: true,
message: `${terminalName} keybindings already configured.`,
};
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${terminalName}.\nFile: ${keybindingsFile}\nError: ${error}`,
};
}
}
// Terminal-specific configuration functions
async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
/**
* Main terminal setup function that detects and configures the current terminal.
*
* This function:
* 1. Detects the current terminal emulator
* 2. Applies appropriate configuration for Shift+Enter and Ctrl+Enter support
* 3. Creates backups of configuration files before modifying them
*
* @returns Promise<TerminalSetupResult> Result object with success status and message
*
* @example
* const result = await terminalSetup();
* if (result.success) {
* console.log(result.message);
* if (result.requiresRestart) {
* console.log('Please restart your terminal');
* }
* }
*/
export async function terminalSetup(): Promise<TerminalSetupResult> {
// Check if terminal already has optimal keyboard support
if (isKittyProtocolEnabled()) {
return {
success: true,
message:
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
};
}
const terminal = await detectTerminal();
if (!terminal) {
return {
success: false,
message:
'Could not detect terminal type. Supported terminals: VS Code, Cursor, and Windsurf.',
};
}
switch (terminal) {
case 'vscode':
return configureVSCode();
case 'cursor':
return configureCursor();
case 'windsurf':
return configureWindsurf();
default:
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
}
}