mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 00:16:57 -07:00
Merge branch 'main' into workspace-command-scope-20737
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
@@ -1791,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1389,11 +1389,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({
|
||||
|
||||
@@ -209,7 +209,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();
|
||||
@@ -351,7 +351,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();
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -249,6 +249,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
],
|
||||
placeholder: 'Type your feedback...',
|
||||
multiSelect: false,
|
||||
unconstrainedHeight: false,
|
||||
},
|
||||
]}
|
||||
onSubmit={(answers) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -539,7 +539,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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,8 +39,11 @@ 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 { extensionsUpdateState } = useExtensionUpdates(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -759,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,
|
||||
@@ -768,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': {
|
||||
@@ -1053,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];
|
||||
|
||||
@@ -73,16 +73,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,6 +88,17 @@ 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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,20 +106,10 @@ export enum Command {
|
||||
*/
|
||||
export class KeyBinding {
|
||||
private static readonly VALID_KEYS = new Set([
|
||||
// Letters & Numbers
|
||||
...'abcdefghijklmnopqrstuvwxyz0123456789',
|
||||
// Punctuation
|
||||
'`',
|
||||
'-',
|
||||
'=',
|
||||
'[',
|
||||
']',
|
||||
'\\',
|
||||
';',
|
||||
"'",
|
||||
',',
|
||||
'.',
|
||||
'/',
|
||||
...'abcdefghijklmnopqrstuvwxyz0123456789', // Letters & Numbers
|
||||
..."`-=[]\\;',./", // Punctuation
|
||||
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`), // Function Keys
|
||||
...Array.from({ length: 10 }, (_, i) => `numpad${i}`), // Numpad Numbers
|
||||
// Navigation & Actions
|
||||
'left',
|
||||
'up',
|
||||
@@ -139,10 +130,6 @@ export class KeyBinding {
|
||||
'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',
|
||||
@@ -354,15 +341,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[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')],
|
||||
@@ -370,6 +348,17 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[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 {
|
||||
@@ -475,20 +464,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 +570,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 +592,4 @@ 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.',
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user