Merge branch 'main' into restart-resume

This commit is contained in:
Jack Wotherspoon
2026-03-11 09:16:30 +01:00
committed by GitHub
239 changed files with 12000 additions and 4065 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260304.28af4e127",
"version": "0.35.0-nightly.20260311.657f19c1f",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,12 +26,12 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"ansi-escapes": "^7.3.0",
@@ -4,7 +4,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
@@ -12,7 +12,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
@@ -23,7 +23,7 @@ exports[`runNonInteractive > should emit appropriate events for streaming JSON o
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
+12
View File
@@ -7,6 +7,7 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -33,6 +34,7 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
type HookDefinition,
@@ -488,6 +490,15 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -764,6 +775,7 @@ export async function loadCliConfig(
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
+3 -2
View File
@@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader {
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
requestConsentOverride?: (consent: string) => Promise<boolean>,
): Promise<GeminiCLIExtension> {
if (
this.settings.security?.allowedExtensions &&
@@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader {
(result.failureReason === 'no release data' &&
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await this.requestConsent(
(await (requestConsentOverride ?? this.requestConsent)(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
Would you like to attempt to install via "git clone" instead?`,
@@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`,
await maybeRequestConsentOrFail(
newExtensionConfig,
this.requestConsent,
requestConsentOverride ?? this.requestConsent,
newHasHooks,
previousExtensionConfig,
previousHasHooks,
@@ -13,14 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
fetchWithTimeout: vi.fn(),
};
});
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
const mockExtensions: RegistryExtension[] = [
@@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
expect(ids).not.toContain('dataplex');
expect(ids).toContain('conductor');
});
it('should fetch extensions from a local file path', async () => {
const filePath = '/path/to/extensions.json';
const clientWithFile = new ExtensionRegistryClient(filePath);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFile.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(filePath),
'utf-8',
);
});
it('should fetch extensions from a file:// URL', async () => {
const fileUrl = 'file:///path/to/extensions.json';
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));
const result = await clientWithFileUrl.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(fileUrl),
'utf-8',
);
});
});
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import {
fetchWithTimeout,
resolveToRealPath,
isPrivateIp,
} from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';
export interface RegistryExtension {
@@ -29,12 +34,19 @@ export interface RegistryExtension {
}
export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
static readonly DEFAULT_REGISTRY_URL =
'https://geminicli.com/extensions.json';
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
private readonly registryURI: string;
constructor(registryURI?: string) {
this.registryURI =
registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL;
}
/** @internal */
static resetCache() {
ExtensionRegistryClient.fetchPromise = null;
@@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
return ExtensionRegistryClient.fetchPromise;
}
const uri = this.registryURI;
ExtensionRegistryClient.fetchPromise = (async () => {
try {
const response = await fetchWithTimeout(
ExtensionRegistryClient.REGISTRY_URL,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
}
if (uri.startsWith('http')) {
if (isPrivateIp(uri)) {
throw new Error(
'Private IP addresses are not allowed for the extension registry.',
);
}
const response = await fetchWithTimeout(
uri,
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
);
if (!response.ok) {
throw new Error(
`Failed to fetch extensions: ${response.statusText}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as RegistryExtension[];
} else {
// Handle local file path
const filePath = resolveToRealPath(uri);
const content = await fs.readFile(filePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as RegistryExtension[];
}
} catch (error) {
ExtensionRegistryClient.fetchPromise = null;
throw error;
+25 -3
View File
@@ -306,10 +306,10 @@ const SETTINGS_SCHEMA = {
label: 'Retry Fetch Errors',
category: 'General',
requiresRestart: false,
default: false,
default: true,
description:
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
showInDialog: true,
},
maxAttempts: {
type: 'number',
@@ -676,7 +676,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: true,
description:
"Show the logged-in user's identity (e.g. email) in the UI.",
"Show the signed-in user's identity (e.g. email) in the UI.",
showInDialog: true,
},
useAlternateBuffer: {
@@ -1496,6 +1496,18 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddToPolicyByDefault: {
type: 'boolean',
label: 'Auto-add to Policy by Default',
category: 'Security',
requiresRestart: false,
default: false,
description: oneLine`
When enabled, the "Allow for all future sessions" option becomes the
default choice for low-risk tools in trusted workspaces.
`,
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -1779,6 +1791,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionRegistryURI: {
type: 'string',
label: 'Extension Registry URI',
category: 'Experimental',
requiresRestart: true,
default: 'https://geminicli.com/extensions.json',
description:
'The URI (web URL or local file path) of the extension registry.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
+2 -2
View File
@@ -48,14 +48,14 @@ describe('auth', () => {
});
it('should return error message on failed auth', async () => {
const error = new Error('Auth failed');
const error = new Error('Authentication failed');
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
const result = await performInitialAuth(
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: 'Failed to login. Message: Auth failed',
authError: 'Failed to sign in. Message: Authentication failed',
accountSuspensionInfo: null,
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
+1 -1
View File
@@ -64,7 +64,7 @@ export async function performInitialAuth(
};
}
return {
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
authError: `Failed to sign in. Message: ${getErrorMessage(e)}`,
accountSuspensionInfo: null,
};
}
+38 -25
View File
@@ -92,6 +92,8 @@ import { computeTerminalTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import {
@@ -109,6 +111,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
const SLOW_RENDER_MS = 200;
@@ -207,6 +210,11 @@ export async function startInteractiveUI(
});
}
const { matchers, errors } = await loadKeyMatchers();
errors.forEach((error) => {
coreEvents.emitFeedback('warning', error);
});
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
@@ -229,35 +237,39 @@ export async function startInteractiveUI(
return (
<SettingsContext.Provider value={settings}>
<KeypressProvider
config={config}
debugKeystrokeLogging={settings.merged.general.debugKeystrokeLogging}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</KeyMatchersProvider>
</SettingsContext.Provider>
);
};
@@ -370,6 +382,7 @@ export async function main() {
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
+1 -1
View File
@@ -211,7 +211,7 @@ export async function runNonInteractive({
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
config,
context: config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { SkillCommandLoader } from './SkillCommandLoader.js';
import { CommandKind } from '../ui/commands/types.js';
import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
describe('SkillCommandLoader', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockConfig: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockSkillManager: any;
beforeEach(() => {
mockSkillManager = {
getDisplayableSkills: vi.fn(),
isAdminEnabled: vi.fn().mockReturnValue(true),
};
mockConfig = {
isSkillsSupportEnabled: vi.fn().mockReturnValue(true),
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
};
});
it('should return an empty array if skills support is disabled', async () => {
mockConfig.isSkillsSupportEnabled.mockReturnValue(false);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should return an empty array if SkillManager is missing', async () => {
mockConfig.getSkillManager.mockReturnValue(null);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should return an empty array if skills are admin-disabled', async () => {
mockSkillManager.isAdminEnabled.mockReturnValue(false);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toEqual([]);
});
it('should load skills as slash commands', async () => {
const mockSkills = [
{ name: 'skill1', description: 'Description 1' },
{ name: 'skill2', description: '' },
];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands).toHaveLength(2);
expect(commands[0]).toMatchObject({
name: 'skill1',
description: 'Description 1',
kind: CommandKind.SKILL,
autoExecute: true,
});
expect(commands[1]).toMatchObject({
name: 'skill2',
description: 'Activate the skill2 skill',
kind: CommandKind.SKILL,
autoExecute: true,
});
});
it('should return a tool action when a skill command is executed', async () => {
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = await commands[0].action!({} as any, '');
expect(actionResult).toEqual({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: 'test-skill' },
postSubmitPrompt: undefined,
});
});
it('should return a tool action with postSubmitPrompt when args are provided', async () => {
const mockSkills = [{ name: 'test-skill', description: 'Test skill' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = await commands[0].action!({} as any, 'hello world');
expect(actionResult).toEqual({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: 'test-skill' },
postSubmitPrompt: 'hello world',
});
});
it('should sanitize skill names with spaces', async () => {
const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }];
mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills);
const loader = new SkillCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
expect(commands[0].name).toBe('my-awesome-skill');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionResult = (await commands[0].action!({} as any, '')) as any;
expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' });
});
});
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { type ICommandLoader } from './types.js';
/**
* Loads Agent Skills as slash commands.
*/
export class SkillCommandLoader implements ICommandLoader {
constructor(private config: Config | null) {}
/**
* Discovers all available skills from the SkillManager and converts
* them into executable slash commands.
*
* @param _signal An AbortSignal (unused for this synchronous loader).
* @returns A promise that resolves to an array of `SlashCommand` objects.
*/
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
if (!this.config || !this.config.isSkillsSupportEnabled()) {
return [];
}
const skillManager = this.config.getSkillManager();
if (!skillManager || !skillManager.isAdminEnabled()) {
return [];
}
// Convert all displayable skills into slash commands.
const skills = skillManager.getDisplayableSkills();
return skills.map((skill) => {
const commandName = skill.name.trim().replace(/\s+/g, '-');
return {
name: commandName,
description: skill.description || `Activate the ${skill.name} skill`,
kind: CommandKind.SKILL,
autoExecute: true,
action: async (_context, args) => ({
type: 'tool',
toolName: ACTIVATE_SKILL_TOOL_NAME,
toolArgs: { name: skill.name },
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
}),
};
});
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
+96 -3
View File
@@ -2770,7 +2770,7 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should exit copy mode on any key press', async () => {
it('should exit copy mode on non-scroll key press', async () => {
await setupCopyModeTest(isAlternateMode);
// Enter copy mode
@@ -2792,6 +2792,61 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should not exit copy mode on PageDown and should pass it through', async () => {
const childHandler = vi.fn().mockReturnValue(false);
await setupCopyModeTest(true, childHandler);
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
childHandler.mockClear();
(enableMouseEvents as Mock).mockClear();
// PageDown should be passed through to lower-priority handlers.
act(() => {
stdin.write('\x1b[6~');
});
rerender();
expect(enableMouseEvents).not.toHaveBeenCalled();
expect(childHandler).toHaveBeenCalled();
expect(childHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'pagedown' }),
);
unmount();
});
it('should not exit copy mode on Shift+Down and should pass it through', async () => {
const childHandler = vi.fn().mockReturnValue(false);
await setupCopyModeTest(true, childHandler);
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
childHandler.mockClear();
(enableMouseEvents as Mock).mockClear();
act(() => {
stdin.write('\x1b[1;2B'); // Shift+Down
});
rerender();
expect(enableMouseEvents).not.toHaveBeenCalled();
expect(childHandler).toHaveBeenCalled();
expect(childHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'down', shift: true }),
);
unmount();
});
it('should have higher priority than other priority listeners when enabled', async () => {
// 1. Initial state with a child component's priority listener (already subscribed)
// It should NOT handle Ctrl+S so we can enter copy mode.
@@ -3145,7 +3200,7 @@ describe('AppContainer State Management', () => {
});
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
it('preserves buffer when cancelling, even if empty (user is in control)', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
@@ -3161,7 +3216,45 @@ describe('AppContainer State Management', () => {
onCancelSubmit(false);
});
expect(mockSetText).toHaveBeenCalledWith('');
// Should NOT modify buffer when cancelling - user is in control
expect(mockSetText).not.toHaveBeenCalled();
unmount!();
});
it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => {
// Mock buffer with text that user typed while streaming (same as last message)
const promptText = 'What is Python?';
mockedUseTextBuffer.mockReturnValue({
text: promptText,
setText: mockSetText,
});
// Mock input history with same message
mockedUseInputHistoryStore.mockReturnValue({
inputHistory: [promptText],
addInput: vi.fn(),
initializeFromLogger: vi.fn(),
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
);
act(() => {
// Simulate Escape key cancelling streaming (shouldRestorePrompt=false)
onCancelSubmit(false);
});
// Should NOT call setText - prompt should be preserved regardless of content
expect(mockSetText).not.toHaveBeenCalled();
unmount!();
});
+27 -11
View File
@@ -473,9 +473,11 @@ export const AppContainer = (props: AppContainerProps) => {
disableMouseEvents();
// Kill all background shells
for (const pid of backgroundShellsRef.current.keys()) {
ShellExecutionService.kill(pid);
}
await Promise.all(
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
ShellExecutionService.kill(pid),
),
);
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@@ -1220,8 +1222,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
// If cancelling (shouldRestorePrompt=false), never modify the buffer
// User is in control - preserve whatever text they typed, pasted, or restored
if (!shouldRestorePrompt) {
return;
}
// Restore the last message when shouldRestorePrompt=true
const lastUserMessage = inputHistory.at(-1);
let textToSet = shouldRestorePrompt ? lastUserMessage || '' : '';
let textToSet = lastUserMessage || '';
const queuedText = getQueuedMessagesText();
if (queuedText) {
@@ -1229,7 +1238,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearQueue();
}
if (textToSet || !shouldRestorePrompt) {
if (textToSet) {
buffer.setText(textToSet);
}
},
@@ -1389,11 +1398,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1859,7 +1864,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
(key: Key) => {
if (
keyMatchers[Command.SCROLL_UP](key) ||
keyMatchers[Command.SCROLL_DOWN](key) ||
keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key) ||
keyMatchers[Command.SCROLL_HOME](key) ||
keyMatchers[Command.SCROLL_END](key)
) {
return false;
}
setCopyModeEnabled(false);
enableMouseEvents();
return true;
+2 -2
View File
@@ -210,7 +210,7 @@ describe('AuthDialog', () => {
{
setup: () => {},
expected: AuthType.LOGIN_WITH_GOOGLE,
desc: 'defaults to Login with Google',
desc: 'defaults to Sign in with Google',
},
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
@@ -352,7 +352,7 @@ describe('AuthDialog', () => {
unmount();
});
it('exits process for Login with Google when browser is suppressed', async () => {
it('exits process for Sign in with Google when browser is suppressed', async () => {
vi.useFakeTimers();
const exitSpy = vi
.spyOn(process, 'exit')
+1 -1
View File
@@ -44,7 +44,7 @@ export function AuthDialog({
const [exiting, setExiting] = useState(false);
let items = [
{
label: 'Login with Google',
label: 'Sign in with Google',
value: AuthType.LOGIN_WITH_GOOGLE,
key: AuthType.LOGIN_WITH_GOOGLE,
},
@@ -59,8 +59,8 @@ describe('AuthInProgress', () => {
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
+2 -2
View File
@@ -53,8 +53,8 @@ export function AuthInProgress({
) : (
<Box>
<Text>
<CliSpinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C
to cancel)
<CliSpinner type="dots" /> Waiting for authentication... (Press Esc
or Ctrl+C to cancel)
</Text>
</Box>
)}
@@ -45,13 +45,13 @@ export const LoginWithGoogleRestartDialog = ({
);
const message =
'You have successfully logged in with Google. Gemini CLI needs to be restarted.';
"You've successfully signed in with Google. Gemini CLI needs to be restarted.";
return (
<Box borderStyle="round" borderColor={theme.status.warning} paddingX={1}>
<Text color={theme.status.warning}>
{message} Press &apos;r&apos; to restart, or &apos;escape&apos; to
choose a different auth method.
{message} Press R to restart, or Esc to choose a different
authentication method.
</Text>
</Box>
);
@@ -7,7 +7,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ Something went wrong │
│ │
@@ -28,7 +28,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ (Use Enter to select) │
│ │
@@ -2,8 +2,8 @@
exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to
restart, or 'escape' to choose a different auth method. │
│ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart,
or Esc to choose a different authentication method.
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+1 -1
View File
@@ -288,7 +288,7 @@ describe('useAuth', () => {
);
await waitFor(() => {
expect(result.current.authError).toContain('Failed to login');
expect(result.current.authError).toContain('Failed to sign in');
expect(result.current.authState).toBe(AuthState.Updating);
});
});
+1 -1
View File
@@ -149,7 +149,7 @@ export const useAuthCommand = (
// Show the error message directly without "Failed to login" prefix
onAuthError(getErrorMessage(e));
} else {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
onAuthError(`Failed to sign in. Message: ${getErrorMessage(e)}`);
}
}
})();
@@ -34,11 +34,13 @@ describe('authCommand', () => {
vi.clearAllMocks();
});
it('should have subcommands: login and logout', () => {
it('should have subcommands: signin and signout', () => {
expect(authCommand.subCommands).toBeDefined();
expect(authCommand.subCommands).toHaveLength(2);
expect(authCommand.subCommands?.[0]?.name).toBe('login');
expect(authCommand.subCommands?.[1]?.name).toBe('logout');
expect(authCommand.subCommands?.[0]?.name).toBe('signin');
expect(authCommand.subCommands?.[0]?.altNames).toContain('login');
expect(authCommand.subCommands?.[1]?.name).toBe('signout');
expect(authCommand.subCommands?.[1]?.altNames).toContain('logout');
});
it('should return a dialog action to open the auth dialog when called with no args', () => {
@@ -59,19 +61,19 @@ describe('authCommand', () => {
expect(authCommand.description).toBe('Manage authentication');
});
describe('auth login subcommand', () => {
describe('auth signin subcommand', () => {
it('should return auth dialog action', () => {
const loginCommand = authCommand.subCommands?.[0];
expect(loginCommand?.name).toBe('login');
expect(loginCommand?.name).toBe('signin');
const result = loginCommand!.action!(mockContext, '');
expect(result).toEqual({ type: 'dialog', dialog: 'auth' });
});
});
describe('auth logout subcommand', () => {
describe('auth signout subcommand', () => {
it('should clear cached credentials', async () => {
const logoutCommand = authCommand.subCommands?.[1];
expect(logoutCommand?.name).toBe('logout');
expect(logoutCommand?.name).toBe('signout');
const { clearCachedCredentialFile } = await import(
'@google/gemini-cli-core'
+6 -4
View File
@@ -14,8 +14,9 @@ import { clearCachedCredentialFile } from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
const authLoginCommand: SlashCommand = {
name: 'login',
description: 'Login or change the auth method',
name: 'signin',
altNames: ['login'],
description: 'Sign in or change the authentication method',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (_context, _args): OpenDialogActionReturn => ({
@@ -25,8 +26,9 @@ const authLoginCommand: SlashCommand = {
};
const authLogoutCommand: SlashCommand = {
name: 'logout',
description: 'Log out and clear all cached credentials',
name: 'signout',
altNames: ['logout'],
description: 'Sign out and clear all cached credentials',
kind: CommandKind.BUILT_IN,
action: async (context, _args): Promise<LogoutActionReturn> => {
await clearCachedCredentialFile();
@@ -475,14 +475,18 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: extension.url });
// Call onSelect
component.props.onSelect?.(extension);
await component.props.onSelect?.(extension);
await waitFor(() => {
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: extension.url,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: extension.url,
type: 'git',
},
undefined,
undefined,
);
});
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
@@ -622,10 +626,14 @@ describe('extensionsCommand', () => {
mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`,
@@ -647,10 +655,14 @@ describe('extensionsCommand', () => {
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'git',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
@@ -279,9 +279,9 @@ async function exploreAction(
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
onSelect: async (extension, requestConsentOverride) => {
debugLogger.log(`Selected extension: ${extension.extensionName}`);
void installAction(context, extension.url);
await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
@@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) {
}
}
async function installAction(context: CommandContext, args: string) {
async function installAction(
context: CommandContext,
args: string,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
@@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) {
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
const extension = await extensionLoader.installOrUpdateExtension(
installMetadata,
undefined,
requestConsentOverride,
);
context.ui.addItem({
type: MessageType.INFO,
text: `Extension "${extension.name}" installed successfully.`,
@@ -67,7 +67,7 @@ describe('toolsCommand', () => {
});
});
it('should list tools without descriptions by default', async () => {
it('should list tools without descriptions by default (no args)', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
@@ -88,6 +88,27 @@ describe('toolsCommand', () => {
expect(message.tools[1].displayName).toBe('Code Editor');
});
it('should list tools without descriptions when "list" arg is passed', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () => ({ getAllTools: () => mockTools }),
},
},
});
if (!toolsCommand.action) throw new Error('Action not defined');
await toolsCommand.action(mockContext, 'list');
const [message] = (mockContext.ui.addItem as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(false);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[1].displayName).toBe('Code Editor');
});
it('should list tools with descriptions when "desc" arg is passed', async () => {
const mockContext = createMockCommandContext({
services: {
@@ -105,9 +126,65 @@ describe('toolsCommand', () => {
expect(message.type).toBe(MessageType.TOOLS_LIST);
expect(message.showDescriptions).toBe(true);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[0].description).toBe(
'Reads files from the local system.',
);
expect(message.tools[1].displayName).toBe('Code Editor');
expect(message.tools[1].description).toBe('Edits code files.');
});
it('should have "list" and "desc" subcommands', () => {
expect(toolsCommand.subCommands).toBeDefined();
const names = toolsCommand.subCommands?.map((s) => s.name);
expect(names).toContain('list');
expect(names).toContain('desc');
expect(names).not.toContain('descriptions');
});
it('subcommand "list" should display tools without descriptions', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () => ({ getAllTools: () => mockTools }),
},
},
});
const listCmd = toolsCommand.subCommands?.find((s) => s.name === 'list');
if (!listCmd?.action) throw new Error('Action not defined');
await listCmd.action(mockContext, '');
const [message] = (mockContext.ui.addItem as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(message.showDescriptions).toBe(false);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[1].displayName).toBe('Code Editor');
});
it('subcommand "desc" should display tools with descriptions', async () => {
const mockContext = createMockCommandContext({
services: {
config: {
getToolRegistry: () => ({ getAllTools: () => mockTools }),
},
},
});
const descCmd = toolsCommand.subCommands?.find((s) => s.name === 'desc');
if (!descCmd?.action) throw new Error('Action not defined');
await descCmd.action(mockContext, '');
const [message] = (mockContext.ui.addItem as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(message.showDescriptions).toBe(true);
expect(message.tools).toHaveLength(2);
expect(message.tools[0].displayName).toBe('File Reader');
expect(message.tools[0].description).toBe(
'Reads files from the local system.',
);
expect(message.tools[1].displayName).toBe('Code Editor');
expect(message.tools[1].description).toBe('Edits code files.');
});
+12 -3
View File
@@ -41,7 +41,16 @@ async function listTools(
context.ui.addItem(toolsListItem);
}
const toolsDescSubCommand: SlashCommand = {
const listSubCommand: SlashCommand = {
name: 'list',
description: 'List available Gemini CLI tools.',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext): Promise<void> =>
listTools(context, false),
};
const descSubCommand: SlashCommand = {
name: 'desc',
altNames: ['descriptions'],
description: 'List available Gemini CLI tools with descriptions.',
@@ -57,11 +66,11 @@ export const toolsCommand: SlashCommand = {
'List available Gemini CLI tools. Use /tools desc to include descriptions.',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [toolsDescSubCommand],
subCommands: [listSubCommand, descSubCommand],
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
// Keep backward compatibility for typed arguments while exposing desc in TUI via subcommands.
// Keep backward compatibility for typed arguments while exposing subcommands in TUI.
const useShowDescriptions =
subCommand === 'desc' || subCommand === 'descriptions';
+1
View File
@@ -182,6 +182,7 @@ export enum CommandKind {
EXTENSION_FILE = 'extension-file',
MCP_PROMPT = 'mcp-prompt',
AGENT = 'agent',
SKILL = 'skill',
}
// The standardized contract for any command in the system.
@@ -36,7 +36,7 @@ describe('AboutBox', () => {
expect(output).toContain('gemini-pro');
expect(output).toContain('default');
expect(output).toContain('macOS');
expect(output).toContain('Logged in with Google');
expect(output).toContain('Signed in with Google');
unmount();
});
@@ -63,7 +63,7 @@ describe('AboutBox', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google (test@example.com)');
expect(output).toContain('Signed in with Google (test@example.com)');
unmount();
});
+2 -2
View File
@@ -116,8 +116,8 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
? `Signed in with Google (${userEmail})`
: 'Signed in with Google'
: selectedAuthType}
</Text>
</Box>
@@ -807,16 +807,21 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
const questionHeightLimit =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
: selectionItems.length;
return (
@@ -824,7 +829,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
maxHeight={questionHeightLimit}
maxWidth={availableWidth}
overflowDirection="bottom"
>
@@ -35,6 +35,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ShellExecutionService: {
resizePty: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
getLogFilePath: vi.fn(
(pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`,
),
getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'),
},
};
});
@@ -222,7 +226,7 @@ describe('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
20,
);
rerender(
@@ -242,7 +246,7 @@ describe('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
26,
);
unmount();
});
@@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { theme } from '../semantic-colors.js';
import {
ShellExecutionService,
shortenPath,
tildeifyPath,
type AnsiOutput,
type AnsiLine,
type AnsiToken,
@@ -43,8 +45,14 @@ interface BackgroundShellDisplayProps {
const CONTENT_PADDING_X = 1;
const BORDER_WIDTH = 2; // Left and Right border
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border
const HEADER_HEIGHT = 1;
const FOOTER_HEIGHT = 1;
const TOTAL_OVERHEAD_HEIGHT =
MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2)
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
const commandFirstLine = command.split('\n')[0];
@@ -81,7 +89,7 @@ export const BackgroundShellDisplay = ({
if (!activePid) return;
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT);
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
}, [activePid, width, height]);
@@ -150,7 +158,7 @@ export const BackgroundShellDisplay = ({
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
dismissBackgroundShell(highlightedPid);
void dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
dismissBackgroundShell(activeShell.pid);
void dismissBackgroundShell(activeShell.pid);
return true;
}
@@ -336,7 +344,10 @@ export const BackgroundShellDisplay = ({
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
maxItemsToShow={Math.max(
1,
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
)}
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
@@ -383,6 +394,23 @@ export const BackgroundShellDisplay = ({
);
};
const renderFooter = () => {
const pidToDisplay = isListOpenProp
? (highlightedPid ?? activePid)
: activePid;
if (!pidToDisplay) return null;
const logPath = ShellExecutionService.getLogFilePath(pidToDisplay);
const displayPath = shortenPath(
tildeifyPath(logPath),
width - LOG_PATH_OVERHEAD,
);
return (
<Box paddingX={1}>
<Text color={theme.text.secondary}>Log: {displayPath}</Text>
</Box>
);
};
const renderOutput = () => {
const lines = typeof output === 'string' ? output.split('\n') : output;
@@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
{renderFooter()}
</Box>
);
};
@@ -831,7 +831,7 @@ describe('Composer', () => {
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
});
it('does not show shortcuts hint immediately when buffer has text', async () => {
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
@@ -901,16 +901,6 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
+18 -17
View File
@@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const shouldReserveSpaceForShortcutsHint =
settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
showShortcutsHintDebounced;
shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
@@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
!showUiDetails &&
(showMinimalInlineLoading ||
showMinimalBleedThroughRow ||
showShortcutsHint);
shouldReserveSpaceForShortcutsHint);
return (
<Box
@@ -249,6 +249,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
>
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
@@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
</Box>
)}
</Box>
{(showMinimalContextBleedThrough || showShortcutsHint) && (
{(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
@@ -317,18 +322,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
terminalWidth={uiState.terminalWidth}
/>
)}
{showShortcutsHint && (
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={
showMinimalContextBleedThrough && isNarrow ? 1 : 0
}
>
<ShortcutsHint />
</Box>
)}
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
)}
</Box>
@@ -35,7 +35,8 @@ describe('CopyModeWarning', () => {
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Press any key to exit');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
unmount();
});
});
@@ -19,7 +19,8 @@ export const CopyModeWarning: React.FC = () => {
return (
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Press any key to exit.
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
to exit.
</Text>
</Box>
);
@@ -249,6 +249,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
],
placeholder: 'Type your feedback...',
multiSelect: false,
unconstrainedHeight: false,
},
]}
onSubmit={(answers) => {
+32 -39
View File
@@ -101,6 +101,12 @@ describe('<Footer />', () => {
beforeEach(() => {
const root = path.parse(process.cwd()).root;
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders the component', async () => {
@@ -427,15 +433,6 @@ describe('<Footer />', () => {
});
describe('footer configuration filtering (golden snapshots)', () => {
beforeEach(() => {
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
@@ -459,23 +456,21 @@ describe('<Footer />', () => {
});
it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
'footer-minimal',
);
@@ -797,21 +792,19 @@ describe('<Footer />', () => {
});
it('handles empty items array', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));
const output = lastFrame({ allowEmpty: true });
expect(output).toBeDefined();
@@ -28,9 +28,9 @@ describe('LogoutConfirmationDialog', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('You are now logged out.');
expect(lastFrame()).toContain('You are now signed out');
expect(lastFrame()).toContain(
'Login again to continue using Gemini CLI, or exit the application.',
'Sign in again to continue using Gemini CLI, or exit the application.',
);
expect(lastFrame()).toContain('(Use Enter to select, Esc to close)');
unmount();
@@ -45,7 +45,7 @@ describe('LogoutConfirmationDialog', () => {
expect(RadioButtonSelect).toHaveBeenCalled();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
expect(mockCall.items).toEqual([
{ label: 'Login', value: LogoutChoice.LOGIN, key: 'login' },
{ label: 'Sign in', value: LogoutChoice.LOGIN, key: 'login' },
{ label: 'Exit', value: LogoutChoice.EXIT, key: 'exit' },
]);
expect(mockCall.isFocused).toBe(true);
@@ -37,7 +37,7 @@ export const LogoutConfirmationDialog: React.FC<
const options: Array<RadioSelectItem<LogoutChoice>> = [
{
label: 'Login',
label: 'Sign in',
value: LogoutChoice.LOGIN,
key: 'login',
},
@@ -61,10 +61,10 @@ export const LogoutConfirmationDialog: React.FC<
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
You are now logged out.
You are now signed out
</Text>
<Text color={theme.text.secondary}>
Login again to continue using Gemini CLI, or exit the application.
Sign in again to continue using Gemini CLI, or exit the application.
</Text>
</Box>
@@ -410,6 +410,7 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
expect(output).toMatchSnapshot();
unmount();
});
@@ -539,7 +540,7 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Logged in with Google');
expect(output).toContain('Signed in with Google');
expect(output).toContain('(test@example.com)');
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
@@ -340,8 +340,8 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
? `Signed in with Google (${userEmail})`
: 'Signed in with Google'
: selectedAuthType}
</Text>
</Box>
@@ -616,7 +616,7 @@ describe('<StatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Logged in with Google (test@example.com)');
expect(output).toContain('Signed in with Google (test@example.com)');
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});
@@ -589,8 +589,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
? `Signed in with Google (${userEmail})`
: 'Signed in with Google'
: selectedAuthType}
</Text>
</StatRow>
@@ -282,7 +282,10 @@ describe('ToolConfirmationQueue', () => {
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
// AskUserDialog allocates questionHeight = availableHeight - overhead - DIALOG_PADDING.
// listHeight = 15 - overhead (Header:0, Margin:1, Footer:2) = 12.
// maxQuestionHeight = listHeight - 4 = 8.
// 8 lines is enough for the 6-line question.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -45,7 +45,7 @@ describe('<UserIdentity />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test@example.com');
expect(output).toContain('Signed in with Google: test@example.com');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
@@ -91,7 +91,8 @@ describe('<UserIdentity />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Logged in with Google');
expect(output).toContain('Signed in with Google');
expect(output).not.toContain('Signed in with Google:');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
@@ -111,11 +112,20 @@ describe('<UserIdentity />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test@example.com');
expect(output).toContain('Signed in with Google: test@example.com');
expect(output).toContain('/auth');
expect(output).toContain('Premium Plan');
expect(output).toContain('Plan: Premium Plan');
expect(output).toContain('/upgrade');
// Check for two lines (or more if wrapped, but here it should be separate)
const lines = output?.split('\n').filter((line) => line.trim().length > 0);
expect(lines?.some((line) => line.includes('Signed in with Google'))).toBe(
true,
);
expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
true,
);
unmount();
});
@@ -168,7 +178,7 @@ describe('<UserIdentity />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Enterprise Tier');
expect(output).toContain('Plan: Enterprise Tier');
expect(output).toContain('/upgrade');
unmount();
});
@@ -43,7 +43,10 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
<Text>{email ?? 'Logged in with Google'}</Text>
<Text>
<Text bold>Signed in with Google{email ? ':' : ''}</Text>
{email ? ` ${email}` : ''}
</Text>
) : (
`Authenticated with ${authType}`
)}
@@ -55,7 +58,7 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
{tierName && (
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{tierName}
<Text bold>Plan:</Text> {tierName}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
</Box>
@@ -136,7 +136,7 @@ export function ValidationDialog({
<CliSpinner />
<Text>
{' '}
Waiting for verification... (Press ESC or CTRL+C to cancel)
Waiting for verification... (Press Esc or Ctrl+C to cancel)
</Text>
</Box>
{errorMessage && (
@@ -5,6 +5,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -19,6 +20,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
│ Log: ~/.gemini/tmp/background-processes/background-1003.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -27,6 +29,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -35,6 +38,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -48,6 +52,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -61,6 +66,7 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
│ Log: ~/.gemini/tmp/background-processes/background-1002.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -165,6 +165,29 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Auto (Gemini 3) Stats For Nerds │
│ │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 200ms 50ms │
│ Tokens │
│ Total 6,000 12,000 │
│ ↳ Input 1,000 2,000 │
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="819" y="512" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">false*</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -83,20 +83,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">true*</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -28,12 +28,12 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -74,12 +74,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -120,12 +120,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,12 +166,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -212,12 +212,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -258,12 +258,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -304,12 +304,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -350,12 +350,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -396,12 +396,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -411,7 +411,7 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should show "Allow for all future sessions" when setting is true', async () => {
it('should show "Allow for all future sessions" when trusted', async () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
@@ -434,7 +434,10 @@ describe('ToolConfirmationMessage', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('Allow for all future sessions');
const output = lastFrame();
expect(output).toContain('future sessions');
// Verify it is the default selection (matching the indicator in the snapshot)
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -246,9 +246,9 @@ export const ToolConfirmationMessage: React.FC<
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
label: 'Allow for this file in all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
key: 'Allow for this file in all future sessions',
});
}
}
@@ -282,7 +282,7 @@ export const ToolConfirmationMessage: React.FC<
});
if (allowPermanentApproval) {
options.push({
label: `Allow for all future sessions`,
label: `Allow this command for all future sessions`,
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: `Allow for all future sessions`,
});
@@ -388,266 +388,301 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
const { question, bodyContent, options, securityWarnings } = useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
const { question, bodyContent, options, securityWarnings, initialIndex } =
useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
initialIndex: number;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
};
}
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [], securityWarnings: null };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
let initialIndex = 0;
if (isTrustedFolder && allowPermanentApproval) {
// It is safe to allow permanent approval for info, edit, and mcp tools
// in trusted folders because the generated policy rules are narrowed
// to specific files, patterns, or tools (rather than allowing all access).
const isSafeToPersist =
confirmationDetails.type === 'info' ||
confirmationDetails.type === 'edit' ||
confirmationDetails.type === 'mcp';
if (
isSafeToPersist &&
settings.merged.security.autoAddToPolicyByDefault
) {
const alwaysAndSaveIndex = options.findIndex(
(o) => o.value === ToolConfirmationOutcome.ProceedAlwaysAndSave,
);
if (alwaysAndSaveIndex !== -1) {
initialIndex = alwaysAndSaveIndex;
}
}
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${sanitizeForDisplay(executionProps.rootCommand)}'?`;
if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<DiffRenderer
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
filename={sanitizeForDisplay(confirmationDetails.fileName)}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
initialIndex: 0,
};
}
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const tipText = `Toggle auto-edit (${formatCommand(Command.CYCLE_APPROVAL_MODE)}) to allow redirection in the future.`;
if (confirmationDetails.type === 'exit_plan_mode') {
bodyContent = (
<ExitPlanModeDialog
planPath={confirmationDetails.planPath}
getPreferredEditor={getPreferredEditor}
onApprove={(approvalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode,
});
}}
onFeedback={(feedback) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight()}
/>
);
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
initialIndex: 0,
};
}
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
const tipLength = REDIRECTION_WARNING_TIP_LABEL.length + tipText.length;
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
const spacerLines = 1;
const warningHeight = noteLines + tipLines + spacerLines;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${sanitizeForDisplay(executionProps.rootCommand)}'?`;
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
bodyContent = (
<DiffRenderer
diffContent={stripUnsafeCharacters(confirmationDetails.fileDiff)}
filename={sanitizeForDisplay(confirmationDetails.fileName)}
availableTerminalHeight={availableBodyContentHeight()}
terminalWidth={terminalWidth}
/>
);
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight = Math.max(
bodyContentHeight - warningHeight,
MINIMUM_MAX_HEIGHT,
bodyContentHeight -= 2; // Account for padding;
}
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
const tipText = `Toggle auto-edit (${formatCommand(Command.CYCLE_APPROVAL_MODE)}) to allow redirection in the future.`;
const tipLength =
REDIRECTION_WARNING_TIP_LABEL.length + tipText.length;
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
const spacerLines = 1;
const warningHeight = noteLines + tipLines + spacerLines;
if (bodyContentHeight !== undefined) {
bodyContentHeight = Math.max(
bodyContentHeight - warningHeight,
MINIMUM_MAX_HEIGHT,
);
}
warnings = (
<>
<Box height={1} />
<Box>
<Text color={theme.text.primary}>
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text>
{REDIRECTION_WARNING_NOTE_TEXT}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{tipText}
</Text>
</Box>
</>
);
}
warnings = (
<>
<Box height={1} />
<Box>
<Text color={theme.text.primary}>
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text>
{REDIRECTION_WARNING_NOTE_TEXT}
bodyContent = (
<Box flexDirection="column">
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Box
key={idx}
flexDirection="column"
paddingBottom={idx < commandsToDisplay.length - 1 ? 1 : 0}
>
{colorizeCode({
code: cmd,
language: 'bash',
maxWidth: Math.max(terminalWidth, 1),
settings,
hideLineNumbers: true,
})}
</Box>
))}
</Box>
</MaxSizedBox>
{warnings}
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(
infoProps.urls.length === 1 &&
infoProps.urls[0] === infoProps.prompt
);
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
defaultColor={theme.text.link}
/>
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{tipText}
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</Box>
</>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool
details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
</Box>
);
}
bodyContent = (
<Box flexDirection="column">
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Box
key={idx}
flexDirection="column"
paddingBottom={idx < commandsToDisplay.length - 1 ? 1 : 0}
>
{colorizeCode({
code: cmd,
language: 'bash',
maxWidth: Math.max(terminalWidth, 1),
settings,
hideLineNumbers: true,
})}
</Box>
))}
</Box>
</MaxSizedBox>
{warnings}
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;
const displayUrls =
infoProps.urls &&
!(
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
);
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.link}>
<RenderInline
text={infoProps.prompt}
defaultColor={theme.text.link}
/>
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
bodyContent = (
<Box flexDirection="column">
<>
<Text color={theme.text.link}>
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
</Text>
<Text color={theme.text.link}>
Tool: {sanitizeForDisplay(mcpProps.toolName)}
</Text>
</>
{hasMcpToolDetails && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>MCP Tool Details:</Text>
{isMcpToolDetailsExpanded ? (
<>
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to collapse MCP tool details)
</Text>
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
</>
) : (
<Text color={theme.text.secondary}>
(press {expandDetailsHintKey} to expand MCP tool details)
</Text>
)}
</Box>
)}
</Box>
);
}
return { question, bodyContent, options, securityWarnings };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
settings,
]);
return { question, bodyContent, options, securityWarnings, initialIndex };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
isMcpToolDetailsExpanded,
hasMcpToolDetails,
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
isTrustedFolder,
allowPermanentApproval,
settings,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
@@ -710,6 +745,7 @@ export const ToolConfirmationMessage: React.FC<
items={options}
onSelect={handleSelect}
isFocused={isFocused}
initialIndex={initialIndex}
/>
</Box>
</>
@@ -69,6 +69,11 @@ describe('<ToolGroupMessage />', () => {
ui: { errorVerbosity: 'full' },
},
});
const lowVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'low' },
},
});
describe('Golden Snapshots', () => {
it('renders single successful tool call', async () => {
@@ -721,6 +726,245 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('does not render a bottom-border fragment when all tools are filtered out', async () => {
const toolCalls = [
createToolCall({
callId: 'hidden-error-tool',
name: 'error-tool',
status: CoreToolCallStatus.Error,
resultDisplay: 'Hidden in low verbosity',
isClientInitiated: false,
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: lowVerbositySettings,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('still renders explicit closing slices for split static/pending groups', async () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toBe('');
unmount();
});
it('does not render a border fragment when plan-mode tools are filtered out', async () => {
const toolCalls = [
createToolCall({
callId: 'plan-write',
name: WRITE_FILE_DISPLAY_NAME,
approvalMode: ApprovalMode.PLAN,
status: CoreToolCallStatus.Success,
resultDisplay: 'Plan file written',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('does not render a border fragment when only confirming tools are present', async () => {
const toolCalls = [
createToolCall({
callId: 'confirm-only',
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'info',
title: 'Confirm',
prompt: 'Proceed?',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('does not leave a border stub when transitioning from visible to fully filtered tools', async () => {
const visibleTools = [
createToolCall({
callId: 'visible-success',
name: 'visible-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'visible output',
}),
];
const hiddenTools = [
createToolCall({
callId: 'hidden-error',
name: 'hidden-error-tool',
status: CoreToolCallStatus.Error,
resultDisplay: 'hidden output',
isClientInitiated: false,
}),
];
const initialItem = createItem(visibleTools);
const hiddenItem = createItem(hiddenTools);
const firstRender = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={initialItem}
toolCalls={visibleTools}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: lowVerbositySettings,
},
);
await firstRender.waitUntilReady();
expect(firstRender.lastFrame()).toContain('visible-tool');
firstRender.unmount();
const secondRender = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={hiddenItem}
toolCalls={hiddenTools}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: lowVerbositySettings,
},
);
await secondRender.waitUntilReady();
expect(secondRender.lastFrame({ allowEmpty: true })).toBe('');
secondRender.unmount();
});
it('keeps visible tools rendered with many filtered tools (stress case)', async () => {
const visibleTool = createToolCall({
callId: 'visible-tool',
name: 'visible-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'visible output',
});
const hiddenTools = Array.from({ length: 50 }, (_, index) =>
createToolCall({
callId: `hidden-${index}`,
name: `hidden-error-${index}`,
status: CoreToolCallStatus.Error,
resultDisplay: `hidden output ${index}`,
isClientInitiated: false,
}),
);
const toolCalls = [visibleTool, ...hiddenTools];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={item}
toolCalls={toolCalls}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: lowVerbositySettings,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('visible-tool');
expect(output).not.toContain('hidden-error-0');
expect(output).not.toContain('hidden-error-49');
unmount();
});
it('renders explicit closing slice even at very narrow terminal width', async () => {
const toolCalls: IndividualToolCallDisplay[] = [];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
item={item}
toolCalls={toolCalls}
terminalWidth={8}
borderTop={false}
borderBottom={true}
/>,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).not.toBe('');
unmount();
});
});
describe('Plan Mode Filtering', () => {
@@ -141,11 +141,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
// If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity
// internal errors, plan-mode hidden write/edit), we should not emit standalone
// border fragments. The only case where an empty group should render is the
// explicit "closing slice" (tools: []) used to bridge static/pending sections.
const isExplicitClosingSlice = allToolCalls.length === 0;
if (
visibleToolCalls.length === 0 &&
(!isExplicitClosingSlice || borderBottomOverride !== true)
) {
return null;
}
@@ -1,5 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should show "Allow for all future sessions" when trusted 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
2. Allow for this session
3. Allow for this file in all future sessions
4. Modify with external editor
5. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello"
@@ -67,6 +67,8 @@ export interface SearchableListProps<T extends GenericListItem> {
onSearch?: (query: string) => void;
/** Whether to reset selection to the top when items change (e.g. after search) */
resetSelectionOnItemsChange?: boolean;
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
isFocused?: boolean;
}
/**
@@ -85,6 +87,7 @@ export function SearchableList<T extends GenericListItem>({
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
isFocused = true,
}: SearchableListProps<T>): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
@@ -111,7 +114,7 @@ export function SearchableList<T extends GenericListItem>({
const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems,
onSelect: handleSelectValue,
isFocused: true,
isFocused,
showNumbers: false,
wrapAround: true,
priority: true,
@@ -157,7 +160,7 @@ export function SearchableList<T extends GenericListItem>({
}
return false;
},
{ isActive: true },
{ isActive: isFocused },
);
const visibleItems = filteredItems.slice(
@@ -209,7 +212,7 @@ export function SearchableList<T extends GenericListItem>({
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
focus={isFocused}
/>
</Box>
)}
@@ -1703,6 +1703,25 @@ export type TextBufferAction =
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line'; payload: { count: number } }
| { type: 'vim_delete_to_last_line'; payload: { count: number } }
| { type: 'vim_delete_char_before'; payload: { count: number } }
| { type: 'vim_toggle_case'; payload: { count: number } }
| { type: 'vim_replace_char'; payload: { char: string; count: number } }
| {
type: 'vim_find_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_find_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'toggle_paste_expansion';
payload: { id: string; row: number; col: number };
@@ -2484,6 +2503,13 @@ function textBufferReducerLogic(
case 'vim_change_to_first_nonwhitespace':
case 'vim_delete_to_first_line':
case 'vim_delete_to_last_line':
case 'vim_delete_char_before':
case 'vim_toggle_case':
case 'vim_replace_char':
case 'vim_find_char_forward':
case 'vim_find_char_backward':
case 'vim_delete_to_char_forward':
case 'vim_delete_to_char_backward':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
@@ -3043,6 +3069,58 @@ export function useTextBuffer({
dispatch({ type: 'vim_delete_char', payload: { count } });
}, []);
const vimDeleteCharBefore = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_char_before', payload: { count } });
}, []);
const vimToggleCase = useCallback((count: number): void => {
dispatch({ type: 'vim_toggle_case', payload: { count } });
}, []);
const vimReplaceChar = useCallback((char: string, count: number): void => {
dispatch({ type: 'vim_replace_char', payload: { char, count } });
}, []);
const vimFindCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimFindCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimInsertAtCursor = useCallback((): void => {
dispatch({ type: 'vim_insert_at_cursor' });
}, []);
@@ -3542,6 +3620,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3630,6 +3715,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3937,6 +4029,20 @@ export interface TextBuffer {
* Delete N characters at cursor (vim 'x' command)
*/
vimDeleteChar: (count: number) => void;
/** Delete N characters before cursor (vim 'X') */
vimDeleteCharBefore: (count: number) => void;
/** Toggle case of N characters at cursor (vim '~') */
vimToggleCase: (count: number) => void;
/** Replace N characters at cursor with char, stay in NORMAL mode (vim 'r') */
vimReplaceChar: (char: string, count: number) => void;
/** Move to Nth occurrence of char forward on line; till=true stops before it (vim 'f'/'t') */
vimFindCharForward: (char: string, count: number, till: boolean) => void;
/** Move to Nth occurrence of char backward on line; till=true stops after it (vim 'F'/'T') */
vimFindCharBackward: (char: string, count: number, till: boolean) => void;
/** Delete from cursor to Nth occurrence of char forward; till=true excludes the char (vim 'df'/'dt') */
vimDeleteToCharForward: (char: string, count: number, till: boolean) => void;
/** Delete from Nth occurrence of char backward to cursor; till=true excludes the char (vim 'dF'/'dT') */
vimDeleteToCharBackward: (char: string, count: number, till: boolean) => void;
/**
* Enter insert mode at cursor (vim 'i' command)
*/
@@ -572,6 +572,21 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel');
// Cursor clamps to last char of the shortened line (vim NORMAL mode
// cursor cannot rest past the final character).
expect(result.cursorCol).toBe(2);
});
it('should clamp cursor when deleting the last character on a line', () => {
const state = createTestState(['hello'], 0, 4);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hell');
expect(result.cursorCol).toBe(3);
});
@@ -626,7 +641,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
expect(result.cursorCol).toBe(5);
});
it('should delete only the word characters if it is the last word followed by whitespace', () => {
@@ -666,6 +681,55 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('foo ');
});
it('should clamp cursor when dW removes the last word leaving only a trailing space', () => {
// cursor on 'w' in 'hello world'; dW deletes 'world' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_forward' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
// col 6 is past the new line end (len 6, max valid = 5)
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_end', () => {
it('should clamp cursor when de removes the last word on a line', () => {
// cursor on 'w' in 'hello world'; de deletes through 'd' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_big_word_end', () => {
it('should delete from cursor to end of WORD (skipping punctuation)', () => {
// cursor on 'b' in 'foo bar.baz qux'; dE treats 'bar.baz' as one WORD
const state = createTestState(['foo bar.baz qux'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('foo qux');
expect(result.cursorCol).toBe(4);
});
it('should clamp cursor when dE removes the last WORD on a line', () => {
// cursor on 'w' in 'hello world'; dE deletes through 'd' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_backward', () => {
@@ -751,7 +815,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
expect(result.cursorCol).toBe(4);
});
it('should do nothing at end of line', () => {
@@ -781,7 +845,7 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
// 2D at position 5 on "line one" should delete "one" + entire "line two"
expect(result.lines).toEqual(['line ', 'line three']);
expect(result.cursorCol).toBe(5);
expect(result.cursorCol).toBe(4);
});
it('should handle count exceeding available lines', () => {
@@ -1727,4 +1791,440 @@ describe('vim-buffer-actions', () => {
});
});
});
describe('Character manipulation commands (X, ~, r, f/F/t/T)', () => {
describe('vim_delete_char_before (X)', () => {
it('should delete the character before the cursor', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('helo');
expect(result.cursorCol).toBe(2);
});
it('should delete N characters before the cursor', () => {
const state = createTestState(['hello world'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 3 },
});
expect(result.lines[0]).toBe('he world');
expect(result.cursorCol).toBe(2);
});
it('should clamp to start of line when count exceeds position', () => {
const state = createTestState(['hello'], 0, 2);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 10 },
});
expect(result.lines[0]).toBe('llo');
expect(result.cursorCol).toBe(0);
});
it('should do nothing when cursor is at column 0', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_toggle_case (~)', () => {
it('should toggle lowercase to uppercase', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('Hello');
expect(result.cursorCol).toBe(1);
});
it('should toggle uppercase to lowercase', () => {
const state = createTestState(['HELLO'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hELLO');
expect(result.cursorCol).toBe(1);
});
it('should toggle N characters', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 5 },
});
expect(result.lines[0]).toBe('HELLO world');
expect(result.cursorCol).toBe(5); // cursor advances past the toggled range
});
it('should clamp count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 100 },
});
expect(result.lines[0]).toBe('hI');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_replace_char (r)', () => {
it('should replace the character under the cursor', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'a', count: 1 },
});
expect(result.lines[0]).toBe('hallo');
expect(result.cursorCol).toBe(1);
});
it('should replace N characters with the given char', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 3 },
});
expect(result.lines[0]).toBe('hxxxo');
expect(result.cursorCol).toBe(3); // cursor at last replaced char
});
it('should clamp replace count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 100 },
});
expect(result.lines[0]).toBe('hz');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
type FindActionCase = {
label: string;
type: 'vim_find_char_forward' | 'vim_find_char_backward';
cursorStart: number;
char: string;
count: number;
till: boolean;
expectedCol: number;
};
it.each<FindActionCase>([
{
label: 'f: move to char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'f: Nth occurrence',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 2,
till: false,
expectedCol: 7,
},
{
label: 't: move before char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: true,
expectedCol: 3,
},
{
label: 'f: not found',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'z',
count: 1,
till: false,
expectedCol: 0,
},
{
label: 'f: skip char at cursor',
type: 'vim_find_char_forward',
cursorStart: 1,
char: 'h',
count: 1,
till: false,
expectedCol: 1,
},
{
label: 'F: move to char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: false,
expectedCol: 7,
},
{
label: 'F: Nth occurrence',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 2,
till: false,
expectedCol: 4,
},
{
label: 'T: move after char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: true,
expectedCol: 8,
},
{
label: 'F: not found',
type: 'vim_find_char_backward',
cursorStart: 4,
char: 'z',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'F: skip char at cursor',
type: 'vim_find_char_backward',
cursorStart: 3,
char: 'o',
count: 1,
till: false,
expectedCol: 3,
},
])('$label', ({ type, cursorStart, char, count, till, expectedCol }) => {
const line =
type === 'vim_find_char_forward' ? ['hello world'] : ['hello world'];
const state = createTestState(line, 0, cursorStart);
const result = handleVimAction(state, {
type,
payload: { char, count, till },
});
expect(result.cursorCol).toBe(expectedCol);
});
});
describe('Unicode character support in find operations', () => {
it('vim_find_char_forward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_find_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3); // 'c','a','f','é' — é is at index 3
expect(result.lines[0]).toBe('café world');
});
it('vim_find_char_backward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 9);
const result = handleVimAction(state, {
type: 'vim_find_char_backward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3);
});
it('vim_delete_to_char_forward: handles multi-byte target char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
// Deletes 'caf' + 'é' → ' world' remains
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('vim_delete_to_char_forward (till): stops before multi-byte char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: true },
});
// Deletes 'caf', keeps 'é world'
expect(result.lines[0]).toBe('é world');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_char_forward (df/dt)', () => {
it('df: deletes from cursor through found char (inclusive)', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('dt: deletes from cursor up to (not including) found char', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: true },
});
expect(result.lines[0]).toBe('o world');
expect(result.cursorCol).toBe(0);
});
it('df with count: deletes to Nth occurrence', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 2, till: false },
});
expect(result.lines[0]).toBe('rld');
expect(result.cursorCol).toBe(0);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
it('df: clamps cursor when deleting through the last char on the line', () => {
// cursor at 1 in 'hello'; dfo finds 'o' at col 4 and deletes [1,4] → 'h'
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.lines[0]).toBe('h');
// cursor was at col 1, new line has only col 0 valid
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_char_backward (dF/dT)', () => {
it('dF: deletes from found char through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
// cursor at 7 ('o' in world), dFo finds 'o' at col 4
// delete [4, 8) — both ends inclusive → 'hell' + 'rld'
expect(result.lines[0]).toBe('hellrld');
expect(result.cursorCol).toBe(4);
});
it('dT: deletes from found+1 through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: true },
});
// dTo finds 'o' at col 4, deletes [5, 8) → 'hello' + 'rld'
expect(result.lines[0]).toBe('hellorld');
expect(result.cursorCol).toBe(5);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(4);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
it('dF: clamps cursor when deletion removes chars up to end of line', () => {
// 'hello', cursor on last char 'o' (col 4), dFe finds 'e' at col 1
// deletes [1, 5) → 'h'; without clamp cursor would be at col 1 (past end)
const state = createTestState(['hello'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'e', count: 1, till: false },
});
expect(result.lines[0]).toBe('h');
expect(result.cursorCol).toBe(0);
});
});
});
@@ -24,6 +24,13 @@ import { assumeExhaustive } from '@google/gemini-cli-core';
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_char_before' }
| { type: 'vim_toggle_case' }
| { type: 'vim_replace_char' }
| { type: 'vim_find_char_forward' }
| { type: 'vim_find_char_backward' }
| { type: 'vim_delete_to_char_forward' }
| { type: 'vim_delete_to_char_backward' }
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
@@ -73,6 +80,49 @@ export type VimAction = Extract<
| { type: 'vim_escape_insert_mode' }
>;
/**
* Find the Nth occurrence of `char` in `codePoints`, starting at `start` and
* stepping by `direction` (+1 forward, -1 backward). Returns the index or -1.
*/
function findCharInLine(
codePoints: string[],
char: string,
count: number,
start: number,
direction: 1 | -1,
): number {
let found = -1;
let hits = 0;
for (
let i = start;
direction === 1 ? i < codePoints.length : i >= 0;
i += direction
) {
if (codePoints[i] === char) {
hits++;
if (hits >= count) {
found = i;
break;
}
}
}
return found;
}
/**
* In NORMAL mode the cursor can never rest past the last character of a line.
* Call this after any delete action that stays in NORMAL mode to enforce that
* invariant. Change actions must NOT use this they immediately enter INSERT
* mode where the cursor is allowed to sit at the end of the line.
*/
function clampNormalCursor(state: TextBufferState): TextBufferState {
const line = state.lines[state.cursorRow] || '';
const len = cpLen(line);
const maxCol = Math.max(0, len - 1);
if (state.cursorCol <= maxCol) return state;
return { ...state, cursorCol: maxCol };
}
export function handleVimAction(
state: TextBufferState,
action: VimAction,
@@ -107,7 +157,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -115,6 +165,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_word_forward'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -149,7 +202,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -157,6 +210,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_big_word_forward'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -262,7 +318,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -270,6 +326,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_word_end'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -315,7 +374,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -323,6 +382,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_big_word_end'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -396,12 +458,13 @@ export function handleVimAction(
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
const totalLines = lines.length;
const isDelete = action.type === 'vim_delete_to_end_of_line';
if (count === 1) {
// Single line: delete from cursor to end of current line
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -409,6 +472,7 @@ export function handleVimAction(
cpLen(currentLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
return state;
} else {
@@ -421,7 +485,7 @@ export function handleVimAction(
// No additional lines to delete, just delete to EOL
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -429,6 +493,7 @@ export function handleVimAction(
cpLen(currentLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
return state;
}
@@ -436,7 +501,7 @@ export function handleVimAction(
// Delete from cursor position to end of endRow (including newlines)
const nextState = detachExpandedPaste(pushUndo(state));
const endLine = lines[endRow] || '';
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -444,6 +509,7 @@ export function handleVimAction(
cpLen(endLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
}
@@ -999,7 +1065,7 @@ export function handleVimAction(
if (cursorCol < lineLength) {
const deleteCount = Math.min(count, lineLength - cursorCol);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -1007,6 +1073,7 @@ export function handleVimAction(
cursorCol + deleteCount,
'',
);
return clampNormalCursor(newState);
}
return state;
}
@@ -1183,6 +1250,157 @@ export function handleVimAction(
};
}
case 'vim_delete_char_before': {
const { count } = action.payload;
if (cursorCol > 0) {
const deleteStart = Math.max(0, cursorCol - count);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
deleteStart,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_toggle_case': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const end = Math.min(cursorCol + count, lineLen);
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol; i < end; i++) {
const ch = codePoints[i];
const upper = ch.toUpperCase();
const lower = ch.toLowerCase();
codePoints[i] = ch === upper ? lower : upper;
}
const newLine = codePoints.join('');
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines[cursorRow] = newLine;
const newCol = Math.min(end, lineLen > 0 ? lineLen - 1 : 0);
return {
...nextState,
lines: newLines,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_replace_char': {
const { char, count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const replaceCount = Math.min(count, lineLen - cursorCol);
const replacement = char.repeat(replaceCount);
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cursorCol + replaceCount,
replacement,
);
return {
...resultState,
cursorCol: cursorCol + replaceCount - 1,
preferredCol: null,
};
}
case 'vim_delete_to_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const endCol = till ? found : found + 1;
const nextState = detachExpandedPaste(pushUndo(state));
return clampNormalCursor(
replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
endCol,
'',
),
);
}
case 'vim_delete_to_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const startCol = till ? found + 1 : found;
const endCol = cursorCol + 1; // inclusive: cursor char is part of the deletion
if (startCol >= endCol) return state;
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
return clampNormalCursor({
...resultState,
cursorCol: startCol,
preferredCol: null,
});
}
case 'vim_find_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const newCol = till ? Math.max(cursorCol, found - 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
case 'vim_find_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const newCol = till ? Math.min(cursorCol, found + 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
default: {
// This should never happen if TypeScript is working correctly
assumeExhaustive(action);
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
const mockExtension: RegistryExtension = {
id: 'ext1',
extensionName: 'Test Extension',
extensionDescription: 'A test extension description',
fullName: 'author/test-extension',
extensionVersion: '1.2.3',
rank: 1,
stars: 123,
url: 'https://github.com/author/test-extension',
repoDescription: 'Repo description',
avatarUrl: '',
lastUpdated: '2023-10-27',
hasMCP: true,
hasContext: true,
hasHooks: true,
hasSkills: true,
hasCustomCommands: true,
isGoogleOwned: true,
licenseKey: 'Apache-2.0',
};
describe('ExtensionDetails', () => {
let mockOnBack: ReturnType<typeof vi.fn>;
let mockOnInstall: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockOnBack = vi.fn();
mockOnInstall = vi.fn();
});
const renderDetails = (isInstalled = false) =>
render(
<KeypressProvider>
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>
</KeypressProvider>,
);
it('should render extension details correctly', async () => {
const { lastFrame } = renderDetails();
await waitFor(() => {
expect(lastFrame()).toContain('Test Extension');
expect(lastFrame()).toContain('v1.2.3');
expect(lastFrame()).toContain('123');
expect(lastFrame()).toContain('[G]');
expect(lastFrame()).toContain('author/test-extension');
expect(lastFrame()).toContain('A test extension description');
expect(lastFrame()).toContain('MCP');
expect(lastFrame()).toContain('Context file');
expect(lastFrame()).toContain('Hooks');
expect(lastFrame()).toContain('Skills');
expect(lastFrame()).toContain('Commands');
});
});
it('should show install prompt when not installed', async () => {
const { lastFrame } = renderDetails(false);
await waitFor(() => {
expect(lastFrame()).toContain('[Enter] Install');
expect(lastFrame()).not.toContain('Already Installed');
});
});
it('should show already installed message when installed', async () => {
const { lastFrame } = renderDetails(true);
await waitFor(() => {
expect(lastFrame()).toContain('Already Installed');
expect(lastFrame()).not.toContain('[Enter] Install');
});
});
it('should call onBack when Escape is pressed', async () => {
const { stdin } = renderDetails();
await React.act(async () => {
stdin.write('\x1b'); // Escape
});
await waitFor(() => {
expect(mockOnBack).toHaveBeenCalled();
});
});
it('should call onInstall when Enter is pressed and not installed', async () => {
const { stdin } = renderDetails(false);
await React.act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalled();
});
});
it('should NOT call onInstall when Enter is pressed and already installed', async () => {
vi.useFakeTimers();
const { stdin } = renderDetails(true);
await React.act(async () => {
stdin.write('\r'); // Enter
});
// Advance timers to trigger the keypress flush
await React.act(async () => {
vi.runAllTimers();
});
expect(mockOnInstall).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
@@ -0,0 +1,245 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { theme } from '../../semantic-colors.js';
export interface ExtensionDetailsProps {
extension: RegistryExtension;
onBack: () => void;
onInstall: (
requestConsentOverride: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
isInstalled: boolean;
}
export function ExtensionDetails({
extension,
onBack,
onInstall,
isInstalled,
}: ExtensionDetailsProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const [consentRequest, setConsentRequest] = useState<{
prompt: string;
resolve: (value: boolean) => void;
} | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
useKeypress(
(key) => {
if (consentRequest) {
if (keyMatchers[Command.ESCAPE](key)) {
consentRequest.resolve(false);
setConsentRequest(null);
setIsInstalling(false);
return true;
}
if (keyMatchers[Command.RETURN](key)) {
consentRequest.resolve(true);
setConsentRequest(null);
return true;
}
return false;
}
if (keyMatchers[Command.ESCAPE](key)) {
onBack();
return true;
}
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
setIsInstalling(true);
void onInstall(
(prompt: string) =>
new Promise((resolve) => {
setConsentRequest({ prompt, resolve });
}),
);
return true;
}
return false;
},
{ isActive: true, priority: true },
);
if (consentRequest) {
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.status.warning}
>
<Box marginBottom={1}>
<Text color={theme.text.primary}>{consentRequest.prompt}</Text>
</Box>
<Box flexGrow={1} />
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
<Text color={theme.text.secondary}>[Esc] Cancel</Text>
<Text color={theme.text.primary}>[Enter] Accept</Text>
</Box>
</Box>
);
}
if (isInstalling) {
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
justifyContent="center"
alignItems="center"
>
<Text color={theme.text.primary}>
Installing {extension.extensionName}...
</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
>
{/* Header Row */}
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
<Box>
<Text color={theme.text.secondary}>
{'>'} Extensions {'>'}{' '}
</Text>
<Text color={theme.text.primary} bold>
{extension.extensionName}
</Text>
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '}
|{' '}
</Text>
<Text color={theme.status.warning}> </Text>
<Text color={theme.text.secondary}>
{String(extension.stars || 0)} |{' '}
</Text>
{extension.isGoogleOwned && (
<Text color={theme.text.primary}>[G] </Text>
)}
<Text color={theme.text.primary}>{extension.fullName}</Text>
</Box>
</Box>
{/* Description */}
<Box marginBottom={1}>
<Text color={theme.text.primary}>
{extension.extensionDescription || extension.repoDescription}
</Text>
</Box>
{/* Features List */}
<Box flexDirection="row" marginBottom={1}>
{[
extension.hasMCP && { label: 'MCP', color: theme.text.primary },
extension.hasContext && {
label: 'Context file',
color: theme.status.error,
},
extension.hasHooks && { label: 'Hooks', color: theme.status.warning },
extension.hasSkills && {
label: 'Skills',
color: theme.status.success,
},
extension.hasCustomCommands && {
label: 'Commands',
color: theme.text.primary,
},
]
.filter((f): f is { label: string; color: string } => !!f)
.map((feature, index, array) => (
<Box key={feature.label} flexDirection="row">
<Text color={feature.color}>{feature.label} </Text>
{index < array.length - 1 && (
<Box marginRight={1}>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
</Box>
))}
</Box>
{/* Details about MCP / Context */}
{extension.hasMCP && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will run the following MCP servers:
</Text>
<Box marginLeft={2}>
<Text color={theme.text.primary}>
* {extension.extensionName} (local)
</Text>
</Box>
</Box>
)}
{extension.hasContext && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will append info to your gemini.md context using
gemini.md
</Text>
</Box>
)}
{/* Spacer to push warning to bottom */}
<Box flexGrow={1} />
{/* Warning Box */}
{!isInstalled && (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
paddingX={1}
paddingY={0}
>
<Text color={theme.text.primary}>
The extension you are about to install may have been created by a
third-party developer and sourced{'\n'}
from a public repository. Google does not vet, endorse, or guarantee
the functionality or security{'\n'}
of extensions. Please carefully inspect any extension and its source
code before installing to{'\n'}
understand the permissions it requires and the actions it may
perform.
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
</Box>
</Box>
)}
{isInstalled && (
<Box flexDirection="row" marginTop={1} justifyContent="center">
<Text color={theme.status.success}>Already Installed</Text>
</Box>
)}
</Box>
);
}
@@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useConfig).mockReturnValue({
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getExtensionRegistryURI: vi
.fn()
.mockReturnValue('https://geminicli.com/extensions.json'),
} as unknown as ReturnType<typeof useConfig>);
});
@@ -203,4 +206,34 @@ describe('ExtensionRegistryView', () => {
);
});
});
it('should call onSelect when extension is selected and Enter is pressed in details', async () => {
const { stdin, lastFrame } = renderView();
// Select the first extension in the list (Enter opens details)
await React.act(async () => {
stdin.write('\r');
});
// Verify we are in details view
await waitFor(() => {
expect(lastFrame()).toContain('author/ext1');
expect(lastFrame()).toContain('[Enter] Install');
});
// Ensure onSelect hasn't been called yet
expect(mockOnSelect).not.toHaveBeenCalled();
// Press Enter again in the details view to trigger install
await React.act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith(
mockExtensions[0],
expect.any(Function),
);
});
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useCallback } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
@@ -23,9 +23,13 @@ import type { ExtensionManager } from '../../../config/extension-manager.js';
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionDetails } from './ExtensionDetails.js';
export interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void;
onSelect?: (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
onClose?: () => void;
extensionManager: ExtensionManager;
}
@@ -39,9 +43,14 @@ export function ExtensionRegistryView({
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig();
const { extensions, loading, error, search } = useExtensionRegistry(
'',
config.getExtensionRegistryURI(),
);
const { terminalHeight, staticExtraHeight } = useUIState();
const [selectedExtension, setSelectedExtension] =
useState<RegistryExtension | null>(null);
const { extensionsUpdateState } = useExtensionUpdates(
extensionManager,
@@ -49,7 +58,9 @@ export function ExtensionRegistryView({
config.getEnableExtensionReloading(),
);
const installedExtensions = extensionManager.getExtensions();
const [installedExtensions, setInstalledExtensions] = useState(() =>
extensionManager.getExtensions(),
);
const items: ExtensionItem[] = useMemo(
() =>
@@ -62,11 +73,28 @@ export function ExtensionRegistryView({
[extensions],
);
const handleSelect = useCallback(
(item: ExtensionItem) => {
onSelect?.(item.extension);
const handleSelect = useCallback((item: ExtensionItem) => {
setSelectedExtension(item.extension);
}, []);
const handleBack = useCallback(() => {
setSelectedExtension(null);
}, []);
const handleInstall = useCallback(
async (
extension: RegistryExtension,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) => {
await onSelect?.(extension, requestConsentOverride);
// Refresh installed extensions list
setInstalledExtensions(extensionManager.getExtensions());
// Go back to the search page (list view)
setSelectedExtension(null);
},
[onSelect],
[onSelect, extensionManager],
);
const renderItem = useCallback(
@@ -203,19 +231,41 @@ export function ExtensionRegistryView({
}
return (
<SearchableList<ExtensionItem>
title="Extensions"
items={items}
onSelect={handleSelect}
onClose={onClose || (() => {})}
searchPlaceholder="Search extension gallery"
renderItem={renderItem}
header={header}
footer={footer}
maxItemsToShow={maxItemsToShow}
useSearch={useRegistrySearch}
onSearch={search}
resetSelectionOnItemsChange={true}
/>
<>
<Box
display={selectedExtension ? 'none' : 'flex'}
flexDirection="column"
width="100%"
height="100%"
>
<SearchableList<ExtensionItem>
title="Extensions"
items={items}
onSelect={handleSelect}
onClose={onClose || (() => {})}
searchPlaceholder="Search extension gallery"
renderItem={renderItem}
header={header}
footer={footer}
maxItemsToShow={maxItemsToShow}
useSearch={useRegistrySearch}
onSearch={search}
resetSelectionOnItemsChange={true}
isFocused={!selectedExtension}
/>
</Box>
{selectedExtension && (
<ExtensionDetails
extension={selectedExtension}
onBack={handleBack}
onInstall={async (requestConsentOverride) => {
await handleInstall(selectedExtension, requestConsentOverride);
}}
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}
/>
)}
</>
);
}
@@ -637,6 +637,9 @@ describe('KeypressContext', () => {
describe('Parameterized functional keys', () => {
it.each([
// CSI-u numeric keys
{ sequence: `\x1b[53;5u`, expected: { name: '5', ctrl: true } },
{ sequence: `\x1b[51;2u`, expected: { name: '3', shift: true } },
// ModifyOtherKeys
{ sequence: `\x1b[27;2;13~`, expected: { name: 'enter', shift: true } },
{ sequence: `\x1b[27;5;13~`, expected: { name: 'enter', ctrl: true } },
@@ -665,6 +668,14 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[17~`, expected: { name: 'f6' } },
{ sequence: `\x1b[23~`, expected: { name: 'f11' } },
{ sequence: `\x1b[24~`, expected: { name: 'f12' } },
{ sequence: `\x1b[25~`, expected: { name: 'f13' } },
{ sequence: `\x1b[34~`, expected: { name: 'f20' } },
// Kitty Extended Function Keys (F13-F35)
{ sequence: `\x1b[302u`, expected: { name: 'f13' } },
{ sequence: `\x1b[324u`, expected: { name: 'f35' } },
// Modifier / Special Keys (Kitty Protocol)
{ sequence: `\x1b[57358u`, expected: { name: 'capslock' } },
{ sequence: `\x1b[57362u`, expected: { name: 'pausebreak' } },
// Reverse tabs
{ sequence: `\x1b[Z`, expected: { name: 'tab', shift: true } },
{ sequence: `\x1b[1;2Z`, expected: { name: 'tab', shift: true } },
@@ -820,6 +831,20 @@ describe('KeypressContext', () => {
sequence: '\x1bOn',
expected: { name: '.', sequence: '.', insertable: true },
},
// Kitty Numpad Support (CSI-u)
{
sequence: '\x1b[57404u',
expected: { name: 'numpad5', sequence: '5', insertable: true },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57404;5u',
expected: { name: 'numpad5', ctrl: true, insertable: false },
},
{
sequence: '\x1b[57411u',
expected: { name: 'numpad_multiply', sequence: '*', insertable: true },
},
])(
'should recognize numpad sequence "$sequence" as $expected.name',
({ sequence, expected }) => {
@@ -66,6 +66,14 @@ const KEY_INFO_MAP: Record<
'[21~': { name: 'f10' },
'[23~': { name: 'f11' },
'[24~': { name: 'f12' },
'[25~': { name: 'f13' },
'[26~': { name: 'f14' },
'[28~': { name: 'f15' },
'[29~': { name: 'f16' },
'[31~': { name: 'f17' },
'[32~': { name: 'f18' },
'[33~': { name: 'f19' },
'[34~': { name: 'f20' },
'[A': { name: 'up' },
'[B': { name: 'down' },
'[C': { name: 'right' },
@@ -91,12 +99,6 @@ const KEY_INFO_MAP: Record<
OZ: { name: 'tab', shift: true }, // SS3 Shift+Tab variant for Windows terminals
'[[5~': { name: 'pageup' },
'[[6~': { name: 'pagedown' },
'[9u': { name: 'tab' },
'[13u': { name: 'enter' },
'[27u': { name: 'escape' },
'[32u': { name: 'space' },
'[127u': { name: 'backspace' },
'[57414u': { name: 'enter' }, // Numpad Enter
'[a': { name: 'up', shift: true },
'[b': { name: 'down', shift: true },
'[c': { name: 'right', shift: true },
@@ -122,6 +124,46 @@ const KEY_INFO_MAP: Record<
'[8^': { name: 'end', ctrl: true },
};
// Kitty Keyboard Protocol (CSI u) code mappings
const KITTY_CODE_MAP: Record<number, { name: string; sequence?: string }> = {
2: { name: 'insert' },
3: { name: 'delete' },
5: { name: 'pageup' },
6: { name: 'pagedown' },
9: { name: 'tab' },
13: { name: 'enter' },
14: { name: 'up' },
15: { name: 'down' },
16: { name: 'right' },
17: { name: 'left' },
27: { name: 'escape' },
32: { name: 'space', sequence: ' ' },
127: { name: 'backspace' },
57358: { name: 'capslock' },
57359: { name: 'scrolllock' },
57360: { name: 'numlock' },
57361: { name: 'printscreen' },
57362: { name: 'pausebreak' },
57409: { name: 'numpad_decimal', sequence: '.' },
57410: { name: 'numpad_divide', sequence: '/' },
57411: { name: 'numpad_multiply', sequence: '*' },
57412: { name: 'numpad_subtract', sequence: '-' },
57413: { name: 'numpad_add', sequence: '+' },
57414: { name: 'enter' },
57416: { name: 'numpad_separator', sequence: ',' },
// Function keys F13-F35, not standard, but supported by Kitty
...Object.fromEntries(
Array.from({ length: 23 }, (_, i) => [302 + i, { name: `f${13 + i}` }]),
),
// Numpad keys in Numeric Keypad Mode (CSI u codes 57399-57408)
...Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [
57399 + i,
{ name: `numpad${i}`, sequence: String(i) },
]),
),
};
// Numpad keys in Application Keypad Mode (SS3 sequences)
const NUMPAD_MAP: Record<string, string> = {
Oj: '*',
@@ -565,17 +607,24 @@ function* emitKeys(
}
} else {
name = 'undefined';
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
codeNumber >= 'a'.charCodeAt(0) &&
codeNumber <= 'z'.charCodeAt(0)
) {
name = String.fromCharCode(codeNumber);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
}
}
}
}
@@ -80,7 +80,7 @@ export interface UIActions {
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
dismissBackgroundShell: (pid: number) => Promise<void>;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
@@ -830,8 +830,8 @@ describe('useShellCommandProcessor', () => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.dismissBackgroundShell(1001);
await act(async () => {
await result.current.dismissBackgroundShell(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
@@ -936,8 +936,8 @@ describe('useShellCommandProcessor', () => {
expect(shell?.exitCode).toBe(1);
// Now dismiss it
act(() => {
result.current.dismissBackgroundShell(999);
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
@@ -205,11 +205,11 @@ export const useShellCommandProcessor = (
}, [state.activeShellPtyId, activeToolPtyId, m]);
const dismissBackgroundShell = useCallback(
(pid: number) => {
async (pid: number) => {
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
ShellExecutionService.kill(pid);
await ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
@@ -52,6 +52,7 @@ import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { SkillCommandLoader } from '../../services/SkillCommandLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
import {
type ExtensionUpdateAction,
@@ -324,6 +325,7 @@ export const useSlashCommandProcessor = (
(async () => {
const commandService = await CommandService.create(
[
new SkillCommandLoader(config),
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new FileCommandLoader(config),
@@ -445,6 +447,7 @@ export const useSlashCommandProcessor = (
type: 'schedule_tool',
toolName: result.toolName,
toolArgs: result.toolArgs,
postSubmitPrompt: result.postSubmitPrompt,
};
case 'message':
addItem(
@@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {
export function useExtensionRegistry(
initialQuery = '',
registryURI?: string,
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const client = useMemo(() => new ExtensionRegistryClient(), []);
const client = useMemo(
() => new ExtensionRegistryClient(registryURI),
[registryURI],
);
// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);
@@ -3510,6 +3510,116 @@ describe('useGeminiStream', () => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
});
describe('Race Condition Prevention', () => {
it('should reject concurrent submitQuery when already responding', async () => {
// Stream that stays open (simulates "still responding")
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'First response',
};
// Keep the stream open
await new Promise(() => {});
})(),
);
const { result } = renderTestHook();
// Start first query without awaiting (fire-and-forget, like existing tests)
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
result.current.submitQuery('first query');
});
// Wait for the stream to start responding
await waitFor(() => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});
// Try a second query while first is still responding
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
result.current.submitQuery('second query');
});
// Should have only called sendMessageStream once (second was rejected)
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
it('should allow continuation queries via loop detection retry', async () => {
const mockLoopDetectionService = {
disableForSession: vi.fn(),
};
const mockClient = {
...new MockedGeminiClientClass(mockConfig),
getLoopDetectionService: () => mockLoopDetectionService,
};
mockConfig.getGeminiClient = vi.fn().mockReturnValue(mockClient);
// First call triggers loop detection
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
// Retry call succeeds
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Retry success',
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP' },
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(
result.current.loopDetectionConfirmationRequest,
).not.toBeNull();
});
// User selects "disable" which triggers a continuation query
await act(async () => {
result.current.loopDetectionConfirmationRequest?.onComplete({
userSelection: 'disable',
});
});
// Verify disableForSession was called
expect(
mockLoopDetectionService.disableForSession,
).toHaveBeenCalledTimes(1);
// Continuation query should have gone through (2 total calls)
await waitFor(() => {
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'test query',
expect.any(AbortSignal),
expect.any(String),
undefined,
false,
'test query',
);
});
});
});
});
describe('Agent Execution Events', () => {
+45 -19
View File
@@ -216,7 +216,15 @@ export const useGeminiStream = (
const previousApprovalModeRef = useRef<ApprovalMode>(
config.getApprovalMode(),
);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [isResponding, setIsRespondingState] = useState<boolean>(false);
const isRespondingRef = useRef<boolean>(false);
const setIsResponding = useCallback(
(value: boolean) => {
setIsRespondingState(value);
isRespondingRef.current = value;
},
[setIsRespondingState],
);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
@@ -320,11 +328,14 @@ export const useGeminiStream = (
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
}, []);
const onExec = useCallback(
async (done: Promise<void>) => {
setIsResponding(true);
await done;
setIsResponding(false);
},
[setIsResponding],
);
const {
handleShellCommand,
@@ -538,7 +549,7 @@ export const useGeminiStream = (
setIsResponding(false);
}
prevActiveShellPtyIdRef.current = activeShellPtyId;
}, [activeShellPtyId, addItem]);
}, [activeShellPtyId, addItem, setIsResponding]);
useEffect(() => {
if (
@@ -700,6 +711,7 @@ export const useGeminiStream = (
cancelAllToolCalls,
toolCalls,
activeShellPtyId,
setIsResponding,
]);
useKeypress(
@@ -747,7 +759,8 @@ export const useGeminiStream = (
if (slashCommandResult) {
switch (slashCommandResult.type) {
case 'schedule_tool': {
const { toolName, toolArgs } = slashCommandResult;
const { toolName, toolArgs, postSubmitPrompt } =
slashCommandResult;
const toolCallRequest: ToolCallRequestInfo = {
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: toolName,
@@ -756,6 +769,15 @@ export const useGeminiStream = (
prompt_id,
};
await scheduleToolCalls([toolCallRequest], abortSignal);
if (postSubmitPrompt) {
localQueryToSendToGemini = postSubmitPrompt;
return {
queryToSend: localQueryToSendToGemini,
shouldProceed: true,
};
}
return { queryToSend: null, shouldProceed: false };
}
case 'submit_prompt': {
@@ -952,7 +974,13 @@ export const useGeminiStream = (
setIsResponding(false);
setThought(null); // Reset thought when user cancels
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought],
[
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
setIsResponding,
],
);
const handleErrorEvent = useCallback(
@@ -1035,10 +1063,6 @@ export const useGeminiStream = (
'Response stopped due to prohibited image content.',
[FinishReason.NO_IMAGE]:
'Response stopped because no image was generated.',
[FinishReason.IMAGE_RECITATION]:
'Response stopped due to image recitation policy.',
[FinishReason.IMAGE_OTHER]:
'Response stopped due to other image-related reasons.',
};
const message = finishReasonMessages[finishReason];
@@ -1358,14 +1382,15 @@ export const useGeminiStream = (
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
const queryId = `${Date.now()}-${Math.random()}`;
activeQueryIdRef.current = queryId;
if (
(streamingState === StreamingState.Responding ||
(isRespondingRef.current ||
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!options?.isContinuation
)
return;
const queryId = `${Date.now()}-${Math.random()}`;
activeQueryIdRef.current = queryId;
const userMessageTimestamp = Date.now();
@@ -1452,7 +1477,7 @@ export const useGeminiStream = (
loopDetectedRef.current = false;
// Show the confirmation dialog to choose whether to disable loop detection
setLoopDetectionConfirmationRequest({
onComplete: (result: {
onComplete: async (result: {
userSelection: 'disable' | 'keep';
}) => {
setLoopDetectionConfirmationRequest(null);
@@ -1468,8 +1493,7 @@ export const useGeminiStream = (
});
if (lastQueryRef.current && lastPromptIdRef.current) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
submitQuery(
await submitQuery(
lastQueryRef.current,
{ isContinuation: true },
lastPromptIdRef.current,
@@ -1537,6 +1561,7 @@ export const useGeminiStream = (
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
settings.merged.billing?.overageStrategy,
setIsResponding,
],
);
@@ -1803,6 +1828,7 @@ export const useGeminiStream = (
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
setIsResponding,
],
);
@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import type { KeyMatchers } from '../key/keyMatchers.js';
import { defaultKeyMatchers } from '../key/keyMatchers.js';
/**
* Hook to retrieve the currently active key matchers.
* This prepares the codebase for dynamic or custom key bindings in the future.
*/
export function useKeyMatchers(): KeyMatchers {
return useMemo(() => defaultKeyMatchers, []);
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { createContext, useContext } from 'react';
import type { KeyMatchers } from '../key/keyMatchers.js';
import { defaultKeyMatchers } from '../key/keyMatchers.js';
export const KeyMatchersContext =
createContext<KeyMatchers>(defaultKeyMatchers);
export const KeyMatchersProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: KeyMatchers;
}): React.JSX.Element => (
<KeyMatchersContext.Provider value={value}>
{children}
</KeyMatchersContext.Provider>
);
/**
* Hook to retrieve the currently active key matchers.
* Defaults to defaultKeyMatchers if no provider is present, allowing tests to run without explicit wrappers.
*/
export function useKeyMatchers(): KeyMatchers {
return useContext(KeyMatchersContext);
}
@@ -97,7 +97,7 @@ export function useToolScheduler(
const scheduler = useMemo(
() =>
new Scheduler({
config,
context: config,
messageBus,
getPreferredEditor: () => getPreferredEditorRef.current(),
schedulerId: ROOT_SCHEDULER_ID,
+451 -3
View File
@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import type React from 'react';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
@@ -166,6 +174,13 @@ describe('useVim hook', () => {
vimChangeBigWordBackward: vi.fn(),
vimChangeBigWordEnd: vi.fn(),
vimDeleteChar: vi.fn(),
vimDeleteCharBefore: vi.fn(),
vimToggleCase: vi.fn(),
vimReplaceChar: vi.fn(),
vimFindCharForward: vi.fn(),
vimFindCharBackward: vi.fn(),
vimDeleteToCharForward: vi.fn(),
vimDeleteToCharBackward: vi.fn(),
vimInsertAtCursor: vi.fn(),
vimAppendAtCursor: vi.fn().mockImplementation(() => {
// Append moves cursor right (vim 'a' behavior - position after current char)
@@ -1029,9 +1044,10 @@ describe('useVim hook', () => {
});
// Should delete "world" (no trailing space at end), leaving "hello "
// Cursor clamps to last valid index in NORMAL mode (col 5 = trailing space)
expect(result.lines).toEqual(['hello ']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(6);
expect(result.cursorCol).toBe(5);
});
it('should delete multiple words with count', () => {
@@ -1711,7 +1727,8 @@ describe('useVim hook', () => {
count: 1,
expectedLines: ['hello '],
expectedCursorRow: 0,
expectedCursorCol: 6,
// Cursor clamps to last valid index in NORMAL mode (col 5 = trailing space)
expectedCursorCol: 5,
},
{
command: 'D',
@@ -1939,4 +1956,435 @@ describe('useVim hook', () => {
expect(handled!).toBe(false);
});
});
describe('Character deletion and case toggle (X, ~)', () => {
it('X: should call vimDeleteCharBefore', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(1);
});
it('~: should call vimToggleCase', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: '~' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(1);
});
it('X can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(2);
});
it('~ can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(2);
});
it('3X calls vimDeleteCharBefore with count=3', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '3' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(3);
});
it('2~ calls vimToggleCase with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(2);
});
});
describe('Replace character (r)', () => {
it('r{char}: should call vimReplaceChar with the next key', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 1);
});
it('r: should consume the pending char without passing through', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let rHandled: boolean;
let charHandled: boolean;
act(() => {
rHandled = result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
charHandled = result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(rHandled!).toBe(true);
expect(charHandled!).toBe(true);
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('a', 1);
});
it('Escape cancels pending r (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
act(() => {
result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(mockBuffer.vimReplaceChar).not.toHaveBeenCalled();
});
it('2rx calls vimReplaceChar with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 2);
});
it('r{char} is dot-repeatable', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'z' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('z', 1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimReplaceChar).toHaveBeenLastCalledWith('z', 1);
});
});
describe('Character find motions (f, F, t, T, ;, ,)', () => {
type FindCase = {
key: string;
char: string;
mockFn: 'vimFindCharForward' | 'vimFindCharBackward';
till: boolean;
};
it.each<FindCase>([
{ key: 'f', char: 'o', mockFn: 'vimFindCharForward', till: false },
{ key: 'F', char: 'o', mockFn: 'vimFindCharBackward', till: false },
{ key: 't', char: 'w', mockFn: 'vimFindCharForward', till: true },
{ key: 'T', char: 'w', mockFn: 'vimFindCharBackward', till: true },
])(
'$key{char}: calls $mockFn (till=$till)',
({ key, char, mockFn, till }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: key }));
});
act(() => {
result.current.handleInput(createKey({ sequence: char }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith(char, 1, till);
},
);
it(';: should repeat last f forward find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ;
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimFindCharForward).toHaveBeenLastCalledWith(
'o',
1,
false,
);
});
it(',: should repeat last f find in reverse direction', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ,
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharBackward).toHaveBeenCalledWith(
'o',
1,
false,
);
});
it('; and , should do nothing if no prior find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
});
it('Escape cancels pending f (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
// o should NOT be consumed as find target
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
});
it('2fo calls vimFindCharForward with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledWith('o', 2, false);
});
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', () => {
it('df{char}: executes delete-to-char, not a dangling operator', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'x',
1,
false,
);
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
// Next key is a fresh normal-mode command — no dangling state
act(() => {
result.current.handleInput(createKey({ sequence: 'l' }));
});
expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1);
});
// operator + find/till motions (df, dt, dF, dT, cf, ct, ...)
type OperatorFindCase = {
operator: string;
findKey: string;
mockFn: 'vimDeleteToCharForward' | 'vimDeleteToCharBackward';
till: boolean;
entersInsert: boolean;
};
it.each<OperatorFindCase>([
{
operator: 'd',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: false,
},
{
operator: 'd',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: false,
},
{
operator: 'c',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: true,
},
{
operator: 'c',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: true,
},
])(
'$operator$findKey{char}: calls $mockFn (till=$till, insert=$entersInsert)',
({ operator, findKey, mockFn, till, entersInsert }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: operator }));
});
act(() => {
result.current.handleInput(createKey({ sequence: findKey }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith('o', 1, till);
if (entersInsert) {
expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT');
}
},
);
it('2df{char}: count is passed through to vimDeleteToCharForward', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'o',
2,
false,
);
});
});
});
+161 -8
View File
@@ -11,6 +11,7 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
import { toCodePoints } from '../utils/textUtils.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -35,6 +36,9 @@ const CMD_TYPES = {
CHANGE_BIG_WORD_BACKWARD: 'cB',
CHANGE_BIG_WORD_END: 'cE',
DELETE_CHAR: 'x',
DELETE_CHAR_BEFORE: 'X',
TOGGLE_CASE: '~',
REPLACE_CHAR: 'r',
DELETE_LINE: 'dd',
CHANGE_LINE: 'cc',
DELETE_TO_EOL: 'D',
@@ -61,18 +65,25 @@ const CMD_TYPES = {
CHANGE_TO_LAST_LINE: 'cG',
} as const;
// Helper function to clear pending state
type PendingFindOp = {
op: 'f' | 'F' | 't' | 'T' | 'r';
operator: 'd' | 'c' | undefined;
count: number; // captured at keypress time, before CLEAR_PENDING_STATES resets it
};
const createClearPendingState = () => ({
count: 0,
pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null,
pendingFindOp: undefined as PendingFindOp | undefined,
});
// State and action types for useReducer
type VimState = {
mode: VimMode;
count: number;
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
lastCommand: { type: string; count: number } | null;
pendingFindOp: PendingFindOp | undefined;
lastCommand: { type: string; count: number; char?: string } | null;
lastFind: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
};
type VimAction =
@@ -84,9 +95,14 @@ type VimAction =
type: 'SET_PENDING_OPERATOR';
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
}
| { type: 'SET_PENDING_FIND_OP'; pendingFindOp: PendingFindOp | undefined }
| {
type: 'SET_LAST_FIND';
find: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
}
| {
type: 'SET_LAST_COMMAND';
command: { type: string; count: number } | null;
command: { type: string; count: number; char?: string } | null;
}
| { type: 'CLEAR_PENDING_STATES' }
| { type: 'ESCAPE_TO_NORMAL' };
@@ -95,7 +111,9 @@ const initialVimState: VimState = {
mode: 'INSERT',
count: 0,
pendingOperator: null,
pendingFindOp: undefined,
lastCommand: null,
lastFind: undefined,
};
// Reducer function
@@ -116,6 +134,12 @@ const vimReducer = (state: VimState, action: VimAction): VimState => {
case 'SET_PENDING_OPERATOR':
return { ...state, pendingOperator: action.operator };
case 'SET_PENDING_FIND_OP':
return { ...state, pendingFindOp: action.pendingFindOp };
case 'SET_LAST_FIND':
return { ...state, lastFind: action.find };
case 'SET_LAST_COMMAND':
return { ...state, lastCommand: action.command };
@@ -195,7 +219,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
/** Executes common commands to eliminate duplication in dot (.) repeat command */
const executeCommand = useCallback(
(cmdType: string, count: number) => {
(cmdType: string, count: number, char?: string) => {
switch (cmdType) {
case CMD_TYPES.DELETE_WORD_FORWARD: {
buffer.vimDeleteWordForward(count);
@@ -268,6 +292,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
break;
}
case CMD_TYPES.DELETE_CHAR_BEFORE: {
buffer.vimDeleteCharBefore(count);
break;
}
case CMD_TYPES.TOGGLE_CASE: {
buffer.vimToggleCase(count);
break;
}
case CMD_TYPES.REPLACE_CHAR: {
if (char) buffer.vimReplaceChar(char, count);
break;
}
case CMD_TYPES.DELETE_LINE: {
buffer.vimDeleteLine(count);
break;
@@ -597,7 +636,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Handle NORMAL mode
if (state.mode === 'NORMAL') {
if (keyMatchers[Command.ESCAPE](normalizedKey)) {
if (state.pendingOperator) {
if (state.pendingOperator || state.pendingFindOp) {
dispatch({ type: 'CLEAR_PENDING_STATES' });
lastEscapeTimestampRef.current = 0;
return true; // Handled by vim
@@ -627,6 +666,47 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const repeatCount = getCurrentCount();
// Handle pending find/till/replace — consume the next char as the target
if (state.pendingFindOp !== undefined) {
const targetChar = normalizedKey.sequence;
const { op, operator, count: findCount } = state.pendingFindOp;
dispatch({ type: 'SET_PENDING_FIND_OP', pendingFindOp: undefined });
dispatch({ type: 'CLEAR_COUNT' });
if (targetChar && toCodePoints(targetChar).length === 1) {
if (op === 'r') {
buffer.vimReplaceChar(targetChar, findCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.REPLACE_CHAR,
count: findCount,
char: targetChar,
},
});
} else {
const isBackward = op === 'F' || op === 'T';
const isTill = op === 't' || op === 'T';
if (operator === 'd' || operator === 'c') {
const del = isBackward
? buffer.vimDeleteToCharBackward
: buffer.vimDeleteToCharForward;
del(targetChar, findCount, isTill);
if (operator === 'c') updateMode('INSERT');
} else {
const find = isBackward
? buffer.vimFindCharBackward
: buffer.vimFindCharForward;
find(targetChar, findCount, isTill);
dispatch({
type: 'SET_LAST_FIND',
find: { op, char: targetChar },
});
}
}
}
return true;
}
switch (normalizedKey.sequence) {
case 'h': {
// Check if this is part of a delete or change command (dh/ch)
@@ -789,8 +869,79 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return true;
}
case 'X': {
buffer.vimDeleteCharBefore(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.DELETE_CHAR_BEFORE,
count: repeatCount,
},
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '~': {
buffer.vimToggleCase(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.TOGGLE_CASE, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'r': {
// Replace char: next keypress is the replacement. Not composable with d/c.
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: {
op: 'r',
operator: undefined,
count: repeatCount,
},
});
return true;
}
case 'f':
case 'F':
case 't':
case 'T': {
const op = normalizedKey.sequence;
const operator =
state.pendingOperator === 'd' || state.pendingOperator === 'c'
? state.pendingOperator
: undefined;
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: { op, operator, count: repeatCount },
});
return true;
}
case ';':
case ',': {
if (state.lastFind) {
const { op, char } = state.lastFind;
const isForward = op === 'f' || op === 't';
const isTill = op === 't' || op === 'T';
const reverse = normalizedKey.sequence === ',';
const shouldMoveForward = reverse ? !isForward : isForward;
if (shouldMoveForward) {
buffer.vimFindCharForward(char, repeatCount, isTill);
} else {
buffer.vimFindCharBackward(char, repeatCount, isTill);
}
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'i': {
// Enter INSERT mode at current position
buffer.vimInsertAtCursor();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
@@ -1107,7 +1258,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const count = state.count > 0 ? state.count : cmdData.count;
// All repeatable commands are now handled by executeCommand
executeCommand(cmdData.type, count);
executeCommand(cmdData.type, count, cmdData.char);
}
dispatch({ type: 'CLEAR_COUNT' });
@@ -1194,7 +1345,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
state.mode,
state.count,
state.pendingOperator,
state.pendingFindOp,
state.lastCommand,
state.lastFind,
dispatch,
getCurrentCount,
handleChangeMovement,
+101 -30
View File
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Storage } from '@google/gemini-cli-core';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
defaultKeyBindingConfig,
KeyBinding,
loadCustomKeybindings,
} from './keyBindings.js';
describe('KeyBinding', () => {
@@ -93,37 +97,15 @@ describe('KeyBinding', () => {
'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
);
});
it('should throw an error for literal "+" as key (must use "=")', () => {
// VS Code style peeling logic results in "+" as the remains
expect(() => new KeyBinding('alt++')).toThrow(
'Invalid keybinding key: "+" in "alt++"',
);
});
});
});
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
it('should have bindings for all commands', () => {
const commands = Object.values(Command);
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
}
});
it('should export all required types', () => {
// Basic type checks
expect(typeof Command.HOME).toBe('string');
expect(typeof Command.END).toBe('string');
// Config should be readonly
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
it('should have bindings for all commands', () => {
for (const command of Object.values(Command)) {
expect(defaultKeyBindingConfig.has(command)).toBe(true);
expect(defaultKeyBindingConfig.get(command)?.length).toBeGreaterThan(0);
}
});
describe('command metadata', () => {
@@ -157,3 +139,92 @@ describe('keyBindings config', () => {
});
});
});
describe('loadCustomKeybindings', () => {
let tempDir: string;
let tempFilePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-keybindings-test-'),
);
tempFilePath = path.join(tempDir, 'keybindings.json');
vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('returns default bindings when file does not exist', async () => {
// We don't write the file.
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
});
it('merges valid custom bindings, prepending them to defaults', async () => {
const customJson = JSON.stringify([
{ command: Command.RETURN, key: 'ctrl+a' },
]);
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.RETURN)).toEqual([
new KeyBinding('ctrl+a'),
new KeyBinding('enter'),
]);
});
it('handles JSON with comments', async () => {
const customJson = `
[
// This is a comment
{ "command": "${Command.QUIT}", "key": "ctrl+x" }
]
`;
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.QUIT)).toEqual([
new KeyBinding('ctrl+x'),
new KeyBinding('ctrl+c'),
]);
});
it('returns validation errors for invalid schema', async () => {
const invalidJson = JSON.stringify([{ command: 'unknown', key: 'a' }]);
await fs.writeFile(tempFilePath, invalidJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toMatch(/error at 0.command: Invalid enum value/);
// Should still have defaults
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
});
it('returns validation errors for invalid key patterns but loads valid ones', async () => {
const mixedJson = JSON.stringify([
{ command: Command.RETURN, key: 'super+a' }, // invalid
{ command: Command.QUIT, key: 'ctrl+y' }, // valid
]);
await fs.writeFile(tempFilePath, mixedJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors.length).toBe(1);
expect(errors[0]).toMatch(/Invalid keybinding/);
expect(config.get(Command.QUIT)).toEqual([
new KeyBinding('ctrl+y'),
new KeyBinding('ctrl+c'),
]);
});
});
+237 -156
View File
@@ -4,6 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { z } from 'zod';
import { parse as parseIgnoringComments } from 'comment-json';
import { isNodeError, Storage } from '@google/gemini-cli-core';
/**
* Command enum for all available keyboard shortcuts
*/
@@ -73,16 +78,6 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
@@ -98,27 +93,26 @@ export enum Command {
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
BACKGROUND_SHELL_SELECT = 'background.select',
TOGGLE_BACKGROUND_SHELL = 'background.toggle',
TOGGLE_BACKGROUND_SHELL_LIST = 'background.toggleList',
KILL_BACKGROUND_SHELL = 'background.kill',
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning',
}
/**
* Data-driven key binding structure for user configuration
*/
export class KeyBinding {
private static readonly VALID_KEYS = new Set([
// Letters & Numbers
...'abcdefghijklmnopqrstuvwxyz0123456789',
// Punctuation
'`',
'-',
'=',
'[',
']',
'\\',
';',
"'",
',',
'.',
'/',
private static readonly VALID_LONG_KEYS = new Set([
...Array.from({ length: 35 }, (_, i) => `f${i + 1}`), // Function Keys
...Array.from({ length: 10 }, (_, i) => `numpad${i}`), // Numpad Numbers
// Navigation & Actions
'left',
'up',
@@ -134,15 +128,12 @@ export class KeyBinding {
'space',
'backspace',
'delete',
'clear',
'pausebreak',
'capslock',
'insert',
'numlock',
'scrolllock',
// Function Keys
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`),
// Numpad
...Array.from({ length: 10 }, (_, i) => `numpad${i}`),
'numpad_multiply',
'numpad_add',
'numpad_separator',
@@ -201,8 +192,11 @@ export class KeyBinding {
const key = remains;
if (!KeyBinding.VALID_KEYS.has(key)) {
throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
@@ -226,151 +220,172 @@ export class KeyBinding {
/**
* Configuration type mapping commands to their key bindings
*/
export type KeyBindingConfig = {
readonly [C in Command]: readonly KeyBinding[];
};
export type KeyBindingConfig = Map<Command, readonly KeyBinding[]>;
/**
* Default key binding configuration
* Matches the original hard-coded logic exactly
*/
export const defaultKeyBindings: KeyBindingConfig = {
export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Basic Controls
[Command.RETURN]: [new KeyBinding('enter')],
[Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
[Command.QUIT]: [new KeyBinding('ctrl+c')],
[Command.EXIT]: [new KeyBinding('ctrl+d')],
[Command.RETURN, [new KeyBinding('enter')]],
[Command.ESCAPE, [new KeyBinding('escape'), new KeyBinding('ctrl+[')]],
[Command.QUIT, [new KeyBinding('ctrl+c')]],
[Command.EXIT, [new KeyBinding('ctrl+d')]],
// Cursor Movement
[Command.HOME]: [new KeyBinding('ctrl+a'), new KeyBinding('home')],
[Command.END]: [new KeyBinding('ctrl+e'), new KeyBinding('end')],
[Command.MOVE_UP]: [new KeyBinding('up')],
[Command.MOVE_DOWN]: [new KeyBinding('down')],
[Command.MOVE_LEFT]: [new KeyBinding('left')],
[Command.MOVE_RIGHT]: [new KeyBinding('right'), new KeyBinding('ctrl+f')],
[Command.MOVE_WORD_LEFT]: [
new KeyBinding('ctrl+left'),
new KeyBinding('alt+left'),
new KeyBinding('alt+b'),
[Command.HOME, [new KeyBinding('ctrl+a'), new KeyBinding('home')]],
[Command.END, [new KeyBinding('ctrl+e'), new KeyBinding('end')]],
[Command.MOVE_UP, [new KeyBinding('up')]],
[Command.MOVE_DOWN, [new KeyBinding('down')]],
[Command.MOVE_LEFT, [new KeyBinding('left')]],
[Command.MOVE_RIGHT, [new KeyBinding('right'), new KeyBinding('ctrl+f')]],
[
Command.MOVE_WORD_LEFT,
[
new KeyBinding('ctrl+left'),
new KeyBinding('alt+left'),
new KeyBinding('alt+b'),
],
],
[Command.MOVE_WORD_RIGHT]: [
new KeyBinding('ctrl+right'),
new KeyBinding('alt+right'),
new KeyBinding('alt+f'),
[
Command.MOVE_WORD_RIGHT,
[
new KeyBinding('ctrl+right'),
new KeyBinding('alt+right'),
new KeyBinding('alt+f'),
],
],
// Editing
[Command.KILL_LINE_RIGHT]: [new KeyBinding('ctrl+k')],
[Command.KILL_LINE_LEFT]: [new KeyBinding('ctrl+u')],
[Command.CLEAR_INPUT]: [new KeyBinding('ctrl+c')],
[Command.DELETE_WORD_BACKWARD]: [
new KeyBinding('ctrl+backspace'),
new KeyBinding('alt+backspace'),
new KeyBinding('ctrl+w'),
[Command.KILL_LINE_RIGHT, [new KeyBinding('ctrl+k')]],
[Command.KILL_LINE_LEFT, [new KeyBinding('ctrl+u')]],
[Command.CLEAR_INPUT, [new KeyBinding('ctrl+c')]],
[
Command.DELETE_WORD_BACKWARD,
[
new KeyBinding('ctrl+backspace'),
new KeyBinding('alt+backspace'),
new KeyBinding('ctrl+w'),
],
],
[Command.DELETE_WORD_FORWARD]: [
new KeyBinding('ctrl+delete'),
new KeyBinding('alt+delete'),
new KeyBinding('alt+d'),
[
Command.DELETE_WORD_FORWARD,
[
new KeyBinding('ctrl+delete'),
new KeyBinding('alt+delete'),
new KeyBinding('alt+d'),
],
],
[Command.DELETE_CHAR_LEFT]: [
new KeyBinding('backspace'),
new KeyBinding('ctrl+h'),
[
Command.DELETE_CHAR_LEFT,
[new KeyBinding('backspace'), new KeyBinding('ctrl+h')],
],
[Command.DELETE_CHAR_RIGHT]: [
new KeyBinding('delete'),
new KeyBinding('ctrl+d'),
[
Command.DELETE_CHAR_RIGHT,
[new KeyBinding('delete'), new KeyBinding('ctrl+d')],
],
[Command.UNDO]: [new KeyBinding('cmd+z'), new KeyBinding('alt+z')],
[Command.REDO]: [
new KeyBinding('ctrl+shift+z'),
new KeyBinding('cmd+shift+z'),
new KeyBinding('alt+shift+z'),
[Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]],
[
Command.REDO,
[
new KeyBinding('ctrl+shift+z'),
new KeyBinding('cmd+shift+z'),
new KeyBinding('alt+shift+z'),
],
],
// Scrolling
[Command.SCROLL_UP]: [new KeyBinding('shift+up')],
[Command.SCROLL_DOWN]: [new KeyBinding('shift+down')],
[Command.SCROLL_HOME]: [
new KeyBinding('ctrl+home'),
new KeyBinding('shift+home'),
[Command.SCROLL_UP, [new KeyBinding('shift+up')]],
[Command.SCROLL_DOWN, [new KeyBinding('shift+down')]],
[
Command.SCROLL_HOME,
[new KeyBinding('ctrl+home'), new KeyBinding('shift+home')],
],
[Command.SCROLL_END]: [
new KeyBinding('ctrl+end'),
new KeyBinding('shift+end'),
[
Command.SCROLL_END,
[new KeyBinding('ctrl+end'), new KeyBinding('shift+end')],
],
[Command.PAGE_UP]: [new KeyBinding('pageup')],
[Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
[Command.PAGE_UP, [new KeyBinding('pageup')]],
[Command.PAGE_DOWN, [new KeyBinding('pagedown')]],
// History & Search
[Command.HISTORY_UP]: [new KeyBinding('ctrl+p')],
[Command.HISTORY_DOWN]: [new KeyBinding('ctrl+n')],
[Command.REVERSE_SEARCH]: [new KeyBinding('ctrl+r')],
[Command.SUBMIT_REVERSE_SEARCH]: [new KeyBinding('enter')],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
[Command.HISTORY_UP, [new KeyBinding('ctrl+p')]],
[Command.HISTORY_DOWN, [new KeyBinding('ctrl+n')]],
[Command.REVERSE_SEARCH, [new KeyBinding('ctrl+r')]],
[Command.SUBMIT_REVERSE_SEARCH, [new KeyBinding('enter')]],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, [new KeyBinding('tab')]],
// Navigation
[Command.NAVIGATION_UP]: [new KeyBinding('up')],
[Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
[Command.NAVIGATION_UP, [new KeyBinding('up')]],
[Command.NAVIGATION_DOWN, [new KeyBinding('down')]],
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
[Command.DIALOG_NAVIGATION_UP]: [new KeyBinding('up'), new KeyBinding('k')],
[Command.DIALOG_NAVIGATION_DOWN]: [
new KeyBinding('down'),
new KeyBinding('j'),
[Command.DIALOG_NAVIGATION_UP, [new KeyBinding('up'), new KeyBinding('k')]],
[
Command.DIALOG_NAVIGATION_DOWN,
[new KeyBinding('down'), new KeyBinding('j')],
],
[Command.DIALOG_NEXT]: [new KeyBinding('tab')],
[Command.DIALOG_PREV]: [new KeyBinding('shift+tab')],
[Command.DIALOG_NEXT, [new KeyBinding('tab')]],
[Command.DIALOG_PREV, [new KeyBinding('shift+tab')]],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [new KeyBinding('tab'), new KeyBinding('enter')],
[Command.COMPLETION_UP]: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
[Command.COMPLETION_DOWN]: [new KeyBinding('down'), new KeyBinding('ctrl+n')],
[Command.EXPAND_SUGGESTION]: [new KeyBinding('right')],
[Command.COLLAPSE_SUGGESTION]: [new KeyBinding('left')],
[Command.ACCEPT_SUGGESTION, [new KeyBinding('tab'), new KeyBinding('enter')]],
[Command.COMPLETION_UP, [new KeyBinding('up'), new KeyBinding('ctrl+p')]],
[Command.COMPLETION_DOWN, [new KeyBinding('down'), new KeyBinding('ctrl+n')]],
[Command.EXPAND_SUGGESTION, [new KeyBinding('right')]],
[Command.COLLAPSE_SUGGESTION, [new KeyBinding('left')]],
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT]: [new KeyBinding('enter')],
[Command.NEWLINE]: [
new KeyBinding('ctrl+enter'),
new KeyBinding('cmd+enter'),
new KeyBinding('alt+enter'),
new KeyBinding('shift+enter'),
new KeyBinding('ctrl+j'),
[Command.SUBMIT, [new KeyBinding('enter')]],
[
Command.NEWLINE,
[
new KeyBinding('ctrl+enter'),
new KeyBinding('cmd+enter'),
new KeyBinding('alt+enter'),
new KeyBinding('shift+enter'),
new KeyBinding('ctrl+j'),
],
],
[Command.OPEN_EXTERNAL_EDITOR]: [new KeyBinding('ctrl+x')],
[Command.PASTE_CLIPBOARD]: [
new KeyBinding('ctrl+v'),
new KeyBinding('cmd+v'),
new KeyBinding('alt+v'),
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[
Command.PASTE_CLIPBOARD,
[
new KeyBinding('ctrl+v'),
new KeyBinding('cmd+v'),
new KeyBinding('alt+v'),
],
],
// App Controls
[Command.SHOW_ERROR_DETAILS]: [new KeyBinding('f12')],
[Command.SHOW_FULL_TODOS]: [new KeyBinding('ctrl+t')],
[Command.SHOW_IDE_CONTEXT_DETAIL]: [new KeyBinding('ctrl+g')],
[Command.TOGGLE_MARKDOWN]: [new KeyBinding('alt+m')],
[Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')],
[Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')],
[Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')],
[Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')],
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')],
[Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')],
[Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
[Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('enter')],
[Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
[Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')],
[Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')],
[Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')],
[Command.UNFOCUS_SHELL_INPUT]: [new KeyBinding('shift+tab')],
[Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')],
[Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')],
[Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')],
};
[Command.SHOW_ERROR_DETAILS, [new KeyBinding('f12')]],
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
[Command.EXPAND_PASTE, [new KeyBinding('ctrl+o')]],
[Command.FOCUS_SHELL_INPUT, [new KeyBinding('tab')]],
[Command.UNFOCUS_SHELL_INPUT, [new KeyBinding('shift+tab')]],
[Command.CLEAR_SCREEN, [new KeyBinding('ctrl+l')]],
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
[Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]],
[Command.TOGGLE_BACKGROUND_SHELL, [new KeyBinding('ctrl+b')]],
[Command.TOGGLE_BACKGROUND_SHELL_LIST, [new KeyBinding('ctrl+l')]],
[Command.KILL_BACKGROUND_SHELL, [new KeyBinding('ctrl+k')]],
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
]);
interface CommandCategory {
readonly title: string;
@@ -475,20 +490,25 @@ export const commandCategories: readonly CommandCategory[] = [
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.EXPAND_PASTE,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
Command.BACKGROUND_SHELL_SELECT,
Command.BACKGROUND_SHELL_ESCAPE,
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
Command.RESTART_APP,
Command.SUSPEND_APP,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
],
},
{
title: 'Background Shell Controls',
commands: [
Command.BACKGROUND_SHELL_ESCAPE,
Command.BACKGROUND_SHELL_SELECT,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
],
},
];
@@ -576,9 +596,18 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Expand and collapse blocks of content when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from shell input.',
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
[Command.TOGGLE_BACKGROUND_SHELL]:
'Toggle current background shell visibility.',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
@@ -589,11 +618,63 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from background shell.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from shell input.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
};
const keybindingsSchema = z.array(
z.object({
command: z.nativeEnum(Command),
key: z.string(),
}),
);
/**
* Loads custom keybindings from the user's keybindings.json file.
* Keybindings are merged with the default bindings.
*/
export async function loadCustomKeybindings(): Promise<{
config: KeyBindingConfig;
errors: string[];
}> {
const errors: string[] = [];
let config = defaultKeyBindingConfig;
const userKeybindingsPath = Storage.getUserKeybindingsPath();
try {
const content = await fs.readFile(userKeybindingsPath, 'utf8');
const parsedJson = parseIgnoringComments(content);
const result = keybindingsSchema.safeParse(parsedJson);
if (result.success) {
config = new Map(defaultKeyBindingConfig);
for (const { command, key } of result.data) {
const currentBindings = config.get(command) ?? [];
try {
const keyBinding = new KeyBinding(key);
// Add new binding (prepend so it's the primary one shown in UI)
config.set(command, [keyBinding, ...currentBindings]);
} catch (e) {
errors.push(`Invalid keybinding for command "${command}": ${e}`);
}
}
} else {
errors.push(
...result.error.issues.map(
(issue) =>
`Keybindings file "${userKeybindingsPath}" error at ${issue.path.join('.')}: ${issue.message}`,
),
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
// File doesn't exist, use default bindings
} else {
errors.push(
`Error reading keybindings file "${userKeybindingsPath}": ${error}`,
);
}
}
return { config, errors };
}
+71 -26
View File
@@ -4,28 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Storage } from '@google/gemini-cli-core';
import {
defaultKeyMatchers,
Command,
createKeyMatchers,
loadKeyMatchers,
} from './keyMatchers.js';
import type { KeyBindingConfig } from './keyBindings.js';
import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
import { defaultKeyBindingConfig, KeyBinding } from './keyBindings.js';
import type { Key } from '../hooks/useKeypress.js';
describe('keyMatchers', () => {
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
});
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
});
describe('keyMatchers', () => {
// Test data for each command with positive and negative test cases
const testCases = [
// Basic bindings
@@ -443,10 +447,11 @@ describe('keyMatchers', () => {
describe('Custom key bindings', () => {
it('should work with custom configuration', () => {
const customConfig: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('0')],
};
const customConfig = new Map(defaultKeyBindingConfig);
customConfig.set(Command.HOME, [
new KeyBinding('ctrl+h'),
new KeyBinding('0'),
]);
const customMatchers = createKeyMatchers(customConfig);
@@ -460,10 +465,11 @@ describe('keyMatchers', () => {
});
it('should support multiple key bindings for same command', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.QUIT]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
};
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [
new KeyBinding('ctrl+q'),
new KeyBinding('alt+q'),
]);
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
@@ -473,10 +479,8 @@ describe('keyMatchers', () => {
describe('Edge Cases', () => {
it('should handle empty binding arrays', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [],
};
const config = new Map(defaultKeyBindingConfig);
config.set(Command.HOME, []);
const matchers = createKeyMatchers(config);
expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
@@ -485,3 +489,44 @@ describe('keyMatchers', () => {
});
});
});
describe('loadKeyMatchers integration', () => {
let tempDir: string;
let tempFilePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-keymatchers-test-'),
);
tempFilePath = path.join(tempDir, 'keybindings.json');
vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('loads matchers from a real file on disk', async () => {
const customJson = JSON.stringify([
{ command: Command.QUIT, key: 'ctrl+y' },
]);
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { matchers, errors } = await loadKeyMatchers();
expect(errors).toHaveLength(0);
// User binding matches
expect(matchers[Command.QUIT](createKey('y', { ctrl: true }))).toBe(true);
// Default binding still matches as fallback
expect(matchers[Command.QUIT](createKey('c', { ctrl: true }))).toBe(true);
});
it('returns errors when the file on disk is invalid', async () => {
await fs.writeFile(tempFilePath, 'invalid json {', 'utf8');
const { errors } = await loadKeyMatchers();
expect(errors.length).toBeGreaterThan(0);
});
});
+27 -6
View File
@@ -6,7 +6,11 @@
import type { Key } from '../hooks/useKeypress.js';
import type { KeyBindingConfig } from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
import {
Command,
defaultKeyBindingConfig,
loadCustomKeybindings,
} from './keyBindings.js';
/**
* Checks if a key matches any of the bindings for a command
@@ -14,9 +18,11 @@ import { Command, defaultKeyBindings } from './keyBindings.js';
function matchCommand(
command: Command,
key: Key,
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
): boolean {
return config[command].some((binding) => binding.matches(key));
const bindings = config.get(command);
if (!bindings) return false;
return bindings.some((binding) => binding.matches(key));
}
/**
@@ -35,7 +41,7 @@ export type KeyMatchers = {
* Creates key matchers from a key binding configuration
*/
export function createKeyMatchers(
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
): KeyMatchers {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const matchers = {} as { [C in Command]: KeyMatcher };
@@ -50,8 +56,23 @@ export function createKeyMatchers(
/**
* Default key binding matchers using the default configuration
*/
export const defaultKeyMatchers: KeyMatchers =
createKeyMatchers(defaultKeyBindings);
export const defaultKeyMatchers: KeyMatchers = createKeyMatchers(
defaultKeyBindingConfig,
);
// Re-export Command for convenience
export { Command };
/**
* Loads and creates key matchers including user customizations.
*/
export async function loadKeyMatchers(): Promise<{
matchers: KeyMatchers;
errors: string[];
}> {
const { config, errors } = await loadCustomKeybindings();
return {
matchers: createKeyMatchers(config),
errors,
};
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
type Command,
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
defaultKeyBindingConfig,
} from './keyBindings.js';
/**
@@ -97,10 +97,10 @@ export function formatKeyBinding(
*/
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
platform?: string,
): string {
const bindings = config[command];
const bindings = config.get(command);
if (!bindings || bindings.length === 0) {
return '';
}
+1
View File
@@ -483,6 +483,7 @@ export type SlashCommandProcessorResult =
type: 'schedule_tool';
toolName: string;
toolArgs: Record<string, unknown>;
postSubmitPrompt?: PartListUnion;
}
| {
type: 'handled'; // Indicates the command was processed and no further action is needed.
+1
View File
@@ -74,6 +74,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
input: 0,
duration_ms: 0,
tool_calls: 0,
models: {},
}),
})),
uiTelemetryService: {
+116
View File
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
promises as fs,
type PathLike,
type Dirent,
type Stats,
} from 'node:fs';
import * as path from 'node:path';
import { cleanupBackgroundLogs } from './logCleanup.js';
vi.mock('@google/gemini-cli-core', () => ({
ShellExecutionService: {
getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'),
},
debugLogger: {
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('node:fs', () => ({
promises: {
access: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
},
}));
describe('logCleanup', () => {
const logDir = '/tmp/gemini/tmp/background-processes';
beforeEach(() => {
vi.clearAllMocks();
});
it('should skip cleanup if the directory does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
await cleanupBackgroundLogs();
expect(fs.access).toHaveBeenCalledWith(logDir);
expect(fs.readdir).not.toHaveBeenCalled();
});
it('should skip cleanup if the directory is empty', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([]);
await cleanupBackgroundLogs();
expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true });
expect(fs.unlink).not.toHaveBeenCalled();
});
it('should delete log files older than 7 days', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago
const entries = [
{ name: 'old.log', isFile: () => true },
{ name: 'new.log', isFile: () => true },
{ name: 'not-a-log.txt', isFile: () => true },
{ name: 'some-dir', isFile: () => false },
] as Dirent[];
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(
fs.readdir as (
path: PathLike,
options: { withFileTypes: true },
) => Promise<Dirent[]>,
).mockResolvedValue(entries);
vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString();
if (pathStr.endsWith('old.log')) {
return Promise.resolve({ mtime: new Date(oldTime) } as Stats);
}
if (pathStr.endsWith('new.log')) {
return Promise.resolve({ mtime: new Date(newTime) } as Stats);
}
return Promise.resolve({ mtime: new Date(now) } as Stats);
});
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await cleanupBackgroundLogs();
expect(fs.unlink).toHaveBeenCalledTimes(1);
expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log'));
expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log'));
});
it('should handle errors during file deletion gracefully', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000;
const entries = [{ name: 'old.log', isFile: () => true }];
vi.mocked(fs.access).mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue(entries as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any);
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
await expect(cleanupBackgroundLogs()).resolves.not.toThrow();
expect(fs.unlink).toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More