Merge branch 'main' into restart-resume
@@ -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":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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!();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 'r' to restart, or 'escape' 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. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -74,6 +74,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
input: 0,
|
||||
duration_ms: 0,
|
||||
tool_calls: 0,
|
||||
models: {},
|
||||
}),
|
||||
})),
|
||||
uiTelemetryService: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||