Merge branch 'main' into adibakm/ask-user-file

This commit is contained in:
Adib234
2026-05-05 14:23:26 -04:00
committed by GitHub
81 changed files with 8549 additions and 1781 deletions
+21 -4
View File
@@ -6,6 +6,7 @@
import {
addMemory,
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
'Lists skills extracted from past sessions that are pending review.';
'Lists memory items extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
};
}
const [skills, patches] = await Promise.all([
const [skills, patches, memoryPatches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
listInboxMemoryPatches(context.agentContext.config),
]);
if (skills.length === 0 && patches.length === 0) {
if (
skills.length === 0 &&
patches.length === 0 &&
memoryPatches.length === 0
) {
return { name: this.name, data: 'No items in inbox.' };
}
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
for (const memoryPatch of memoryPatches) {
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
const date = memoryPatch.extractedAt
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
: '';
const sourceCount = memoryPatch.sourceFiles.length;
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
lines.push(
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
);
}
const total = skills.length + patches.length;
const total = skills.length + patches.length + memoryPatches.length;
return {
name: this.name,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
@@ -20,8 +20,11 @@ import {
getScopedEnvContents,
type ExtensionSetting,
} from '../../config/extensions/extensionSettings.js';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import prompts from 'prompts';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
vi.hoisted(() => {
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
vi.spyOn(debugLogger, 'error');
vi.clearAllMocks();
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
);
});
afterEach(() => {
afterEach(async () => {
await cleanupTmpDir(tempWorkspaceDir);
vi.restoreAllMocks();
});
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings } from './settings.js';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import {
loadAgentsFromDirectory,
loadSkillsFromDir,
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
);
});
afterEach(() => {
// Clean up files if needed, or rely on temp dir cleanup
afterEach(async () => {
await cleanupTmpDir(currentTempHome);
await cleanupTmpDir(tempWorkspace);
vi.clearAllMocks();
});
@@ -0,0 +1,238 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type * as osActual from 'node:os';
vi.mock('node:os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => path.resolve('/mock/home')),
platform: vi.fn(() => 'linux'),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: vi.fn(() => path.resolve('/mock/home')),
};
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { loadEnvironment, type Settings } from './settings.js';
import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
vi.mock('node:fs');
describe('Environment Isolation', () => {
const mockHome = path.resolve('/mock/home');
const mockWorkspace = path.resolve('/mock/workspace');
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue(mockHome);
vi.mocked(coreHomedir).mockReturnValue(mockHome);
// Default to no files existing
vi.mocked(fs.existsSync).mockReturnValue(false);
process.argv = ['node', 'gemini'];
// Clear env vars that might leak from the host environment
delete process.env['GEMINI_API_KEY'];
delete process.env['OTHER_VAR'];
});
afterEach(() => {
process.argv = originalArgv;
process.env = { ...originalEnv };
});
it('should load local .env by default', () => {
const workspaceEnv = path.join(mockWorkspace, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === workspaceEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBe('local');
delete process.env['GEMINI_API_KEY'];
});
it('should ignore local .env when ignoreLocalEnv is true', () => {
const workspaceEnv = path.join(mockWorkspace, '.env');
const homeEnv = path.join(mockHome, '.env');
vi.mocked(fs.existsSync).mockImplementation((p) => {
const ps = p.toString();
return ps === workspaceEnv || ps === homeEnv;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
const ps = p.toString();
if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
return '';
});
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
// Should skip local and find home
expect(process.env['GEMINI_API_KEY']).toBe('home');
delete process.env['GEMINI_API_KEY'];
});
it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === workspaceGeminiEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
delete process.env['GEMINI_API_KEY'];
});
it('should respect --ignore-env flag', () => {
const workspaceEnv = path.join(mockWorkspace, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === workspaceEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
process.argv = ['node', 'gemini', '--ignore-env'];
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should allow home .env even with ignoreLocalEnv true', () => {
const homeEnv = path.join(mockHome, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === homeEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
// Running from home dir
loadEnvironment(settings, mockHome, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBe('home');
delete process.env['GEMINI_API_KEY'];
});
it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
const deepProject = path.join(mockWorkspace, 'deep', 'dir');
const deepEnv = path.join(deepProject, '.env');
const parentEnv = path.join(mockWorkspace, '.env');
const homeEnv = path.join(mockHome, '.env');
vi.mocked(fs.existsSync).mockImplementation((p) => {
const ps = p.toString();
return ps === deepEnv || ps === parentEnv || ps === homeEnv;
});
vi.mocked(fs.readFileSync).mockImplementation((p) => {
const ps = p.toString();
if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
return '';
});
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
loadEnvironment(settings, deepProject, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBe('home');
delete process.env['GEMINI_API_KEY'];
});
it('should respect trust whitelist even when loading from home .env', () => {
const homeEnv = path.join(mockHome, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === homeEnv,
);
// Include one whitelisted and one non-whitelisted variable
vi.mocked(fs.readFileSync).mockReturnValue(
'GEMINI_API_KEY=home\nOTHER_VAR=secret',
);
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
// Running from an UNTRUSTED workspace
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: false,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBe('home');
expect(process.env['OTHER_VAR']).toBeUndefined();
delete process.env['GEMINI_API_KEY'];
});
it('should prioritize --ignore-env flag even if setting is false', () => {
const workspaceEnv = path.join(mockWorkspace, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === workspaceEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
process.argv = ['node', 'gemini', '--ignore-env'];
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should respect both -s and --ignore-env flags simultaneously', () => {
const workspaceEnv = path.join(mockWorkspace, '.env');
vi.mocked(fs.existsSync).mockImplementation(
(p) => p.toString() === workspaceEnv,
);
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
process.argv = ['node', 'gemini', '-s', '--ignore-env'];
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
loadEnvironment(settings, mockWorkspace, () => ({
isTrusted: true,
source: 'file',
}));
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
+14 -3
View File
@@ -500,7 +500,11 @@ export class LoadedSettings {
}
}
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
function findEnvFile(
startDir: string,
isTrusted: boolean,
ignoreLocalEnv: boolean,
): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
@@ -512,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
return envPath;
if (!ignoreLocalEnv || currentDir === homedir()) {
return envPath;
}
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
@@ -595,7 +601,6 @@ export function loadEnvironment(
): void {
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
const isTrusted = trustResult.isTrusted ?? false;
const envFilePath = findEnvFile(workspaceDir, isTrusted);
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
@@ -612,6 +617,12 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
const shouldIgnoreEnv =
!!settings.advanced?.ignoreLocalEnv ||
relevantArgs.includes('--ignore-env');
const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
const selectedAuthType = settings.security?.auth?.selectedType;
+11 -1
View File
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
ignoreLocalEnv: {
type: 'boolean',
label: 'Ignore Local .env',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Whether to ignore generic .env files in the project directory.',
showInDialog: true,
},
bugCommand: {
type: 'object',
label: 'Bug Command',
@@ -2410,7 +2420,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it.',
showInDialog: true,
},
generalistProfile: {
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
agentsCommand: { name: 'agents' },
}));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
bugMemoryCommand: { name: 'bug-memory' },
}));
vi.mock('../ui/commands/chatCommand.js', () => ({
chatCommand: {
name: 'chat',
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { commandsCommand } from '../ui/commands/commandsCommand.js';
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
bugMemoryCommand,
{
...chatCommand,
subCommands: chatResumeSubCommands,
@@ -110,7 +110,15 @@ describe('agentsCommand', () => {
});
it('should reload the agent registry when reload subcommand is called', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
const reloadSpy = vi.fn().mockResolvedValue({
totalLoaded: 3,
localCount: 2,
remoteCount: 1,
newAgents: ['new-agent'],
updatedAgents: ['updated-agent'],
deletedAgents: ['deleted-agent'],
errors: [],
});
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
@@ -120,7 +128,10 @@ describe('agentsCommand', () => {
);
expect(reloadCommand).toBeDefined();
const result = await reloadCommand!.action!(mockContext, '');
const result = (await reloadCommand!.action!(mockContext, '')) as {
type: 'message';
content: string;
};
expect(reloadSpy).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -132,8 +143,42 @@ describe('agentsCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Agents reloaded successfully',
content: expect.stringContaining('Agents reloaded successfully:'),
});
expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
expect(result.content).toContain('- New: new-agent');
expect(result.content).toContain('- Updated: updated-agent');
expect(result.content).toContain('- Deleted: deleted-agent');
expect(result.content).toContain(
'Run /agents list to see all available agents.',
);
});
it('should show "reloaded with errors" if errors occurred during reload', async () => {
const reloadSpy = vi.fn().mockResolvedValue({
totalLoaded: 1,
localCount: 1,
remoteCount: 0,
newAgents: [],
updatedAgents: [],
deletedAgents: [],
errors: ['Some error'],
});
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
);
const result = (await reloadCommand!.action!(mockContext, '')) as {
type: 'message';
content: string;
};
expect(result.content).toContain('Agents reloaded with errors:');
expect(result.content).toContain('- Errors: 1 encountered during reload');
});
it('should show an error if agent registry is not available during reload', async () => {
+23 -2
View File
@@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = {
text: 'Reloading agent registry...',
});
await agentRegistry.reload();
const summary = await agentRegistry.reload();
let content =
summary.errors.length > 0
? 'Agents reloaded with errors:'
: 'Agents reloaded successfully:';
content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
if (summary.newAgents.length > 0) {
content += `\n- New: ${summary.newAgents.join(', ')}`;
}
if (summary.updatedAgents.length > 0) {
content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
}
if (summary.deletedAgents.length > 0) {
content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
}
if (summary.errors.length > 0) {
content += `\n- Errors: ${summary.errors.length} encountered during reload`;
}
content += '\n\nRun /agents list to see all available agents.';
return {
type: 'message',
messageType: 'info',
content: 'Agents reloaded successfully',
content,
};
},
};
+124 -1
View File
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { getVersion, type Config } from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import { MessageType } from '../types.js';
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
const { memoryUsageMock } = vi.hoisted(() => ({
memoryUsageMock: vi.fn(() => ({
rss: 0,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
})),
}));
// Mock dependencies
vi.mock('open');
vi.mock('../utils/formatters.js');
vi.mock('../utils/memorySnapshot.js', () => ({
captureHeapSnapshot: vi.fn(),
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
stat: vi.fn().mockResolvedValue({ size: 4096 }),
};
});
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
memoryUsage: () => ({ rss: 0 }),
memoryUsage: memoryUsageMock,
},
}));
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(formatBytes).mockReturnValue('100 MB');
memoryUsageMock.mockReturnValue({
rss: 0,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.stubEnv('SANDBOX', 'gemini-test');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
expect(open).toHaveBeenCalledWith(expectedUrl);
});
const buildHighMemoryContext = (tempDir: string | undefined) =>
createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => false,
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
geminiClient: { getChat: () => ({ getHistory: () => [] }) },
},
},
});
it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
memoryUsageMock.mockReturnValue({
rss: 3 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
const tempDir = path.join('/tmp', 'gemini-test');
const context = buildHighMemoryContext(tempDir);
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(context, 'A memory bug');
const now = new Date('2024-01-01T00:00:00Z').getTime();
const expectedSnapshotPath = path.join(
tempDir,
`bug-memory-${now}.heapsnapshot`,
);
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
const addItem = vi.mocked(context.ui.addItem);
const callOrder = addItem.mock.invocationCallOrder;
const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
// The URL message must precede the "capturing" message so the user sees
// the URL before the 20+ second snapshot starts.
expect(callOrder[0]).toBeLessThan(openOrder);
expect(callOrder[1]).toBeGreaterThan(openOrder);
expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
});
it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
memoryUsageMock.mockReturnValue({
rss: 1 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
const context = buildHighMemoryContext('/tmp/gemini-test');
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(context, 'A light bug');
expect(captureHeapSnapshot).not.toHaveBeenCalled();
});
it('reports an error if the auto-capture fails but does not throw', async () => {
memoryUsageMock.mockReturnValue({
rss: 3 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
new Error('inspector failure'),
);
const context = buildHighMemoryContext('/tmp/gemini-test');
if (!bugCommand.action) throw new Error('Action is not defined');
await expect(
bugCommand.action(context, 'A memory bug'),
).resolves.toBeUndefined();
const addItem = vi.mocked(context.ui.addItem).mock.calls;
const lastCall = addItem[addItem.length - 1][0];
expect(lastCall.type).toBe(MessageType.ERROR);
expect(lastCall.text).toContain('inspector failure');
});
});
@@ -22,6 +22,11 @@ import {
} from '@google/gemini-cli-core';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
import {
captureHeapSnapshot,
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
} from '../utils/memorySnapshot.js';
import { stat } from 'node:fs/promises';
import path from 'node:path';
export const bugCommand: SlashCommand = {
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
Date.now(),
);
}
const rss = process.memoryUsage().rss;
const tempDir = config?.storage?.getProjectTempDir();
if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
const snapshotPath = path.join(
tempDir,
`bug-memory-${Date.now()}.heapsnapshot`,
);
context.ui.addItem(
{
type: MessageType.INFO,
text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
},
Date.now(),
);
try {
const startedAt = Date.now();
await captureHeapSnapshot(snapshotPath);
const durationMs = Date.now() - startedAt;
let sizeText = '';
try {
const { size } = await stat(snapshotPath);
sizeText = ` (${formatBytes(size)})`;
} catch {
// Size reporting is best-effort; the snapshot itself was captured successfully.
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
},
Date.now(),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
debugLogger.error(
`Failed to capture heap snapshot for bug report: ${errorMessage}`,
);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to capture heap snapshot: ${errorMessage}`,
},
Date.now(),
);
}
}
},
};
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import { bugMemoryCommand } from './bugMemoryCommand.js';
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../utils/memorySnapshot.js', () => ({
captureHeapSnapshot: vi.fn(),
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
stat: vi.fn().mockResolvedValue({ size: 1234 }),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
error: vi.fn(),
log: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
};
});
function makeContextWithTempDir(tempDir: string | undefined) {
return createMockCommandContext({
services: {
agentContext: {
config: {
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
} as unknown as Config,
},
},
});
}
describe('bugMemoryCommand', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('declares itself as a non-auto-executing built-in command', () => {
expect(bugMemoryCommand.name).toBe('bug-memory');
expect(bugMemoryCommand.autoExecute).toBe(false);
expect(bugMemoryCommand.description).toBeTruthy();
});
it('captures a heap snapshot and reports the file path', async () => {
const tempDir = path.join('/tmp', 'gemini-test');
const context = makeContextWithTempDir(tempDir);
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');
const expectedPath = path.join(
tempDir,
`bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
);
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
expect(addItemCalls).toHaveLength(2);
expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
expect(addItemCalls[0][0].text).toContain(expectedPath);
expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
expect(addItemCalls[1][0].text).toContain(expectedPath);
});
it('surfaces an error if capture fails', async () => {
const context = makeContextWithTempDir('/tmp/gemini-test');
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
new Error('inspector disconnected'),
);
if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
const lastCall = addItemCalls[addItemCalls.length - 1][0];
expect(lastCall.type).toBe(MessageType.ERROR);
expect(lastCall.text).toContain('inspector disconnected');
});
it('emits an error when no project temp directory is available', async () => {
const context = makeContextWithTempDir(undefined);
if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');
expect(captureHeapSnapshot).not.toHaveBeenCalled();
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
expect(addItemCalls).toHaveLength(1);
expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
expect(addItemCalls[0][0].text).toContain('temp directory');
});
});
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { debugLogger } from '@google/gemini-cli-core';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { formatBytes } from '../utils/formatters.js';
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
export const bugMemoryCommand: SlashCommand = {
name: 'bug-memory',
description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext): Promise<void> => {
const tempDir =
context.services.agentContext?.config?.storage?.getProjectTempDir();
if (!tempDir) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
},
Date.now(),
);
return;
}
const filePath = path.join(
tempDir,
`bug-memory-${Date.now()}.heapsnapshot`,
);
const rss = process.memoryUsage().rss;
context.ui.addItem(
{
type: MessageType.INFO,
text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
},
Date.now(),
);
const startedAt = Date.now();
try {
await captureHeapSnapshot(filePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.error(`Failed to capture heap snapshot: ${message}`);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to capture heap snapshot: ${message}`,
},
Date.now(),
);
return;
}
const durationMs = Date.now() - startedAt;
let sizeText = '';
try {
const { size } = await stat(filePath);
sizeText = ` (${formatBytes(size)})`;
} catch {
// Size reporting is best-effort; the snapshot itself was captured successfully.
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
},
Date.now(),
);
},
};
@@ -18,7 +18,7 @@ import {
type SlashCommand,
type SlashCommandActionReturn,
} from './types.js';
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
import { InboxDialog } from '../components/InboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
return {
type: 'custom_dialog',
component: React.createElement(SkillInboxDialog, {
component: React.createElement(InboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
onReloadMemory: async () => {
await refreshMemory(config);
},
}),
};
},
@@ -6,19 +6,32 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
import type {
Config,
InboxSkill,
InboxPatch,
InboxMemoryPatch,
} from '@google/gemini-cli-core';
import {
dismissInboxSkill,
dismissInboxMemoryPatch,
listInboxSkills,
listInboxPatches,
listInboxMemoryPatches,
moveInboxSkill,
applyInboxPatch,
dismissInboxPatch,
applyInboxMemoryPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { SkillInboxDialog } from './SkillInboxDialog.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { InboxDialog } from './InboxDialog.js';
const altBufferSettings = createMockSettings({
ui: { useAlternateBuffer: true },
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
@@ -27,11 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
dismissInboxSkill: vi.fn(),
dismissInboxMemoryPatch: vi.fn(),
listInboxSkills: vi.fn(),
listInboxPatches: vi.fn(),
listInboxMemoryPatches: vi.fn(),
moveInboxSkill: vi.fn(),
applyInboxPatch: vi.fn(),
dismissInboxPatch: vi.fn(),
applyInboxMemoryPatch: vi.fn(),
isProjectSkillPatchTarget: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
@@ -41,10 +57,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockListInboxPatches = vi.mocked(listInboxPatches);
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
const inboxSkill: InboxSkill = {
@@ -76,6 +95,27 @@ const inboxPatch: InboxPatch = {
extractedAt: '2025-01-20T14:00:00Z',
};
const inboxMemoryPatch: InboxMemoryPatch = {
kind: 'private',
relativePath: 'private',
name: 'Private memory',
sourceFiles: ['update-memory.patch'],
entries: [
{
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
isNewFile: false,
diffContent: [
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
'@@ -1,1 +1,1 @@',
'-old',
'+use focused tests',
].join('\n'),
},
],
extractedAt: '2025-01-21T10:00:00Z',
};
const workspacePatch: InboxPatch = {
fileName: 'workspace-update.patch',
name: 'workspace-update',
@@ -137,11 +177,12 @@ const windowsGlobalPatch: InboxPatch = {
],
};
describe('SkillInboxDialog', () => {
describe('InboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
@@ -158,6 +199,14 @@ describe('SkillInboxDialog', () => {
success: true,
message: 'Dismissed "update-docs.patch" from inbox.',
});
mockApplyInboxMemoryPatch.mockResolvedValue({
success: true,
message: 'Applied memory patch to 1 file.',
});
mockDismissInboxMemoryPatch.mockResolvedValue({
success: true,
message: 'Dismissed 1 private memory patch from inbox.',
});
mockIsProjectSkillPatchTarget.mockImplementation(
async (targetPath: string, config: Config) => {
const projectSkillsDir = config.storage
@@ -176,6 +225,64 @@ describe('SkillInboxDialog', () => {
vi.unstubAllEnvs();
});
it('reviews and applies memory patches', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn()}
onReloadMemory={onReloadMemory}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Private memory');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Review');
expect(frame).toMatch(/source patch/);
});
// Memory patches default to Dismiss as the highlighted action so a stray
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
// press Enter to confirm.
await act(async () => {
stdin.write('\u001B[B'); // arrow down → Apply
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
// Aggregate apply: relativePath equals the kind name.
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
config,
'private',
'private',
);
expect(onReloadMemory).toHaveBeenCalled();
});
unmount();
});
it('disables the project destination when the workspace is untrusted', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
@@ -183,7 +290,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -228,7 +335,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -276,7 +383,7 @@ describe('SkillInboxDialog', () => {
.mockRejectedValue(new Error('reload hook failed'));
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -316,6 +423,83 @@ describe('SkillInboxDialog', () => {
unmount();
});
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
// the list with focus jumped back to row 0 instead of staying on the row
// the user was on.
const secondSkill: InboxSkill = {
...inboxSkill,
dirName: 'second-skill',
name: 'Second Skill',
};
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Inbox Skill');
expect(frame).toContain('Second Skill');
});
// Arrow down to the second row.
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
// Enter the second row's preview.
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Review new skill');
expect(frame).toContain('Second Skill');
});
// Esc back to list.
await act(async () => {
stdin.write('\x1b');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Inbox Skill');
expect(frame).toContain('Second Skill');
});
// Re-enter (no arrow keys this time). The active row must still be the
// SECOND skill, not the first — which is what the bug reproduced before.
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Review new skill');
// The preview header echoes the highlighted skill's name.
expect(frame).toContain('Second Skill');
});
unmount();
});
describe('patch support', () => {
it('shows patches alongside skills with section headers', async () => {
mockListInboxPatches.mockResolvedValue([inboxPatch]);
@@ -328,7 +512,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -360,7 +544,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -401,7 +585,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -449,7 +633,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -494,7 +678,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -538,7 +722,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -593,7 +777,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -628,7 +812,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -656,5 +840,332 @@ describe('SkillInboxDialog', () => {
consoleErrorSpy.mockRestore();
unmount();
});
const tallPatch: InboxPatch = {
fileName: 'tall.patch',
name: 'tall-patch',
entries: [
{
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
'@@ -1,4 +1,8 @@',
' line1',
' line2',
'+added-1',
'+added-2',
'+added-3',
'+added-4',
' line3',
' line4',
].join('\n'),
},
],
};
it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
// Alt-buffer mode has no terminal scrollback, so the dialog must
// scroll inside itself. ScrollableList renders a `█` thumb when
// content exceeds viewport height — the regression signal that the
// diff is bounded and off-screen content is reachable via PgUp/PgDn.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{
settings: altBufferSettings,
uiState: { terminalHeight: 18 },
},
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Apply');
expect(frame).toContain('Dismiss');
expect(frame).toContain('█');
});
unmount();
});
it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ settings: altBufferSettings },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('update-docs');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain('PgUp/PgDn to scroll');
});
unmount();
});
it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
// Non-alt-buffer mode uses the codebase's standard bounded
// DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
// FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
// "... first/last N line(s) hidden ..." hint when it clips, which
// is the regression signal that the diff is bounded.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18, constrainHeight: true } },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame() ?? '').toMatch(/lines? hidden/);
});
unmount();
});
it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
// In non-alt-buffer mode the Ctrl+O affordance is rendered inline
// by ShowMoreLines above the footer when the diff is clipped. The
// footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
// duplicating the hint there would be noisy.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18, constrainHeight: true } },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Ctrl+O');
expect(frame).not.toContain('PgUp/PgDn to scroll');
});
unmount();
});
});
it('renders each list row as exactly two lines even with long descriptions', async () => {
// Reproduces the production bug: with the previous renderer, long
// descriptions wrapped onto multiple lines (and the date sibling was
// interleaved into the wrap), making each item 3-5 rows tall and
// breaking the listMaxItemsToShow budget. The fix uses height={2}
// and wrap="truncate-end" on every list row.
const longDescription =
'This is an extremely long description that would absolutely wrap to ' +
'multiple lines if rendered without truncation, which used to push the ' +
'list-phase footer off the bottom of the alternate buffer in production.';
mockListInboxSkills.mockResolvedValue([
{
dirName: 'long-skill',
name: 'long-skill',
description: longDescription,
content: '---\nname: x\ndescription: y\n---\n',
},
]);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('long-skill');
});
const frame = lastFrame() ?? '';
expect(frame).not.toContain('production');
expect(frame).toContain('extremely long description');
unmount();
});
it('keeps the list-phase footer on screen with many long-description skills', async () => {
const longDesc =
'A very long description that would wrap across multiple lines if not ' +
'truncated, which was causing the dialog body to overflow the bottom ' +
'of the alternate buffer';
const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
dirName: `skill-${i}`,
name: `skill-${i}`,
description: `${longDesc} (#${i})`,
content: '---\nname: x\ndescription: y\n---\n',
}));
mockListInboxSkills.mockResolvedValue(manySkills);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 28 } },
),
);
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Memory Inbox');
expect(frame).toContain('Esc to close');
});
unmount();
});
it('keeps the list-phase footer on screen on short terminals', async () => {
const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
dirName: `skill-${i}`,
name: `Skill ${i}`,
description: `Description ${i}`,
content: '---\nname: Skill\ndescription: Skill\n---\n',
}));
mockListInboxSkills.mockResolvedValue(manySkills);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18 } },
),
);
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Memory Inbox');
expect(frame).toContain('Esc to close');
});
unmount();
});
});
File diff suppressed because it is too large Load Diff
@@ -326,6 +326,36 @@ describe('SettingsDialog', () => {
});
unmount();
});
it('should render the bottom border correctly when height is constrained', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const constrainedHeight = 15;
const renderResult = await renderDialog(settings, onSelect, {
availableTerminalHeight: constrainedHeight,
});
await renderResult.waitUntilReady();
await waitFor(() => {
const output = renderResult.lastFrame();
const lines = output.trim().split('\n');
// Verify height constraint
expect(lines.length).toBeLessThanOrEqual(constrainedHeight);
// Verify bottom border existence in the last line of the output
const lastLine = lines[lines.length - 1];
// 'round' border characters: ─, ╰, ╯
expect(lastLine).toMatch(/[─╰╯]/);
});
// SVG snapshot ensures visual layout and border rendering are preserved
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('Setting Descriptions', () => {
@@ -1,876 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, Text, useStdout } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import {
type Config,
type InboxSkill,
type InboxPatch,
type InboxSkillDestination,
getErrorMessage,
listInboxSkills,
listInboxPatches,
moveInboxSkill,
dismissInboxSkill,
applyInboxPatch,
dismissInboxPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
type InboxItem =
| { type: 'skill'; skill: InboxSkill }
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
| { type: 'header'; label: string };
interface DestinationChoice {
destination: InboxSkillDestination;
label: string;
description: string;
}
interface PatchAction {
action: 'apply' | 'dismiss';
label: string;
description: string;
}
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
{
destination: 'global',
label: 'Global',
description: '~/.gemini/skills — available in all projects',
},
{
destination: 'project',
label: 'Project',
description: '.gemini/skills — available in this workspace',
},
];
interface SkillPreviewAction {
action: 'move' | 'dismiss';
label: string;
description: string;
}
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
{
action: 'move',
label: 'Move',
description: 'Choose where to install this skill',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox',
},
];
const PATCH_ACTION_CHOICES: PatchAction[] = [
{
action: 'apply',
label: 'Apply',
description: 'Apply patch and delete from inbox',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox without applying',
},
];
function normalizePathForUi(filePath: string): string {
return path.posix.normalize(filePath.replaceAll('\\', '/'));
}
function getPathBasename(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
const basename = path.posix.basename(normalizedPath);
return basename === '.' ? filePath : basename;
}
async function patchTargetsProjectSkills(
patch: InboxPatch,
config: Config,
): Promise<boolean> {
const entryTargetsProjectSkills = await Promise.all(
patch.entries.map((entry) =>
isProjectSkillPatchTarget(entry.targetPath, config),
),
);
return entryTargetsProjectSkills.some(Boolean);
}
/**
* Derives a bracketed origin tag from a skill file path,
* matching the existing [Built-in] convention in SkillsList.
*/
function getSkillOriginTag(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
if (normalizedPath.includes('/bundle/')) {
return 'Built-in';
}
if (normalizedPath.includes('/extensions/')) {
return 'Extension';
}
if (normalizedPath.includes('/.gemini/skills/')) {
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
.filter((homeDir): homeDir is string => Boolean(homeDir))
.map(normalizePathForUi);
if (
homeDirs.some((homeDir) =>
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
)
) {
return 'Global';
}
return 'Workspace';
}
return '';
}
/**
* Creates a unified diff string representing a new file.
*/
function newFileDiff(filename: string, content: string): string {
const lines = content.split('\n');
const hunkLines = lines.map((l) => `+${l}`).join('\n');
return [
`--- /dev/null`,
`+++ ${filename}`,
`@@ -0,0 +1,${lines.length} @@`,
hunkLines,
].join('\n');
}
function formatDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return isoString;
}
}
interface SkillInboxDialogProps {
config: Config;
onClose: () => void;
onReloadSkills: () => Promise<void>;
}
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
config,
onClose,
onReloadSkills,
}) => {
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 80;
const isTrustedFolder = config.isTrustedFolder();
const [phase, setPhase] = useState<Phase>('list');
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
const [feedback, setFeedback] = useState<{
text: string;
isError: boolean;
} | null>(null);
// Load inbox skills and patches on mount
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [skills, patches] = await Promise.all([
listInboxSkills(config),
listInboxPatches(config),
]);
const patchItems = await Promise.all(
patches.map(async (patch): Promise<InboxItem> => {
let targetsProjectSkills = false;
try {
targetsProjectSkills = await patchTargetsProjectSkills(
patch,
config,
);
} catch {
targetsProjectSkills = false;
}
return {
type: 'patch',
patch,
targetsProjectSkills,
};
}),
);
if (!cancelled) {
const combined: InboxItem[] = [
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
...patchItems,
];
setItems(combined);
setLoading(false);
}
} catch {
if (!cancelled) {
setItems([]);
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [config]);
const getItemKey = useCallback(
(item: InboxItem): string =>
item.type === 'skill'
? `skill:${item.skill.dirName}`
: item.type === 'patch'
? `patch:${item.patch.fileName}`
: `header:${item.label}`,
[],
);
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
const skills = items.filter((i) => i.type === 'skill');
const patches = items.filter((i) => i.type === 'patch');
const result: Array<SelectionListItem<InboxItem>> = [];
// Only show section headers when both types are present
const showHeaders = skills.length > 0 && patches.length > 0;
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'New Skills' };
result.push({
key: 'header:new-skills',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of skills) {
result.push({ key: getItemKey(item), value: item });
}
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
result.push({
key: 'header:skill-updates',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of patches) {
result.push({ key: getItemKey(item), value: item });
}
return result;
}, [items, getItemKey]);
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
() =>
SKILL_DESTINATION_CHOICES.map((choice) => {
if (choice.destination === 'project' && !isTrustedFolder) {
return {
key: choice.destination,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.destination,
value: choice,
};
}),
[isTrustedFolder],
);
const selectedPatchTargetsProjectSkills = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'patch') {
return false;
}
return selectedItem.targetsProjectSkills;
}, [selectedItem]);
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
() =>
PATCH_ACTION_CHOICES.map((choice) => {
if (
choice.action === 'apply' &&
selectedPatchTargetsProjectSkills &&
!isTrustedFolder
) {
return {
key: choice.action,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.action,
value: choice,
};
}),
[isTrustedFolder, selectedPatchTargetsProjectSkills],
);
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
useMemo(
() =>
SKILL_PREVIEW_CHOICES.map((choice) => ({
key: choice.action,
value: choice,
})),
[],
);
const handleSelectItem = useCallback((item: InboxItem) => {
setSelectedItem(item);
setFeedback(null);
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
}, []);
const removeItem = useCallback(
(item: InboxItem) => {
setItems((prev) =>
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
);
},
[getItemKey],
);
const handleSkillPreviewAction = useCallback(
(choice: SkillPreviewAction) => {
if (!selectedItem || selectedItem.type !== 'skill') return;
if (choice.action === 'move') {
setFeedback(null);
setPhase('skill-action');
return;
}
// Dismiss
setFeedback(null);
const skill = selectedItem.skill;
void (async () => {
try {
const result = await dismissInboxSkill(config, skill.dirName);
setFeedback({ text: result.message, isError: !result.success });
if (result.success) {
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
}
} catch (error) {
setFeedback({
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, removeItem],
);
const handleSelectDestination = useCallback(
(choice: DestinationChoice) => {
if (!selectedItem || selectedItem.type !== 'skill') return;
const skill = selectedItem.skill;
if (choice.destination === 'project' && !config.isTrustedFolder()) {
setFeedback({
text: 'Project skills are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
const result = await moveInboxSkill(
config,
skill.dirName,
choice.destination,
);
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
} catch (error) {
setFeedback({
text: `Failed to install skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, onReloadSkills, removeItem],
);
const handleSelectPatchAction = useCallback(
(choice: PatchAction) => {
if (!selectedItem || selectedItem.type !== 'patch') return;
const patch = selectedItem.patch;
if (
choice.action === 'apply' &&
!config.isTrustedFolder() &&
selectedItem.targetsProjectSkills
) {
setFeedback({
text: 'Project skill patches are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
let result: { success: boolean; message: string };
if (choice.action === 'apply') {
result = await applyInboxPatch(config, patch.fileName);
} else {
result = await dismissInboxPatch(config, patch.fileName);
}
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
if (choice.action === 'apply') {
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
}
} catch (error) {
const operation =
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
setFeedback({
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, onReloadSkills, removeItem],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
if (phase === 'skill-action') {
setPhase('skill-preview');
setFeedback(null);
} else if (phase !== 'list') {
setPhase('list');
setSelectedItem(null);
setFeedback(null);
} else {
onClose();
}
return true;
}
return false;
},
{ isActive: true, priority: true },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text>Loading inbox</Text>
</Box>
);
}
if (items.length === 0 && !feedback) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text bold>Memory Inbox</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>No items in inbox.</Text>
</Box>
<DialogFooter primaryAction="Esc to close" cancelAction="" />
</Box>
);
}
// Border + paddingX account for 6 chars of width
const contentWidth = terminalWidth - 6;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
width="100%"
>
{phase === 'list' && (
<>
<Text bold>
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
</Text>
<Text color={theme.text.secondary}>
Extracted from past sessions. Select one to review.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<InboxItem>
items={listItems}
onSelect={handleSelectItem}
isFocused={true}
showNumbers={false}
showScrollArrows={true}
maxItemsToShow={8}
renderItem={(item, { titleColor }) => {
if (item.value.type === 'header') {
return (
<Box marginTop={1}>
<Text color={theme.text.secondary} bold>
{item.value.label}
</Text>
</Box>
);
}
if (item.value.type === 'skill') {
const skill = item.value.skill;
return (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{skill.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{skill.description}
</Text>
{skill.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(skill.extractedAt)}
</Text>
)}
</Box>
</Box>
);
}
const patch = item.value.patch;
const fileNames = patch.entries.map((e) =>
getPathBasename(e.targetPath),
);
const origin = getSkillOriginTag(
patch.entries[0]?.targetPath ?? '',
);
return (
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
<Text color={titleColor} bold>
{patch.name}
</Text>
{origin && (
<Text color={theme.text.secondary}>
{` [${origin}]`}
</Text>
)}
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{fileNames.join(', ')}
</Text>
{patch.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(patch.extractedAt)}
</Text>
)}
</Box>
</Box>
);
}}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to select"
cancelAction="Esc to close"
/>
</>
)}
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
<>
<Text bold>{selectedItem.skill.name}</Text>
<Text color={theme.text.secondary}>
Review new skill before installing.
</Text>
{selectedItem.skill.content && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.secondary} bold>
SKILL.md
</Text>
<DiffRenderer
diffContent={newFileDiff(
'SKILL.md',
selectedItem.skill.content,
)}
filename="SKILL.md"
terminalWidth={contentWidth}
/>
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<SkillPreviewAction>
items={skillPreviewItems}
onSelect={handleSkillPreviewAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
<>
<Text bold>Move &quot;{selectedItem.skill.name}&quot;</Text>
<Text color={theme.text.secondary}>
Choose where to install this skill.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<DestinationChoice>
items={destinationItems}
onSelect={handleSelectDestination}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
<>
<Text bold>{selectedItem.patch.name}</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
Review changes before applying.
</Text>
{(() => {
const origin = getSkillOriginTag(
selectedItem.patch.entries[0]?.targetPath ?? '',
);
return origin ? (
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
) : null;
})()}
</Box>
<Box flexDirection="column" marginTop={1}>
{selectedItem.patch.entries.map((entry, index) => (
<Box
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
flexDirection="column"
marginBottom={1}
>
<Text color={theme.text.secondary} bold>
{entry.targetPath}
</Text>
<DiffRenderer
diffContent={entry.diffContent}
filename={entry.targetPath}
terminalWidth={contentWidth}
/>
</Box>
))}
</Box>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<PatchAction>
items={patchActionItems}
onSelect={handleSelectPatchAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
</Box>
);
};
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">&gt; Settings </text>
<text x="891" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#d7ffd7" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="891" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">╰─</text>
<rect x="36" y="85" width="9" height="17" fill="#ffffff" />
<text x="36" y="87" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">S</text>
<text x="45" y="87" fill="#afafaf" textLength="135" lengthAdjust="spacingAndGlyphs">earch to filter</text>
<text x="180" y="87" fill="#d7ffd7" textLength="702" lengthAdjust="spacingAndGlyphs">─────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="119" width="9" height="17" fill="#005f00" />
<text x="27" y="121" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="119" width="9" height="17" fill="#005f00" />
<rect x="45" y="119" width="72" height="17" fill="#005f00" />
<text x="45" y="121" fill="#d7ffd7" textLength="72" lengthAdjust="spacingAndGlyphs">Vim Mode</text>
<rect x="117" y="119" width="711" height="17" fill="#005f00" />
<rect x="828" y="119" width="45" height="17" fill="#005f00" />
<text x="828" y="121" fill="#d7ffd7" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="138" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="136" width="198" height="17" fill="#005f00" />
<text x="45" y="138" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">Enable Vim keybindings</text>
<text x="891" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Apply To </text>
<text x="891" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="187" width="9" height="17" fill="#005f00" />
<text x="27" y="189" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="187" width="9" height="17" fill="#005f00" />
<rect x="45" y="187" width="117" height="17" fill="#005f00" />
<text x="45" y="189" fill="#d7ffd7" textLength="117" lengthAdjust="spacingAndGlyphs">User Settings</text>
<rect x="162" y="187" width="711" height="17" fill="#005f00" />
<text x="891" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="206" fill="#afafaf" textLength="657" lengthAdjust="spacingAndGlyphs">(Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)</text>
<text x="891" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -46,6 +46,24 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Initial Rendering > should render the bottom border correctly when height is constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ ╰─Search to filter─────────────────────────────────────────────────────────────────────────────╯ │
│ ▲ │
│ ● Vim Mode false │
│ ▼ Enable Vim keybindings │
│ │
│ Apply To │
│ ● User Settings │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -425,7 +425,7 @@ export function BaseSettingsDialog({
flexDirection="row"
padding={1}
width="100%"
height="100%"
maxHeight={availableHeight}
>
<Box flexDirection="column" flexGrow={1}>
{/* Title */}
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Readable } from 'node:stream';
import {
captureHeapSnapshot,
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
} from './memorySnapshot.js';
const { mkdirMock, pipelineMock, getHeapSnapshotMock, createWriteStreamMock } =
vi.hoisted(() => ({
mkdirMock: vi.fn(async () => undefined),
pipelineMock: vi.fn(async () => undefined),
getHeapSnapshotMock: vi.fn(),
createWriteStreamMock: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return { ...actual, mkdir: mkdirMock };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, createWriteStream: createWriteStreamMock };
});
vi.mock('node:v8', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:v8')>();
return { ...actual, getHeapSnapshot: getHeapSnapshotMock };
});
vi.mock('node:stream/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:stream/promises')>();
return { ...actual, pipeline: pipelineMock };
});
describe('captureHeapSnapshot', () => {
beforeEach(() => {
mkdirMock.mockClear();
pipelineMock.mockClear();
getHeapSnapshotMock.mockClear().mockReturnValue(Readable.from([]));
createWriteStreamMock
.mockClear()
.mockReturnValue({ write: vi.fn(), end: vi.fn() });
});
afterEach(() => {
vi.clearAllMocks();
});
it('exports the 2 GB auto-capture threshold', () => {
expect(MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
});
it('creates the target directory and pipelines the V8 snapshot to disk', async () => {
const target = '/tmp/gemini-test/snapshot.heapsnapshot';
await captureHeapSnapshot(target);
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gemini-test', {
recursive: true,
});
expect(getHeapSnapshotMock).toHaveBeenCalledTimes(1);
expect(createWriteStreamMock).toHaveBeenCalledWith(target);
expect(pipelineMock).toHaveBeenCalledTimes(1);
expect(pipelineMock).toHaveBeenCalledWith(
getHeapSnapshotMock.mock.results[0].value,
createWriteStreamMock.mock.results[0].value,
);
});
it('propagates pipeline failures to the caller', async () => {
pipelineMock.mockRejectedValueOnce(new Error('write failed'));
await expect(
captureHeapSnapshot('/tmp/gemini-test/fail.heapsnapshot'),
).rejects.toThrow('write failed');
});
});
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { getHeapSnapshot } from 'node:v8';
/**
* RSS threshold at which `/bug` auto-captures a heap snapshot.
*/
export const MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
/**
* Capture a V8 heap snapshot from the current process and write it to disk.
*
* `v8.getHeapSnapshot()` returns a Readable stream whose producer is V8's
* internal snapshot generator. Piping it through `node:stream/promises`'
* `pipeline` propagates backpressure end-to-end, so even a multi-gigabyte
* heap is written without buffering the serialized snapshot in memory.
* Nothing is exposed over a debugger port.
*/
export async function captureHeapSnapshot(filePath: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await pipeline(getHeapSnapshot(), createWriteStream(filePath));
}
@@ -19,11 +19,11 @@ import {
} from '@google/gemini-cli-core';
// Mock os.homedir to control the home directory in tests
vi.mock('os', async (importOriginal) => {
vi.mock('node:os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
return {
...actualOs,
homedir: vi.fn(),
homedir: vi.fn(() => actualOs.homedir()),
};
});
@@ -32,7 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
@@ -66,6 +65,7 @@ describe('getUserStartupWarnings', () => {
afterEach(async () => {
await fs.rm(testRootDir, { recursive: true, force: true });
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -98,6 +98,54 @@ describe('getUserStartupWarnings', () => {
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when running in a subdirectory of home', async () => {
const subDir = path.join(homeDir, 'projects', 'my-app');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => {
const realHome = path.join(testRootDir, 'real-home');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const subDir = path.join(symlinkedHome, 'projects');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should return a warning when home directory is a symlink and running in it', async () => {
const realHome = path.join(testRootDir, 'real-home2');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home2');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const warnings = await getUserStartupWarnings({}, symlinkedHome);
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'home-directory',
message: expect.stringContaining(
'Warning you are running Gemini CLI in your home directory',
),
priority: WarningPriority.Low,
}),
);
});
it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => {
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir, { recursive: true });
vi.stubEnv('GEMINI_CLI_HOME', projectDir);
const warnings = await getUserStartupWarnings({}, projectDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
@@ -5,10 +5,10 @@
*/
import fs from 'node:fs/promises';
import { homedir as osHomedir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
homedir,
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
@@ -39,10 +39,10 @@ const homeDirectoryCheck: WarningCheck = {
try {
const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(workspaceRoot),
fs.realpath(homedir()),
fs.realpath(osHomedir()),
]);
if (workspaceRealPath === homeRealPath) {
if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) {
// If folder trust is enabled and the user trusts the home directory, don't show the warning.
if (
isFolderTrustEnabled(settings) &&