summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorYuki Okita <[email protected]>2025-07-31 05:38:20 +0900
committerGitHub <[email protected]>2025-07-30 20:38:20 +0000
commitc1fe6889569610878c45216556fb99424b5bcba4 (patch)
treeb96f5f66bc00426fcd3e4b87402067342abbce12 /packages/cli/src
parent21965f986c8aa99da5a0f8e52ae823bb2f040d7a (diff)
feat: Multi-Directory Workspace Support (part1: add `--include-directories` option) (#4605)
Co-authored-by: Allen Hutchison <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/config.ts11
-rw-r--r--packages/cli/src/gemini.tsx2
-rw-r--r--packages/cli/src/ui/App.test.tsx10
-rw-r--r--packages/cli/src/ui/commands/aboutCommand.test.ts1
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx17
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx11
-rw-r--r--packages/cli/src/ui/hooks/atCommandProcessor.test.ts4
-rw-r--r--packages/cli/src/ui/hooks/atCommandProcessor.ts138
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.test.ts41
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts145
-rw-r--r--packages/cli/src/utils/sandbox-macos-permissive-closed.sb6
-rw-r--r--packages/cli/src/utils/sandbox-macos-permissive-open.sb6
-rw-r--r--packages/cli/src/utils/sandbox-macos-permissive-proxied.sb6
-rw-r--r--packages/cli/src/utils/sandbox-macos-restrictive-closed.sb6
-rw-r--r--packages/cli/src/utils/sandbox-macos-restrictive-open.sb6
-rw-r--r--packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb6
-rw-r--r--packages/cli/src/utils/sandbox.ts37
17 files changed, 320 insertions, 133 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 27e3ec09..1dd8519c 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -61,6 +61,7 @@ export interface CliArgs {
listExtensions: boolean | undefined;
ideMode: boolean | undefined;
proxy: string | undefined;
+ includeDirectories: string[] | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -199,6 +200,15 @@ export async function parseArguments(): Promise<CliArgs> {
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
+ .option('include-directories', {
+ type: 'array',
+ string: true,
+ description:
+ 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
+ coerce: (dirs: string[]) =>
+ // Handle comma-separated values
+ dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
+ })
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -366,6 +376,7 @@ export async function loadCliConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
+ includeDirectories: argv.includeDirectories,
debugMode,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index a31c4b2f..b4b70b61 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -199,7 +199,7 @@ export async function main() {
process.exit(1);
}
}
- await start_sandbox(sandboxConfig, memoryArgs);
+ await start_sandbox(sandboxConfig, memoryArgs, config);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index fef4106a..13ddb77d 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -152,6 +152,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
getIdeMode: vi.fn(() => false),
+ getWorkspaceContext: vi.fn(() => ({
+ getDirectories: vi.fn(() => []),
+ })),
};
});
@@ -292,6 +295,13 @@ describe('App UI', () => {
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
+
+ // Ensure getWorkspaceContext is available if not added by the constructor
+ if (!mockConfig.getWorkspaceContext) {
+ mockConfig.getWorkspaceContext = vi.fn(() => ({
+ getDirectories: vi.fn(() => ['/test/dir']),
+ }));
+ }
vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined);
});
diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts
index 48dd6db3..43cd59ec 100644
--- a/packages/cli/src/ui/commands/aboutCommand.test.ts
+++ b/packages/cli/src/ui/commands/aboutCommand.test.ts
@@ -62,6 +62,7 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
+ process.env.SANDBOX = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 1d0b868f..e0d967da 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -172,6 +172,9 @@ describe('InputPrompt', () => {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
+ getWorkspaceContext: () => ({
+ getDirectories: () => ['/test/project/src'],
+ }),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -731,6 +734,7 @@ describe('InputPrompt', () => {
// Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -756,6 +760,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -781,6 +786,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -806,6 +812,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -831,6 +838,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -857,6 +865,7 @@ describe('InputPrompt', () => {
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -882,6 +891,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -908,6 +918,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -934,6 +945,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -960,6 +972,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -986,6 +999,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -1014,6 +1028,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -1040,6 +1055,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
@@ -1068,6 +1084,7 @@ describe('InputPrompt', () => {
expect(mockedUseCompletion).toHaveBeenCalledWith(
mockBuffer,
+ ['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 17b7694e..5a7b6353 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -60,8 +60,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
+ const [dirs, setDirs] = useState<readonly string[]>(
+ config.getWorkspaceContext().getDirectories(),
+ );
+ const dirsChanged = config.getWorkspaceContext().getDirectories();
+ useEffect(() => {
+ if (dirs.length !== dirsChanged.length) {
+ setDirs(dirsChanged);
+ }
+ }, [dirs.length, dirsChanged]);
+
const completion = useCompletion(
buffer,
+ dirs,
config.getTargetDir(),
slashCommands,
commandContext,
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
index de05667e..2b4c81a3 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts
@@ -57,6 +57,10 @@ describe('handleAtCommand', () => {
respectGeminiIgnore: true,
}),
getEnableRecursiveFileSearch: vi.fn(() => true),
+ getWorkspaceContext: () => ({
+ isPathWithinWorkspace: () => true,
+ getDirectories: () => [testRootDir],
+ }),
} as unknown as Config;
const registry = new ToolRegistry(mockConfig);
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts
index 237d983f..7b9005fa 100644
--- a/packages/cli/src/ui/hooks/atCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts
@@ -188,6 +188,14 @@ export async function handleAtCommand({
// Check if path should be ignored based on filtering options
+ const workspaceContext = config.getWorkspaceContext();
+ if (!workspaceContext.isPathWithinWorkspace(pathName)) {
+ onDebugMessage(
+ `Path ${pathName} is not in the workspace and will be skipped.`,
+ );
+ continue;
+ }
+
const gitIgnored =
respectFileIgnore.respectGitIgnore &&
fileDiscovery.shouldIgnoreFile(pathName, {
@@ -215,90 +223,88 @@ export async function handleAtCommand({
continue;
}
- let currentPathSpec = pathName;
- let resolvedSuccessfully = false;
-
- try {
- const absolutePath = path.resolve(config.getTargetDir(), pathName);
- const stats = await fs.stat(absolutePath);
- if (stats.isDirectory()) {
- currentPathSpec =
- pathName + (pathName.endsWith(path.sep) ? `**` : `/**`);
- onDebugMessage(
- `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
- );
- } else {
- onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`);
- }
- resolvedSuccessfully = true;
- } catch (error) {
- if (isNodeError(error) && error.code === 'ENOENT') {
- if (config.getEnableRecursiveFileSearch() && globTool) {
+ for (const dir of config.getWorkspaceContext().getDirectories()) {
+ let currentPathSpec = pathName;
+ let resolvedSuccessfully = false;
+ try {
+ const absolutePath = path.resolve(dir, pathName);
+ const stats = await fs.stat(absolutePath);
+ if (stats.isDirectory()) {
+ currentPathSpec =
+ pathName + (pathName.endsWith(path.sep) ? `**` : `/**`);
onDebugMessage(
- `Path ${pathName} not found directly, attempting glob search.`,
+ `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
- try {
- const globResult = await globTool.execute(
- {
- pattern: `**/*${pathName}*`,
- path: config.getTargetDir(),
- },
- signal,
+ } else {
+ onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`);
+ }
+ resolvedSuccessfully = true;
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ if (config.getEnableRecursiveFileSearch() && globTool) {
+ onDebugMessage(
+ `Path ${pathName} not found directly, attempting glob search.`,
);
- if (
- globResult.llmContent &&
- typeof globResult.llmContent === 'string' &&
- !globResult.llmContent.startsWith('No files found') &&
- !globResult.llmContent.startsWith('Error:')
- ) {
- const lines = globResult.llmContent.split('\n');
- if (lines.length > 1 && lines[1]) {
- const firstMatchAbsolute = lines[1].trim();
- currentPathSpec = path.relative(
- config.getTargetDir(),
- firstMatchAbsolute,
- );
- onDebugMessage(
- `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
- );
- resolvedSuccessfully = true;
+ try {
+ const globResult = await globTool.execute(
+ {
+ pattern: `**/*${pathName}*`,
+ path: dir,
+ },
+ signal,
+ );
+ if (
+ globResult.llmContent &&
+ typeof globResult.llmContent === 'string' &&
+ !globResult.llmContent.startsWith('No files found') &&
+ !globResult.llmContent.startsWith('Error:')
+ ) {
+ const lines = globResult.llmContent.split('\n');
+ if (lines.length > 1 && lines[1]) {
+ const firstMatchAbsolute = lines[1].trim();
+ currentPathSpec = path.relative(dir, firstMatchAbsolute);
+ onDebugMessage(
+ `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
+ );
+ resolvedSuccessfully = true;
+ } else {
+ onDebugMessage(
+ `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
+ );
+ }
} else {
onDebugMessage(
- `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
+ `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
- } else {
+ } catch (globError) {
+ console.error(
+ `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
+ );
onDebugMessage(
- `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
+ `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
);
}
- } catch (globError) {
- console.error(
- `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
- );
+ } else {
onDebugMessage(
- `Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
+ `Glob tool not found. Path ${pathName} will be skipped.`,
);
}
} else {
+ console.error(
+ `Error stating path ${pathName}: ${getErrorMessage(error)}`,
+ );
onDebugMessage(
- `Glob tool not found. Path ${pathName} will be skipped.`,
+ `Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
- } else {
- console.error(
- `Error stating path ${pathName}: ${getErrorMessage(error)}`,
- );
- onDebugMessage(
- `Error stating path ${pathName}. Path ${pathName} will be skipped.`,
- );
}
- }
-
- if (resolvedSuccessfully) {
- pathSpecsToRead.push(currentPathSpec);
- atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
- contentLabelsForDisplay.push(pathName);
+ if (resolvedSuccessfully) {
+ pathSpecsToRead.push(currentPathSpec);
+ atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
+ contentLabelsForDisplay.push(pathName);
+ break;
+ }
}
}
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index da6a7ab3..f876eea1 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -22,6 +22,7 @@ describe('useCompletion', () => {
// A minimal mock is sufficient for these tests.
const mockCommandContext = {} as CommandContext;
+ let testDirs: string[];
async function createEmptyDir(...pathSegments: string[]) {
const fullPath = path.join(testRootDir, ...pathSegments);
@@ -51,8 +52,12 @@ describe('useCompletion', () => {
testRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'completion-unit-test-'),
);
+ testDirs = [testRootDir];
mockConfig = {
getTargetDir: () => testRootDir,
+ getWorkspaceContext: () => ({
+ getDirectories: () => testDirs,
+ }),
getProjectRoot: () => testRootDir,
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
@@ -79,6 +84,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest(''),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -108,6 +114,7 @@ describe('useCompletion', () => {
const textBuffer = useTextBufferForTest(text);
return useCompletion(
textBuffer,
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -138,6 +145,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/help'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -170,6 +178,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest(''),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -191,6 +200,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest(''),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -215,6 +225,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/h'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -242,6 +253,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/h'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -270,6 +282,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -315,6 +328,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/command'),
+ testDirs,
testRootDir,
largeMockCommands,
mockCommandContext,
@@ -372,6 +386,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -394,6 +409,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/mem'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -417,6 +433,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/usag'), // part of the word "usage"
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -443,6 +460,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/clear'), // No trailing space
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -474,6 +492,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest(query),
+ testDirs,
testRootDir,
mockSlashCommands,
mockCommandContext,
@@ -494,6 +513,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/clear '),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -514,6 +534,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/unknown-command'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -547,6 +568,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/memory'), // Note: no trailing space
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -584,6 +606,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/memory'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -619,6 +642,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/memory a'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -650,6 +674,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/memory dothisnow'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -692,6 +717,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/chat resume my-ch'),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -735,6 +761,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/chat resume '),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -769,6 +796,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('/chat resume '),
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -796,6 +824,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@s'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -829,6 +858,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@src/comp'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -854,6 +884,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@.'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -885,6 +916,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@d'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -910,6 +942,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -944,6 +977,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -974,6 +1008,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@d'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -1007,6 +1042,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -1039,6 +1075,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
useTextBufferForTest('@t'),
+ testDirs,
testRootDir,
[],
mockCommandContext,
@@ -1085,6 +1122,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -1128,6 +1166,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -1173,6 +1212,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -1221,6 +1261,7 @@ describe('useCompletion', () => {
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
+ testDirs,
testRootDir,
slashCommands,
mockCommandContext,
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 10724c21..4b106c1b 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -43,6 +43,7 @@ export interface UseCompletionReturn {
export function useCompletion(
buffer: TextBuffer,
+ dirs: readonly string[],
cwd: string,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
@@ -328,8 +329,6 @@ export function useCompletion(
: partialPath.substring(lastSlashIndex + 1),
);
- const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
-
let isMounted = true;
const findFilesRecursively = async (
@@ -358,7 +357,7 @@ export function useCompletion(
const entryPathRelative = path.join(currentRelativePath, entry.name);
const entryPathFromRoot = path.relative(
- cwd,
+ startDir,
path.join(startDir, entry.name),
);
@@ -417,29 +416,31 @@ export function useCompletion(
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
},
+ searchDir: string,
maxResults = 50,
): Promise<Suggestion[]> => {
const globPattern = `**/${searchPrefix}*`;
const files = await glob(globPattern, {
- cwd,
+ cwd: searchDir,
dot: searchPrefix.startsWith('.'),
nocase: true,
});
const suggestions: Suggestion[] = files
- .map((file: string) => ({
- label: file,
- value: escapePath(file),
- }))
- .filter((s) => {
+ .filter((file) => {
if (fileDiscoveryService) {
- return !fileDiscoveryService.shouldIgnoreFile(
- s.label,
- filterOptions,
- ); // relative path
+ return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
}
return true;
})
+ .map((file: string) => {
+ const absolutePath = path.resolve(searchDir, file);
+ const label = path.relative(cwd, absolutePath);
+ return {
+ label,
+ value: escapePath(label),
+ };
+ })
.slice(0, maxResults);
return suggestions;
@@ -456,63 +457,78 @@ export function useCompletion(
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
try {
- // If there's no slash, or it's the root, do a recursive search from cwd
- if (
- partialPath.indexOf('/') === -1 &&
- prefix &&
- enableRecursiveSearch
- ) {
- if (fileDiscoveryService) {
- fetchedSuggestions = await findFilesWithGlob(
- prefix,
- fileDiscoveryService,
- filterOptions,
- );
+ // If there's no slash, or it's the root, do a recursive search from workspace directories
+ for (const dir of dirs) {
+ let fetchedSuggestionsPerDir: Suggestion[] = [];
+ if (
+ partialPath.indexOf('/') === -1 &&
+ prefix &&
+ enableRecursiveSearch
+ ) {
+ if (fileDiscoveryService) {
+ fetchedSuggestionsPerDir = await findFilesWithGlob(
+ prefix,
+ fileDiscoveryService,
+ filterOptions,
+ dir,
+ );
+ } else {
+ fetchedSuggestionsPerDir = await findFilesRecursively(
+ dir,
+ prefix,
+ null,
+ filterOptions,
+ );
+ }
} else {
- fetchedSuggestions = await findFilesRecursively(
- cwd,
- prefix,
- null,
- filterOptions,
- );
- }
- } else {
- // Original behavior: list files in the specific directory
- const lowerPrefix = prefix.toLowerCase();
- const entries = await fs.readdir(baseDirAbsolute, {
- withFileTypes: true,
- });
+ // Original behavior: list files in the specific directory
+ const lowerPrefix = prefix.toLowerCase();
+ const baseDirAbsolute = path.resolve(dir, baseDirRelative);
+ const entries = await fs.readdir(baseDirAbsolute, {
+ withFileTypes: true,
+ });
- // Filter entries using git-aware filtering
- const filteredEntries = [];
- for (const entry of entries) {
- // Conditionally ignore dotfiles
- if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
- continue;
- }
- if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
+ // Filter entries using git-aware filtering
+ const filteredEntries = [];
+ for (const entry of entries) {
+ // Conditionally ignore dotfiles
+ if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
+ continue;
+ }
+ if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
- const relativePath = path.relative(
- cwd,
- path.join(baseDirAbsolute, entry.name),
- );
- if (
- fileDiscoveryService &&
- fileDiscoveryService.shouldIgnoreFile(relativePath, filterOptions)
- ) {
- continue;
+ const relativePath = path.relative(
+ dir,
+ path.join(baseDirAbsolute, entry.name),
+ );
+ if (
+ fileDiscoveryService &&
+ fileDiscoveryService.shouldIgnoreFile(
+ relativePath,
+ filterOptions,
+ )
+ ) {
+ continue;
+ }
+
+ filteredEntries.push(entry);
}
- filteredEntries.push(entry);
+ fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
+ const absolutePath = path.resolve(baseDirAbsolute, entry.name);
+ const label =
+ cwd === dir ? entry.name : path.relative(cwd, absolutePath);
+ const suggestionLabel = entry.isDirectory() ? label + '/' : label;
+ return {
+ label: suggestionLabel,
+ value: escapePath(suggestionLabel),
+ };
+ });
}
-
- fetchedSuggestions = filteredEntries.map((entry) => {
- const label = entry.isDirectory() ? entry.name + '/' : entry.name;
- return {
- label,
- value: escapePath(label), // Value for completion should be just the name part
- };
- });
+ fetchedSuggestions = [
+ ...fetchedSuggestions,
+ ...fetchedSuggestionsPerDir,
+ ];
}
// Like glob, we always return forwardslashes, even in windows.
@@ -585,6 +601,7 @@ export function useCompletion(
};
}, [
buffer.text,
+ dirs,
cwd,
isActive,
resetCompletionState,
diff --git a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb
index 36d88995..cf64da94 100644
--- a/packages/cli/src/utils/sandbox-macos-permissive-closed.sb
+++ b/packages/cli/src/utils/sandbox-macos-permissive-closed.sb
@@ -13,6 +13,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox-macos-permissive-open.sb b/packages/cli/src/utils/sandbox-macos-permissive-open.sb
index 552efcd4..50d21a1f 100644
--- a/packages/cli/src/utils/sandbox-macos-permissive-open.sb
+++ b/packages/cli/src/utils/sandbox-macos-permissive-open.sb
@@ -13,6 +13,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb
index 4410776b..8becc8cb 100644
--- a/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb
+++ b/packages/cli/src/utils/sandbox-macos-permissive-proxied.sb
@@ -13,6 +13,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb
index 9ce68e9d..17d0c073 100644
--- a/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb
+++ b/packages/cli/src/utils/sandbox-macos-restrictive-closed.sb
@@ -71,6 +71,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb
index e89b8090..17f27224 100644
--- a/packages/cli/src/utils/sandbox-macos-restrictive-open.sb
+++ b/packages/cli/src/utils/sandbox-macos-restrictive-open.sb
@@ -71,6 +71,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb
index a49712a3..c07c1496 100644
--- a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb
+++ b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb
@@ -71,6 +71,12 @@
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
+ ;; Allow writes to included directories from --include-directories
+ (subpath (param "INCLUDE_DIR_0"))
+ (subpath (param "INCLUDE_DIR_1"))
+ (subpath (param "INCLUDE_DIR_2"))
+ (subpath (param "INCLUDE_DIR_3"))
+ (subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts
index 84fdc8f7..72b5e56b 100644
--- a/packages/cli/src/utils/sandbox.ts
+++ b/packages/cli/src/utils/sandbox.ts
@@ -15,7 +15,7 @@ import {
SETTINGS_DIRECTORY_NAME,
} from '../config/settings.js';
import { promisify } from 'util';
-import { SandboxConfig } from '@google/gemini-cli-core';
+import { Config, SandboxConfig } from '@google/gemini-cli-core';
const execAsync = promisify(exec);
@@ -183,6 +183,7 @@ function entrypoint(workdir: string): string[] {
export async function start_sandbox(
config: SandboxConfig,
nodeArgs: string[] = [],
+ cliConfig?: Config,
) {
if (config.command === 'sandbox-exec') {
// disallow BUILD_SANDBOX
@@ -223,6 +224,38 @@ export async function start_sandbox(
`HOME_DIR=${fs.realpathSync(os.homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`,
+ ];
+
+ // Add included directories from the workspace context
+ // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
+ const MAX_INCLUDE_DIRS = 5;
+ const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
+ const includedDirs: string[] = [];
+
+ if (cliConfig) {
+ const workspaceContext = cliConfig.getWorkspaceContext();
+ const directories = workspaceContext.getDirectories();
+
+ // Filter out TARGET_DIR
+ for (const dir of directories) {
+ const realDir = fs.realpathSync(dir);
+ if (realDir !== targetDir) {
+ includedDirs.push(realDir);
+ }
+ }
+ }
+
+ for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
+ let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
+
+ if (i < includedDirs.length) {
+ dirPath = includedDirs[i];
+ }
+
+ args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
+ }
+
+ args.push(
'-f',
profileFile,
'sh',
@@ -232,7 +265,7 @@ export async function start_sandbox(
`NODE_OPTIONS="${nodeOptions}"`,
...process.argv.map((arg) => quote([arg])),
].join(' '),
- ];
+ );
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND;
let proxyProcess: ChildProcess | undefined = undefined;