Update contextFileName to support an optional list of strings (#1001)

This commit is contained in:
Billy Biggs 2025-06-13 09:19:08 -07:00 committed by GitHub
parent 34e0d9c0b6
commit 2a1ad1f5d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 176 additions and 117 deletions

View File

@ -38,9 +38,9 @@ When you create a `.gemini/settings.json` file for project-specific settings, or
### Available Settings in `settings.json`:
- **`contextFileName`** (string, optional):
- **`contextFileName`** (string or array of strings, optional):
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`).
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). May be a single filename or a list of accepted filenames.
- **Default:** `GEMINI.md`
- **Example:** `"contextFileName": "AGENTS.md"`

View File

@ -16,7 +16,7 @@ export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string;
contextFileName?: string | string[];
}
export function loadExtensions(workspaceDir: string): ExtensionConfig[] {
@ -76,12 +76,15 @@ function loadExtensionsFromDir(dir: string): ExtensionConfig[] {
}
if (extensionConfig.contextFileName) {
const contextFilePath = path.join(
extensionDir,
extensionConfig.contextFileName,
);
if (fs.existsSync(contextFilePath)) {
extensionConfig.contextFileName = contextFilePath;
const contextFileNames = Array.isArray(extensionConfig.contextFileName)
? extensionConfig.contextFileName
: [extensionConfig.contextFileName];
const resolvedPaths = contextFileNames
.map((fileName) => path.join(extensionDir, fileName))
.filter((filePath) => fs.existsSync(filePath));
if (resolvedPaths.length > 0) {
extensionConfig.contextFileName =
resolvedPaths.length === 1 ? resolvedPaths[0] : resolvedPaths;
}
} else {
const contextFilePath = path.join(extensionDir, 'gemini.md');

View File

@ -35,7 +35,7 @@ export interface Settings {
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
showMemoryUsage?: boolean;
contextFileName?: string;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: boolean;
preferredEditor?: string;

View File

@ -64,6 +64,7 @@ interface MockServerConfig {
getShowMemoryUsage: Mock<() => boolean>;
getAccessibility: Mock<() => AccessibilitySettings>;
getProjectRoot: Mock<() => string | undefined>;
getAllGeminiMdFilenames: Mock<() => string[]>;
}
// Mock @gemini-cli/core and its Config class
@ -124,12 +125,14 @@ vi.mock('@gemini-cli/core', async (importOriginal) => {
getProjectRoot: vi.fn(() => opts.projectRoot),
getGeminiClient: vi.fn(() => ({})),
getCheckpointEnabled: vi.fn(() => opts.checkpoint ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
};
});
return {
...actualCore,
Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
};
});
@ -269,6 +272,26 @@ describe('App UI', () => {
expect(lastFrame()).toContain('Using 1 AGENTS.MD file');
});
it('should display the first custom contextFileName when an array is provided', async () => {
mockSettings = createMockSettings({
contextFileName: ['AGENTS.MD', 'CONTEXT.MD'],
theme: 'Default',
});
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using 2 AGENTS.MD files');
});
it('should display custom contextFileName with plural when set and count is > 1', async () => {
mockSettings = createMockSettings({
contextFileName: 'MY_NOTES.TXT',

View File

@ -45,7 +45,7 @@ import process from 'node:process';
import {
getErrorMessage,
type Config,
getCurrentGeminiMdFilename,
getAllGeminiMdFilenames,
ApprovalMode,
isEditorAvailable,
EditorType,
@ -373,6 +373,14 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const branchName = useGitBranchName(config.getTargetDir());
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.contextFileName;
if (fromSettings) {
return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
}
return getAllGeminiMdFilenames();
}, [settings.merged.contextFileName]);
if (quittingMessages) {
return (
<Box flexDirection="column" marginBottom={1}>
@ -509,10 +517,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
) : (
<ContextSummaryDisplay
geminiMdFileCount={geminiMdFileCount}
contextFileName={
settings.merged.contextFileName ||
getCurrentGeminiMdFilename()
}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
showToolDescriptions={showToolDescriptions}
/>

View File

@ -11,14 +11,14 @@ import { type MCPServerConfig } from '@gemini-cli/core';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
contextFileName: string;
contextFileNames: string[];
mcpServers?: Record<string, MCPServerConfig>;
showToolDescriptions?: boolean;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
geminiMdFileCount,
contextFileName,
contextFileNames,
mcpServers,
showToolDescriptions,
}) => {
@ -30,7 +30,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
const geminiMdText =
geminiMdFileCount > 0
? `${geminiMdFileCount} ${contextFileName} file${geminiMdFileCount > 1 ? 's' : ''}`
? `${geminiMdFileCount} ${contextFileNames[0]} file${
geminiMdFileCount > 1 ? 's' : ''
}`
: '';
const mcpText =

View File

@ -74,7 +74,7 @@ export interface ConfigParameters {
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
showMemoryUsage?: boolean;
contextFileName?: string;
contextFileName?: string | string[];
geminiIgnorePatterns?: string[];
accessibility?: AccessibilitySettings;
telemetry?: boolean;

View File

@ -9,6 +9,7 @@ import {
MemoryTool,
setGeminiMdFilename,
getCurrentGeminiMdFilename,
getAllGeminiMdFilenames,
DEFAULT_CONTEXT_FILENAME,
} from './memoryTool.js';
import * as fs from 'fs/promises';
@ -74,6 +75,13 @@ describe('MemoryTool', () => {
setGeminiMdFilename('');
expect(getCurrentGeminiMdFilename()).toBe(initialName);
});
it('should handle an array of filenames', () => {
const newNames = ['CUSTOM_CONTEXT.md', 'ANOTHER_CONTEXT.md'];
setGeminiMdFilename(newNames);
expect(getCurrentGeminiMdFilename()).toBe('CUSTOM_CONTEXT.md');
expect(getAllGeminiMdFilenames()).toEqual(newNames);
});
});
describe('performAddMemoryEntry (static method)', () => {

View File

@ -51,18 +51,32 @@ export const MEMORY_SECTION_HEADER = '## Gemini Added Memories';
// This variable will hold the currently configured filename for GEMINI.md context files.
// It defaults to DEFAULT_CONTEXT_FILENAME but can be overridden by setGeminiMdFilename.
let currentGeminiMdFilename = DEFAULT_CONTEXT_FILENAME;
let currentGeminiMdFilename: string | string[] = DEFAULT_CONTEXT_FILENAME;
export function setGeminiMdFilename(newFilename: string): void {
if (newFilename && newFilename.trim() !== '') {
export function setGeminiMdFilename(newFilename: string | string[]): void {
if (Array.isArray(newFilename)) {
if (newFilename.length > 0) {
currentGeminiMdFilename = newFilename.map((name) => name.trim());
}
} else if (newFilename && newFilename.trim() !== '') {
currentGeminiMdFilename = newFilename.trim();
}
}
export function getCurrentGeminiMdFilename(): string {
if (Array.isArray(currentGeminiMdFilename)) {
return currentGeminiMdFilename[0];
}
return currentGeminiMdFilename;
}
export function getAllGeminiMdFilenames(): string[] {
if (Array.isArray(currentGeminiMdFilename)) {
return currentGeminiMdFilename;
}
return [currentGeminiMdFilename];
}
interface SaveMemoryParams {
fact: string;
}

View File

@ -11,7 +11,7 @@ import { homedir } from 'os';
import { bfsFileSearch } from './bfsFileSearch.js';
import {
GEMINI_CONFIG_DIR,
getCurrentGeminiMdFilename,
getAllGeminiMdFilenames,
} from '../tools/memoryTool.js';
// Simple console logger, similar to the one previously in CLI's config.ts
@ -83,131 +83,135 @@ async function getGeminiMdFilePathsInternal(
debugMode: boolean,
extensionContextFilePaths: string[] = [],
): Promise<string[]> {
const resolvedCwd = path.resolve(currentWorkingDirectory);
const resolvedHome = path.resolve(userHomePath);
const globalMemoryPath = path.join(
resolvedHome,
GEMINI_CONFIG_DIR,
getCurrentGeminiMdFilename(),
);
const paths: string[] = [];
const allPaths = new Set<string>();
const geminiMdFilenames = getAllGeminiMdFilenames();
if (debugMode)
logger.debug(
`Searching for ${getCurrentGeminiMdFilename()} starting from CWD: ${resolvedCwd}`,
for (const geminiMdFilename of geminiMdFilenames) {
const resolvedCwd = path.resolve(currentWorkingDirectory);
const resolvedHome = path.resolve(userHomePath);
const globalMemoryPath = path.join(
resolvedHome,
GEMINI_CONFIG_DIR,
geminiMdFilename,
);
if (debugMode) logger.debug(`User home directory: ${resolvedHome}`);
try {
await fs.access(globalMemoryPath, fsSync.constants.R_OK);
paths.push(globalMemoryPath);
if (debugMode)
logger.debug(
`Found readable global ${getCurrentGeminiMdFilename()}: ${globalMemoryPath}`,
`Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`,
);
} catch {
if (debugMode)
logger.debug(
`Global ${getCurrentGeminiMdFilename()} not found or not readable: ${globalMemoryPath}`,
);
}
if (debugMode) logger.debug(`User home directory: ${resolvedHome}`);
const projectRoot = await findProjectRoot(resolvedCwd);
if (debugMode)
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
const upwardPaths: string[] = [];
let currentDir = resolvedCwd;
// Determine the directory that signifies the top of the project or user-specific space.
const ultimateStopDir = projectRoot
? path.dirname(projectRoot)
: path.dirname(resolvedHome);
while (currentDir && currentDir !== path.dirname(currentDir)) {
// Loop until filesystem root or currentDir is empty
if (debugMode) {
logger.debug(
`Checking for ${getCurrentGeminiMdFilename()} in (upward scan): ${currentDir}`,
);
try {
await fs.access(globalMemoryPath, fsSync.constants.R_OK);
allPaths.add(globalMemoryPath);
if (debugMode)
logger.debug(
`Found readable global ${geminiMdFilename}: ${globalMemoryPath}`,
);
} catch {
if (debugMode)
logger.debug(
`Global ${geminiMdFilename} not found or not readable: ${globalMemoryPath}`,
);
}
// Skip the global .gemini directory itself during upward scan from CWD,
// as global is handled separately and explicitly first.
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
const projectRoot = await findProjectRoot(resolvedCwd);
if (debugMode)
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
const upwardPaths: string[] = [];
let currentDir = resolvedCwd;
// Determine the directory that signifies the top of the project or user-specific space.
const ultimateStopDir = projectRoot
? path.dirname(projectRoot)
: path.dirname(resolvedHome);
while (currentDir && currentDir !== path.dirname(currentDir)) {
// Loop until filesystem root or currentDir is empty
if (debugMode) {
logger.debug(
`Upward scan reached global config dir path, stopping upward search here: ${currentDir}`,
`Checking for ${geminiMdFilename} in (upward scan): ${currentDir}`,
);
}
break;
}
const potentialPath = path.join(currentDir, getCurrentGeminiMdFilename());
try {
await fs.access(potentialPath, fsSync.constants.R_OK);
// Add to upwardPaths only if it's not the already added globalMemoryPath
if (potentialPath !== globalMemoryPath) {
upwardPaths.unshift(potentialPath);
// Skip the global .gemini directory itself during upward scan from CWD,
// as global is handled separately and explicitly first.
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
if (debugMode) {
logger.debug(
`Found readable upward ${getCurrentGeminiMdFilename()}: ${potentialPath}`,
`Upward scan reached global config dir path, stopping upward search here: ${currentDir}`,
);
}
break;
}
const potentialPath = path.join(currentDir, geminiMdFilename);
try {
await fs.access(potentialPath, fsSync.constants.R_OK);
// Add to upwardPaths only if it's not the already added globalMemoryPath
if (potentialPath !== globalMemoryPath) {
upwardPaths.unshift(potentialPath);
if (debugMode) {
logger.debug(
`Found readable upward ${geminiMdFilename}: ${potentialPath}`,
);
}
}
} catch {
if (debugMode) {
logger.debug(
`Upward ${geminiMdFilename} not found or not readable in: ${currentDir}`,
);
}
}
} catch {
if (debugMode) {
logger.debug(
`Upward ${getCurrentGeminiMdFilename()} not found or not readable in: ${currentDir}`,
);
// Stop condition: if currentDir is the ultimateStopDir, break after this iteration.
if (currentDir === ultimateStopDir) {
if (debugMode)
logger.debug(
`Reached ultimate stop directory for upward scan: ${currentDir}`,
);
break;
}
currentDir = path.dirname(currentDir);
}
upwardPaths.forEach((p) => allPaths.add(p));
// Stop condition: if currentDir is the ultimateStopDir, break after this iteration.
if (currentDir === ultimateStopDir) {
if (debugMode)
logger.debug(
`Reached ultimate stop directory for upward scan: ${currentDir}`,
);
break;
}
currentDir = path.dirname(currentDir);
}
paths.push(...upwardPaths);
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: getCurrentGeminiMdFilename(),
maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY,
debug: debugMode,
respectGitIgnore: true,
projectRoot: projectRoot || resolvedCwd,
});
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
if (debugMode && downwardPaths.length > 0)
logger.debug(
`Found downward ${getCurrentGeminiMdFilename()} files (sorted): ${JSON.stringify(
downwardPaths,
)}`,
);
// Add downward paths only if they haven't been included already (e.g. from upward scan)
for (const dPath of downwardPaths) {
if (!paths.includes(dPath)) {
paths.push(dPath);
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename,
maxDirs: MAX_DIRECTORIES_TO_SCAN_FOR_MEMORY,
debug: debugMode,
respectGitIgnore: true,
projectRoot: projectRoot || resolvedCwd,
});
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
if (debugMode && downwardPaths.length > 0)
logger.debug(
`Found downward ${geminiMdFilename} files (sorted): ${JSON.stringify(
downwardPaths,
)}`,
);
// Add downward paths only if they haven't been included already (e.g. from upward scan)
for (const dPath of downwardPaths) {
allPaths.add(dPath);
}
}
// Add extension context file paths
for (const extensionPath of extensionContextFilePaths) {
if (!paths.includes(extensionPath)) {
paths.push(extensionPath);
}
allPaths.add(extensionPath);
}
const finalPaths = Array.from(allPaths);
if (debugMode)
logger.debug(
`Final ordered ${getCurrentGeminiMdFilename()} paths to read: ${JSON.stringify(paths)}`,
`Final ordered ${getAllGeminiMdFilenames()} paths to read: ${JSON.stringify(
finalPaths,
)}`,
);
return paths;
return finalPaths;
}
async function readGeminiMdFiles(
@ -228,7 +232,7 @@ async function readGeminiMdFiles(
if (!isTestEnv) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(
`Warning: Could not read ${getCurrentGeminiMdFilename()} file at ${filePath}. Error: ${message}`,
`Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`,
);
}
results.push({ filePath, content: null }); // Still include it with null content