mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-13 03:20:33 -07:00
Merge branch 'main' into adibakm/ask-user-file
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
+525
-14
@@ -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 "{selectedItem.skill.name}"</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>
|
||||
);
|
||||
};
|
||||
+63
@@ -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">> 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) &&
|
||||
|
||||
Reference in New Issue
Block a user