From f9a05401c1d2d93d1251d3ebf2c079ee1f4ba8df Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Fri, 1 Aug 2025 04:02:08 +0900 Subject: [PATCH] feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) --- docs/cli/commands.md | 11 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../src/ui/commands/directoryCommand.test.tsx | 172 ++++++++++++++++++ .../cli/src/ui/commands/directoryCommand.tsx | 150 +++++++++++++++ packages/core/src/config/config.ts | 13 +- packages/core/src/core/client.ts | 30 +++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/directoryCommand.test.tsx create mode 100644 packages/cli/src/ui/commands/directoryCommand.tsx diff --git a/docs/cli/commands.md b/docs/cli/commands.md index d5072ab3..58717635 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -38,6 +38,17 @@ Slash commands provide meta-level control over the CLI itself. - **`/copy`** - **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse. +- **`/directory`** (or **`/dir`**) + - **Description:** Manage workspace directories for multi-directory support. + - **Sub-commands:** + - **`add`**: + - **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well. + - **Usage:** `/directory add ,` + - **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead. + - **`show`**: + - **Description:** Display all directories added by `/direcotry add` and `--include-directories`. + - **Usage:** `/directory show` + - **`/editor`** - **Description:** Open a dialog for selecting supported editors. diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d3ad6cb2..3b54047c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -16,6 +16,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; +import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -56,6 +57,7 @@ export class BuiltinCommandLoader implements ICommandLoader { copyCommand, corgiCommand, docsCommand, + directoryCommand, editorCommand, extensionsCommand, helpCommand, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx new file mode 100644 index 00000000..081083d3 --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { directoryCommand, expandHomeDir } from './directoryCommand.js'; +import { Config, WorkspaceContext } from '@google/gemini-cli-core'; +import { CommandContext } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +describe('directoryCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let mockWorkspaceContext: WorkspaceContext; + const addCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'add', + ); + const showCommand = directoryCommand.subCommands?.find( + (c) => c.name === 'show', + ); + + beforeEach(() => { + mockWorkspaceContext = { + addDirectory: vi.fn(), + getDirectories: vi + .fn() + .mockReturnValue([ + path.normalize('/home/user/project1'), + path.normalize('/home/user/project2'), + ]), + } as unknown as WorkspaceContext; + + mockConfig = { + getWorkspaceContext: () => mockWorkspaceContext, + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + getGeminiClient: vi.fn().mockReturnValue({ + addDirectoryContext: vi.fn(), + }), + } as unknown as Config; + + mockContext = { + services: { + config: mockConfig, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext; + }); + + describe('show', () => { + it('should display the list of directories', () => { + if (!showCommand?.action) throw new Error('No action'); + showCommand.action(mockContext, ''); + expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Current workspace directories:\n- ${path.normalize( + '/home/user/project1', + )}\n- ${path.normalize('/home/user/project2')}`, + }), + expect.any(Number), + ); + }); + }); + + describe('add', () => { + it('should show an error if no path is provided', () => { + if (!addCommand?.action) throw new Error('No action'); + addCommand.action(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }), + expect.any(Number), + ); + }); + + it('should call addDirectory and show a success message for a single path', async () => { + const newPath = path.normalize('/home/user/new-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath}`, + }), + expect.any(Number), + ); + }); + + it('should call addDirectory for each path and show a success message for multiple paths', async () => { + const newPath1 = path.normalize('/home/user/new-project1'); + const newPath2 = path.normalize('/home/user/new-project2'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${newPath1},${newPath2}`); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1); + expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`, + }), + expect.any(Number), + ); + }); + + it('should show an error if addDirectory throws an exception', async () => { + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => { + throw error; + }); + const newPath = path.normalize('/home/user/invalid-project'); + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, newPath); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${newPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + + it('should handle a mix of successful and failed additions', async () => { + const validPath = path.normalize('/home/user/valid-project'); + const invalidPath = path.normalize('/home/user/invalid-project'); + const error = new Error('Directory does not exist'); + vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation( + (p: string) => { + if (p === invalidPath) { + throw error; + } + }, + ); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action(mockContext, `${validPath},${invalidPath}`); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: `Successfully added directories:\n- ${validPath}`, + }), + expect.any(Number), + ); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: `Error adding '${invalidPath}': ${error.message}`, + }), + expect.any(Number), + ); + }); + }); + it('should correctly expand a Windows-style home directory path', () => { + const windowsPath = '%userprofile%\\Documents'; + const expectedPath = path.win32.join(os.homedir(), 'Documents'); + const result = expandHomeDir(windowsPath); + expect(path.win32.normalize(result)).toBe( + path.win32.normalize(expectedPath), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx new file mode 100644 index 00000000..18f7e78f --- /dev/null +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SlashCommand, CommandContext, CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import * as os from 'os'; +import * as path from 'path'; + +export function expandHomeDir(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p.startsWith('~')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} + +export const directoryCommand: SlashCommand = { + name: 'directory', + altNames: ['dir'], + description: 'Manage workspace directories', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'add', + description: + 'Add directories to the workspace. Use comma to separate multiple paths', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext, args: string) => { + const { + ui: { addItem }, + services: { config }, + } = context; + const [...rest] = args.split(' '); + + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + + const workspaceContext = config.getWorkspaceContext(); + + const pathsToAdd = rest + .join(' ') + .split(',') + .filter((p) => p); + if (pathsToAdd.length === 0) { + addItem( + { + type: MessageType.ERROR, + text: 'Please provide at least one path to add.', + }, + Date.now(), + ); + return; + } + + if (config.isRestrictiveSandbox()) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + }; + } + + const added: string[] = []; + const errors: string[] = []; + + for (const pathToAdd of pathsToAdd) { + try { + workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim())); + added.push(pathToAdd.trim()); + } catch (e) { + const error = e as Error; + errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`); + } + } + + if (added.length > 0) { + const gemini = config.getGeminiClient(); + if (gemini) { + await gemini.addDirectoryContext(); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added directories:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } + + if (errors.length > 0) { + addItem( + { + type: MessageType.ERROR, + text: errors.join('\n'), + }, + Date.now(), + ); + } + }, + }, + { + name: 'show', + description: 'Show all directories in the workspace', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { + ui: { addItem }, + services: { config }, + } = context; + if (!config) { + addItem( + { + type: MessageType.ERROR, + text: 'Configuration is not available.', + }, + Date.now(), + ); + return; + } + const workspaceContext = config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); + addItem( + { + type: MessageType.INFO, + text: `Current workspace directories:\n${directoryList}`, + }, + Date.now(), + ); + }, + }, + ], +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index edb24351..b2d5f387 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -197,7 +197,7 @@ export class Config { private readonly embeddingModel: string; private readonly sandbox: SandboxConfig | undefined; private readonly targetDir: string; - private readonly workspaceContext: WorkspaceContext; + private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; @@ -394,6 +394,17 @@ export class Config { return this.sandbox; } + isRestrictiveSandbox(): boolean { + const sandboxConfig = this.getSandbox(); + const seatbeltProfile = process.env.SEATBELT_PROFILE; + return ( + !!sandboxConfig && + sandboxConfig.command === 'sandbox-exec' && + !!seatbeltProfile && + seatbeltProfile.startsWith('restrictive-') + ); + } + getTargetDir(): string { return this.targetDir; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5b26e32c..be105971 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -171,6 +171,35 @@ export class GeminiClient { this.chat = await this.startChat(); } + async addDirectoryContext(): Promise { + if (!this.chat) { + return; + } + + this.getChat().addHistory({ + role: 'user', + parts: [{ text: await this.getDirectoryContext() }], + }); + } + + private async getDirectoryContext(): Promise { + const workspaceContext = this.config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + const folderStructures = await Promise.all( + workspaceDirectories.map((dir) => + getFolderStructure(dir, { + fileService: this.config.getFileService(), + }), + ), + ); + + const folderStructure = folderStructures.join('\n'); + const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n'); + const workingDirPreamble = `I'm currently working in the following directories:\n${dirList}\n Folder structures are as follows:\n${folderStructure}`; + return workingDirPreamble; + } + private async getEnvironment(): Promise { const today = new Date().toLocaleDateString(undefined, { weekday: 'long', @@ -208,6 +237,7 @@ export class GeminiClient { Today's date is ${today}. My operating system is: ${platform} ${workingDirPreamble} + Here is the folder structure of the current working directories:\n ${folderStructure} `.trim();