From a7743b5cdd4ae17608830aa63115f57e81aa1878 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Feb 2026 01:42:21 -0500 Subject: [PATCH 1/2] docs: add vi mode shortcuts and clarify custom sandbox requirements - Document vi mode keyboard shortcuts in keyboard-shortcuts.md including navigation, editing, and mode switching commands - Add note that custom sandbox Dockerfile builds are only supported when running from source code (not via npm install) Closes #17381, #15141 --- docs/cli/keyboard-shortcuts.md | 83 ++++++++++++++++++++++++++++ docs/cli/sandbox.md | 2 +- docs/get-started/configuration-v1.md | 6 ++ docs/get-started/configuration.md | 6 ++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 938bc6ff7d7..5788748273f 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -141,6 +141,89 @@ available combinations. - `Shift + Tab` (while typing in the prompt): Cycle approval modes. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. +## Vi Mode Shortcuts + +When vim mode is enabled (`/vim` command or `general.vimMode: true` in settings), +the CLI supports modal editing with NORMAL and INSERT modes. + +### Mode Switching + +| Action | Keys | +| ------------------------------------------- | --------------------------------- | +| Enter NORMAL mode (from INSERT) | `Esc` | +| Enter INSERT mode at cursor (from NORMAL) | `i` | +| Enter INSERT mode after cursor | `a` | +| Enter INSERT mode at start of line | `I` | +| Enter INSERT mode at end of line | `A` | +| Insert new line below and enter INSERT mode | `o` | +| Insert new line above and enter INSERT mode | `O` | +| Clear input (double Escape in NORMAL mode) | `Esc` `Esc` | + +### Navigation (NORMAL mode) + +| Action | Keys | +| ----------------------------------- | ----------------------- | +| Move left | `h` or `←` | +| Move down | `j` or `↓` | +| Move up | `k` or `↑` | +| Move right | `l` or `→` | +| Move to start of line | `0` | +| Move to first non-whitespace char | `^` | +| Move to end of line | `$` | +| Move forward by word | `w` | +| Move backward by word | `b` | +| Move to end of word | `e` | +| Move forward by WORD (non-space) | `W` | +| Move backward by WORD | `B` | +| Move to end of WORD | `E` | +| Go to first line | `gg` | +| Go to last line | `G` | +| Go to line N | `N` `G` or `N` `gg` | + +**Note:** All navigation commands support numeric prefixes (e.g., `5j` moves down 5 lines, `3w` moves forward 3 words). + +### Editing (NORMAL mode) + +| Action | Keys | +| ------------------------------------------ | ------------ | +| Delete character under cursor | `x` | +| Delete from cursor to end of line | `D` | +| Delete entire line(s) | `dd` | +| Change from cursor to end of line | `C` | +| Change entire line(s) | `cc` | +| Delete forward word | `dw` | +| Delete backward word | `db` | +| Delete to end of word | `de` | +| Delete forward WORD | `dW` | +| Delete backward WORD | `dB` | +| Delete to end of WORD | `dE` | +| Change forward word (delete + insert) | `cw` | +| Change backward word | `cb` | +| Change to end of word | `ce` | +| Change forward WORD | `cW` | +| Change backward WORD | `cB` | +| Change to end of WORD | `cE` | +| Change left (delete char + insert) | `ch` | +| Change down | `cj` | +| Change up | `ck` | +| Change right | `cl` | +| Delete left | `dh` | +| Delete down | `dj` | +| Delete up | `dk` | +| Delete right | `dl` | +| Delete to start of line | `d0` | +| Delete to first non-whitespace | `d^` | +| Change to start of line | `c0` | +| Change to first non-whitespace | `c^` | +| Delete from first line to current | `dgg` | +| Delete from current to last line | `dG` | +| Change from first line to current | `cgg` | +| Change from current to last line | `cG` | +| Undo last change | `u` | +| Repeat last command | `.` | + +**Note:** Editing commands support numeric prefixes (e.g., `3dd` deletes 3 lines, `2cw` changes 2 words). + - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, otherwise browse and rewind previous interactions. - `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 9f632693c7b..9d674dae301 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -130,7 +130,7 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping **Missing commands** -- Add to custom Dockerfile. +- Add to custom Dockerfile (requires running from source code; not available when installed via npm). - Install via `sandbox.bashrc`. **Network issues** diff --git a/docs/get-started/configuration-v1.md b/docs/get-started/configuration-v1.md index cd1325b977f..11b66a833e8 100644 --- a/docs/get-started/configuration-v1.md +++ b/docs/get-started/configuration-v1.md @@ -844,6 +844,12 @@ sandbox image: BUILD_SANDBOX=1 gemini -s ``` +**Note:** Building custom sandboxes from a Dockerfile is only supported when +running Gemini CLI from source code. If you installed the CLI via npm +(`npm install -g @google/gemini-cli`), the `BUILD_SANDBOX` feature is not +available. To use custom sandboxes with the npm package, you need to build your +Docker image separately and reference it via configuration. + ## Usage statistics To help us improve the Gemini CLI, we collect anonymized usage statistics. This diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 5f6b89b9a2d..abd554d7268 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -1606,6 +1606,12 @@ sandbox image: BUILD_SANDBOX=1 gemini -s ``` +**Note:** Building custom sandboxes from a Dockerfile is only supported when +running Gemini CLI from source code. If you installed the CLI via npm +(`npm install -g @google/gemini-cli`), the `BUILD_SANDBOX` feature is not +available. To use custom sandboxes with the npm package, you need to build your +Docker image separately and reference it via configuration. + ## Usage statistics To help us improve the Gemini CLI, we collect anonymized usage statistics. This From 7e19a7b9003606b811497c9008be0518ebb30d5e Mon Sep 17 00:00:00 2001 From: Christopher Thomas Date: Mon, 13 Apr 2026 22:38:08 -0500 Subject: [PATCH 2/2] fix(core): honor custom Gemini and Vertex base URLs --- .../core/src/core/contentGenerator.test.ts | 122 ++++++++++++++++++ packages/core/src/core/contentGenerator.ts | 29 ++++- 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 9b7c3ac8028..4d9f54cb686 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -475,6 +475,128 @@ describe('createContentGenerator', () => { apiVersion: 'v1alpha', }); }); + + it('should pass baseUrl to GoogleGenAI when GOOGLE_GEMINI_BASE_URL is set', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_GEMINI, + ); + await createContentGenerator(config, mockConfig); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + vertexai: false, + httpOptions: expect.objectContaining({ + baseUrl: 'https://gemini.test.local', + }), + }), + ); + }); + + it('should pass baseUrl to GoogleGenAI when GOOGLE_VERTEX_BASE_URL is set', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local'); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project'); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1'); + + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_VERTEX_AI, + ); + await createContentGenerator(config, mockConfig); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: undefined, + vertexai: true, + httpOptions: expect.objectContaining({ + baseUrl: 'https://vertex.test.local', + }), + }), + ); + }); + + it('should allow localhost baseUrl overrides over http', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + + await createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'http://127.0.0.1:8080', + }, + mockConfig, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: 'http://127.0.0.1:8080', + }), + }), + ); + }); + + it('should reject invalid custom baseUrl values', async () => { + await expect( + createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'not-a-url', + }, + mockConfig, + ), + ).rejects.toThrow('Invalid custom base URL: not-a-url'); + }); + + it('should reject non-https remote custom baseUrl values', async () => { + await expect( + createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'http://example.com', + }, + mockConfig, + ), + ).rejects.toThrow( + 'Custom base URL must use HTTPS unless it is localhost.', + ); + }); + }); describe('createContentGeneratorConfig', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 0c9b36634ef..621da47a0e7 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -82,8 +82,26 @@ export type ContentGeneratorConfig = { vertexai?: boolean; authType?: AuthType; proxy?: string; + baseUrl?: string; }; +const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]']; + +function validateBaseUrl(baseUrl: string): void { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error(`Invalid custom base URL: ${baseUrl}`); + } + + if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) { + throw new Error( + 'Custom base URL must use HTTPS unless it is localhost.', + ); + } +} + export async function createContentGeneratorConfig( config: Config, authType: AuthType | undefined, @@ -113,6 +131,7 @@ export async function createContentGeneratorConfig( if (authType === AuthType.USE_GEMINI && geminiApiKey) { contentGeneratorConfig.apiKey = geminiApiKey; contentGeneratorConfig.vertexai = false; + contentGeneratorConfig.baseUrl = process.env['GOOGLE_GEMINI_BASE_URL']; return contentGeneratorConfig; } @@ -123,6 +142,7 @@ export async function createContentGeneratorConfig( ) { contentGeneratorConfig.apiKey = googleApiKey; contentGeneratorConfig.vertexai = true; + contentGeneratorConfig.baseUrl = process.env['GOOGLE_VERTEX_BASE_URL']; return contentGeneratorConfig; } @@ -194,7 +214,14 @@ export async function createContentGenerator( 'x-gemini-api-privileged-user-id': `${installationId}`, }; } - const httpOptions = { headers }; + const httpOptions: { + headers: Record; + baseUrl?: string; + } = { headers }; + if (config.baseUrl) { + validateBaseUrl(config.baseUrl); + httpOptions.baseUrl = config.baseUrl; + } const googleGenAI = new GoogleGenAI({ apiKey: config.apiKey === '' ? undefined : config.apiKey,