Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/cli/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
6 changes: 6 additions & 0 deletions docs/get-started/configuration-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/get-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/core/contentGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
29 changes: 28 additions & 1 deletion packages/core/src/core/contentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -123,6 +142,7 @@ export async function createContentGeneratorConfig(
) {
contentGeneratorConfig.apiKey = googleApiKey;
contentGeneratorConfig.vertexai = true;
contentGeneratorConfig.baseUrl = process.env['GOOGLE_VERTEX_BASE_URL'];

return contentGeneratorConfig;
}
Expand Down Expand Up @@ -194,7 +214,14 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
const httpOptions = { headers };
const httpOptions: {
headers: Record<string, string>;
baseUrl?: string;
} = { headers };
if (config.baseUrl) {
validateBaseUrl(config.baseUrl);
httpOptions.baseUrl = config.baseUrl;
}

const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
Expand Down