Compare commits

...

24 Commits

Author SHA1 Message Date
gemini-cli-robot 5839bb5381 chore(release): v0.12.0 2025-11-04 23:27:04 +00:00
gemini-cli-robot 9e7c80f276 chore(release): v0.12.0-preview.11 2025-11-04 22:03:06 +00:00
christine betts d6f977cb8f Cherry pick screen reader nudge changes (#12553) 2025-11-04 19:41:35 +00:00
Abhi 51a415f2b9 fix: cherry-pick commits for release (#12549) 2025-11-04 19:13:51 +00:00
gemini-cli-robot a2cb116980 fix(patch): cherry-pick fd2cbac to release/v0.12.0-preview.9-pr-12399 [CONFLICTS] (#12488)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-03 14:29:38 -05:00
gemini-cli-robot 1c44586582 chore(release): v0.12.0-preview.9 2025-10-31 21:37:01 +00:00
Gaurav Ghosh a893c8c7fe refactor: simplify daily quota error messages 2025-10-31 14:02:44 -07:00
Tommaso Sciortino 09a6592097 Mark model.compressionThreshold as requiring a restart (#12378) 2025-10-31 14:01:03 -07:00
Abhi d756ef64f2 Remove context percentage in footer by default (#12326) 2025-10-31 14:00:37 -07:00
gemini-cli-robot 5d7772ff58 chore(release): v0.12.0-preview.8 2025-10-31 16:45:39 +00:00
gemini-cli-robot cc076e95a8 fix(patch): cherry-pick 11e1e98 to release/v0.12.0-preview.7-pr-12347 to patch version v0.12.0-preview.7 and create version 0.12.0-preview.8 (#12383)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-10-31 16:31:39 +00:00
gemini-cli-robot 3d44880ea6 chore(release): v0.12.0-preview.7 2025-10-31 15:29:31 +00:00
gemini-cli-robot 9fbea50ee5 fix(patch): cherry-pick 135d981 to release/v0.12.0-preview.6-pr-12299 to patch version v0.12.0-preview.6 and create version 0.12.0-preview.7 (#12368)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2025-10-31 11:14:57 -04:00
gemini-cli-robot a0198269e8 chore(release): v0.12.0-preview.6 2025-10-31 02:11:16 +00:00
gemini-cli-robot fe44afe892 fix(patch): cherry-pick 3332703 to release/v0.12.0-preview.5-pr-12317 to patch version v0.12.0-preview.5 and create version 0.12.0-preview.6 (#12334)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-10-30 18:58:03 -07:00
gemini-cli-robot 2072d90ab2 chore(release): v0.12.0-preview.5 2025-10-31 01:25:18 +00:00
gemini-cli-robot 174462f4a4 fix(patch): cherry-pick 643f2c0 to release/v0.12.0-preview.4-pr-12300 to patch version v0.12.0-preview.4 and create version 0.12.0-preview.5 (#12329)
Co-authored-by: Victor May <mayvic@google.com>
2025-10-31 00:31:43 +00:00
gemini-cli-robot 30dc89c8af chore(release): v0.12.0-preview.4 2025-10-30 23:49:24 +00:00
gemini-cli-robot 66b61a13b4 fix(patch): cherry-pick 68afb72 to release/v0.12.0-preview.3-pr-12306 to patch version v0.12.0-preview.3 and create version 0.12.0-preview.4 (#12327)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-10-30 23:35:15 +00:00
gemini-cli-robot cd4bebba9e chore(release): v0.12.0-preview.3 2025-10-30 22:44:31 +00:00
gemini-cli-robot 3876379bf7 fix(patch): cherry-pick 82c1042 to release/v0.12.0-preview.2-pr-12231 to patch version v0.12.0-preview.2 and create version 0.12.0-preview.3 (#12320)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-10-30 22:28:07 +00:00
gemini-cli-robot 7124551aa5 chore(release): v0.12.0-preview.2 2025-10-30 22:07:18 +00:00
matt korwel b4a63bf73b Merge commit 'b382ae6803ce21ead2a91682fc58126f3786f15b' into HEAD 2025-10-30 14:52:52 -07:00
gemini-cli-robot fe98c85535 chore(release): v0.12.0-preview.0 2025-10-29 00:57:46 +00:00
42 changed files with 618 additions and 300 deletions
+9
View File
@@ -67,3 +67,12 @@ This document lists the available keyboard shortcuts in the Gemini CLI.
| Shortcut | Description |
| -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE |
## Meta+key combos on mac
On Mac, all Meta+char combos should work normally except for these three which
are mapped to special functionality.
- `meta+b`: "∫" back one word
- `meta+f`: "ƒ" forward one word
- `meta+m`: "µ" toggle markup view
+6 -4
View File
@@ -539,10 +539,6 @@ Measures tool usage and latency.
- `decision` (string: "accept", "reject", "modify", or "auto_accept", if
applicable)
- `tool_type` (string: "mcp" or "native", if applicable)
- `model_added_lines` (Int, optional)
- `model_removed_lines` (Int, optional)
- `user_added_lines` (Int, optional)
- `user_removed_lines` (Int, optional)
- `gemini_cli.tool.call.latency` (Histogram, ms): Measures tool call latency.
- **Attributes**:
@@ -586,6 +582,12 @@ Counts file operations with basic context.
- `extension` (string, optional)
- `programming_language` (string, optional)
- `gemini_cli.lines.changed` (Counter, Int): Number of lines changed (from file
diffs).
- **Attributes**:
- `function_name`
- `type` ("added" or "removed")
##### Chat and Streaming
Resilience counters for compression, invalid chunks, and retries.
-15
View File
@@ -473,21 +473,6 @@ a few things you can try in order of recommendation:
"loadMemoryFromIncludeDirectories": true
```
- **`chatCompression`** (object):
- **Description:** Controls the settings for chat history compression, both
automatic and when manually invoked through the /compress command.
- **Properties:**
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that
specifies the token threshold for compression as a percentage of the
model's total token limit. For example, a value of `0.6` will trigger
compression when the chat history exceeds 60% of the token limit.
- **Example:**
```json
"chatCompression": {
"contextPercentageThreshold": 0.6
}
```
- **`showLineNumbers`** (boolean):
- **Description:** Controls whether line numbers are displayed in code blocks
in the CLI output.
+3 -3
View File
@@ -245,13 +245,13 @@ their corresponding top-level category object in your `settings.json` file.
example `{"run_shell_command": {"tokenBudget": 2000}}`
- **Default:** `undefined`
- **`model.chatCompression.contextPercentageThreshold`** (number):
- **`model.compressionThreshold`** (number):
- **Description:** Sets the threshold for chat history compression as a
percentage of the model's total token limit. This is a value between 0 and 1
fraction of the model's total token limit. This is a value between 0 and 1
that applies to both automatic compression and the manual `/compress`
command. For example, a value of `0.6` will trigger compression when the
chat history exceeds 60% of the token limit.
- **Default:** `0.7`
- **Default:** `0.2`
- **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check.
+25
View File
@@ -161,6 +161,31 @@ export default tseslint.config(
'default-case': 'error',
},
},
{
// Prevent self-imports in packages
files: ['packages/core/src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
name: '@google/gemini-cli-core',
message: 'Please use relative imports within the @google/gemini-cli-core package.',
},
],
},
},
{
files: ['packages/cli/src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
name: '@google/gemini-cli',
message: 'Please use relative imports within the @google/gemini-cli package.',
},
],
},
},
{
files: ['packages/*/src/**/*.test.{ts,tsx}'],
plugins: {
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Investigating File Access**\n\nI'm currently focused on the challenge of reading a file. The path provided is `/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt`, and I'm anticipating an error. It's safe to assume the file doesn't exist, which I intend to handle by responding with \"File not found\" as instructed.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12418,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":115}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Analyzing Error Handling**\n\nI've attempted to read the specified file, expecting an error due to the \"nonexistent\" path. My plan is to catch the error thrown by the `read_file` tool. Upon receiving this error, I'll promptly return \"File not found.\" This is in line with the initial instructions and ensures appropriate error management for the user's intended functionality. I'm now testing the error response.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12467,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb0c450IIdZRHl1vvjWDAl9oKa7s5sfFgrTnU0w3qBQwKYgHR7YpvPjZlXSHaJNYgX8IuCvxfyyeACX4NZ8u+u3Z1kqQbgOMpiH6aPYCK9lPyhVftPDBF2m3j7Y2AShwXSpB+9+UB3zphOKCvq6i0ZLvK6QzVynZ1fySQacyjEBD+U6y5CpoBAdHtim9D6oskRu7f3x8rp56h24i6dwb1hzlyqLGl3A5Hsh/fGYjBCxR+Vs+U5Sb7LunmFMKxmO1fktz0x06FUiyaWgAaXl4E4FyLTKs9BbPdgo58uAhaI3vTQybeGZQzkRi6n+ywCniVKaTUd07EHYWdvLiB6x5zFKVOiTmh8PC6I4vNOkJIzFXRfR5aM5QFjEQY8R88HBH+ugraAQHR7YpvuNUG8Ttbzg+kq+kJYMNGDG4zVLPxFBbJZYU5zGLFLrNeEpp9HJ+1N1Pdts17J5pzoefKd2U9H5muPxoeW1CCpFqqKfYZNyoChrQnb4CgWoJ92pcb2UAZbDQ6H4rTyXWdXaAEP1i8rOmdWzI5GFPyxCwBKAbFhfMJOo7P4va5H13XTlLgWAgXlx3n24ookkD/DOL9ro/F6pmV6tzf/j7RVl0V5yozngnrFl4dFsQPp1nDQcFkbRIKHD30byjLGh6Vd6A2lrB477qUaCRfPXtlEgKgsOSYCuABAdHtim+PJPZV3QuQoJ0Qokr+vcdW1W2qzC4Vvt4QRJXyp6+eIS6iNjOisk4jfWLHmLTa11KrUSQsFJnGwsFhe1foOqfO4y46ROBPeSdyuc0gDzSCZPowiovGBwoZd+MbPTTVyExWBo6StVk+xWLj+sUqa8VDof45AOOK9kdzcLQPa2SXIeK6SYLS1zVTrbzGHQDZPv9UC99lmKxPFqAcuwpe9CQre9J3GcZSwd5Mfs/UkHbWui10a8jga4Ck7umjKSDFq/VWiRUp/jh+EN4xeLTG/UQhgH49Gw+Bi1hN1uYKIgHR7YpvS0j1oO2HV/HeK3YxSku9CLQ3OSJGhoAKiu8UGYE=","functionCall":{"name":"read_file","args":{"absolute_path":"/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12303,"candidatesTokenCount":58,"totalTokenCount":12525,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File not found"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12588,"candidatesTokenCount":3,"totalTokenCount":12591,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12588}]}}]}
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"workspaces": [
"packages/*"
],
@@ -17434,7 +17434,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17708,7 +17708,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.16.0",
@@ -17828,7 +17828,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -17969,7 +17969,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -17980,7 +17980,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-nightly.20251022.0542de95"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -127,7 +127,10 @@
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix --max-warnings 0"
"eslint --fix --max-warnings 0 --no-warn-ignored"
],
"eslint.config.js": [
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-nightly.20251022.0542de95"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
+6 -10
View File
@@ -1545,7 +1545,7 @@ describe('loadCliConfig with includeDirectories', () => {
});
});
describe('loadCliConfig chatCompression', () => {
describe('loadCliConfig compressionThreshold', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
@@ -1558,28 +1558,24 @@ describe('loadCliConfig chatCompression', () => {
vi.restoreAllMocks();
});
it('should pass chatCompression settings to the core config', async () => {
it('should pass settings to the core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
model: {
chatCompression: {
contextPercentageThreshold: 0.5,
},
compressionThreshold: 0.5,
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getChatCompression()).toEqual({
contextPercentageThreshold: 0.5,
});
expect(config.getCompressionThreshold()).toBe(0.5);
});
it('should have undefined chatCompression if not in settings', async () => {
it('should have undefined compressionThreshold if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getChatCompression()).toBeUndefined();
expect(config.getCompressionThreshold()).toBeUndefined();
});
});
+1 -1
View File
@@ -684,7 +684,7 @@ export async function loadCliConfig(
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
compressionThreshold: settings.model?.compressionThreshold,
folderTrust,
interactive,
trustedFolder,
+15 -59
View File
@@ -1104,15 +1104,15 @@ describe('Settings Loading and Merging', () => {
});
});
it('should merge chatCompression settings, with workspace taking precedence', () => {
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
model: { compressionThreshold: 0.5 },
};
const workspaceSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 0.8 } },
model: { compressionThreshold: 0.8 },
};
(fs.readFileSync as Mock).mockImplementation(
@@ -1127,15 +1127,11 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.workspace.settings.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
0.8,
);
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
});
it('should merge output format settings, with workspace taking precedence', () => {
@@ -1162,13 +1158,13 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.output?.format).toBe('json');
});
it('should handle chatCompression when only in user settings', () => {
it('should handle compressionThreshold when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
model: { compressionThreshold: 0.5 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
@@ -1179,9 +1175,7 @@ describe('Settings Loading and Merging', () => {
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
});
it('should have model as undefined if not in any settings file', () => {
@@ -1191,39 +1185,15 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.model).toBeUndefined();
});
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 1.5 } },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 1.5,
});
warnSpy.mockRestore();
});
it('should deep merge chatCompression settings', () => {
it('should use user compressionThreshold if workspace does not define it', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
model: { compressionThreshold: 0.5 },
};
const workspaceSettingsContent = {
general: {},
model: { chatCompression: {} },
model: {},
};
(fs.readFileSync as Mock).mockImplementation(
@@ -1238,9 +1208,7 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
});
it('should merge includeDirectories from all scopes', () => {
@@ -2025,9 +1993,6 @@ describe('Settings Loading and Merging', () => {
},
model: {
name: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.5,
},
},
mcpServers: {
'server-1': {
@@ -2046,9 +2011,6 @@ describe('Settings Loading and Merging', () => {
myTheme: {},
},
model: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.5,
},
mcpServers: {
'server-1': {
command: 'node server.js',
@@ -2088,9 +2050,6 @@ describe('Settings Loading and Merging', () => {
},
model: {
name: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.8,
},
},
context: {
fileName: 'CONTEXT.md',
@@ -2130,9 +2089,6 @@ describe('Settings Loading and Merging', () => {
theme: 'dark',
usageStatisticsEnabled: false,
model: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.8,
},
contextFileName: 'CONTEXT.md',
includeDirectories: ['/src'],
sandbox: true,
+1 -1
View File
@@ -64,7 +64,7 @@ const MIGRATION_MAP: Record<string, string> = {
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.chatCompression',
chatCompression: 'model.compressionThreshold',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
@@ -159,6 +159,10 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.footer.properties
.hideContextPercentage.showInDialog,
).toBe(true);
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
true,
);
+18 -9
View File
@@ -14,7 +14,6 @@ import type {
BugCommandSettings,
TelemetrySettings,
AuthType,
ChatCompressionSettings,
} from '@google/gemini-cli-core';
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
@@ -389,6 +388,15 @@ const SETTINGS_SCHEMA = {
description: 'Hide the model name and context usage in the footer.',
showInDialog: true,
},
hideContextPercentage: {
type: 'boolean',
label: 'Hide Context Window Percentage',
category: 'UI',
requiresRestart: false,
default: true,
description: 'Hides the context window remaining percentage.',
showInDialog: true,
},
},
},
hideFooter: {
@@ -578,14 +586,15 @@ const SETTINGS_SCHEMA = {
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
compressionThreshold: {
type: 'number',
label: 'Compression Threshold',
category: 'Model',
requiresRestart: false,
default: undefined as ChatCompressionSettings | undefined,
description: 'Chat compression settings.',
showInDialog: false,
requiresRestart: true,
default: 0.2 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
},
skipNextSpeakerCheck: {
type: 'boolean',
@@ -1128,7 +1137,7 @@ const SETTINGS_SCHEMA = {
label: 'Thinking Budget',
category: 'Experimental',
requiresRestart: true,
default: -1,
default: 8192,
description:
'The thinking budget for the Codebase Investigator agent.',
showInDialog: false,
+37 -1
View File
@@ -47,7 +47,7 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { useContext } from 'react';
import { useContext, act } from 'react';
// Mock useStdout to capture terminal title writes
let mockStdout: { write: ReturnType<typeof vi.fn> };
@@ -1395,5 +1395,41 @@ describe('AppContainer State Management', () => {
expect.any(Number),
);
});
it('updates currentModel when ModelChanged event is received', async () => {
// Arrange: Mock initial model
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// Verify initial model
await act(async () => {
await vi.waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
});
});
// Get the registered handler for ModelChanged
const handler = mockCoreEvents.on.mock.calls.find(
(call: unknown[]) => call[0] === CoreEvent.ModelChanged,
)?.[1];
expect(handler).toBeDefined();
// Act: Simulate ModelChanged event
act(() => {
handler({ model: 'new-model' });
});
// Assert: Verify model is updated
expect(capturedUIState.currentModel).toBe('new-model');
unmount();
});
});
});
+8 -1
View File
@@ -47,6 +47,7 @@ import {
debugLogger,
coreEvents,
CoreEvent,
type ModelChangedPayload,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
@@ -253,16 +254,22 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager.addItem],
);
// Subscribe to fallback mode changes from core
// Subscribe to fallback mode and model changes from core
useEffect(() => {
const handleFallbackModeChanged = () => {
const effectiveModel = getEffectiveModel();
setCurrentModel(effectiveModel);
};
const handleModelChanged = (payload: ModelChangedPayload) => {
setCurrentModel(payload.model);
};
coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
return () => {
coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
};
}, [getEffectiveModel]);
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
renderWithProviders,
createMockSettings,
@@ -120,6 +121,13 @@ describe('<Footer />', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
@@ -129,6 +137,13 @@ describe('<Footer />', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
@@ -192,6 +207,13 @@ describe('<Footer />', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
});
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
});
@@ -247,10 +269,49 @@ describe('<Footer />', () => {
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
});
it('hides the context percentage when hideContextPercentage is true', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
});
it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
});
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});
+13 -6
View File
@@ -60,6 +60,8 @@ export const Footer: React.FC = () => {
const hideSandboxStatus =
settings.merged.ui?.footer?.hideSandboxStatus || false;
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
const hideContextPercentage =
settings.merged.ui?.footer?.hideContextPercentage ?? true;
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
@@ -145,12 +147,17 @@ export const Footer: React.FC = () => {
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
{model}{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={mainAreaWidth}
/>
{model}
{!hideContextPercentage && (
<>
{' '}
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={mainAreaWidth}
/>
</>
)}
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
@@ -5,41 +5,86 @@
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useEffect, useState } from 'react';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import { homedir } from 'node:os';
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const settingsPath = path.join(homedir(), GEMINI_DIR, 'settings.json');
const settingsPath = path.join(os.homedir(), GEMINI_DIR, 'settings.json');
const screenReaderNudgeFilePath = path.join(
Storage.getGlobalTempDir(),
'seen_screen_reader_nudge.json',
);
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const showStartupWarnings = startupWarnings.length > 0;
const showInitError =
initError && streamingState !== StreamingState.Responding;
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState<
boolean | undefined
>(undefined);
useEffect(() => {
const checkScreenReaderNudge = async () => {
try {
await fs.access(screenReaderNudgeFilePath);
setHasSeenScreenReaderNudge(true);
} catch {
setHasSeenScreenReaderNudge(false);
}
};
checkScreenReaderNudge();
}, []);
const showScreenReaderNudge =
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
useEffect(() => {
const writeScreenReaderNudgeFile = async () => {
if (showScreenReaderNudge) {
try {
await fs.mkdir(path.dirname(screenReaderNudgeFilePath), {
recursive: true,
});
await fs.writeFile(screenReaderNudgeFilePath, 'true');
} catch (error) {
console.error('Error storing screen reader nudge', error);
}
}
};
writeScreenReaderNudgeFile();
}, [showScreenReaderNudge]);
if (
!showStartupWarnings &&
!showInitError &&
!updateInfo &&
!isScreenReaderEnabled
!showScreenReaderNudge
) {
return null;
}
return (
<>
{isScreenReaderEnabled && (
{showScreenReaderNudge && (
<Text>
You are currently in screen reader-friendly view. To switch out, open{' '}
{settingsPath} and remove the entry for {'"screenReader"'}.
{settingsPath} and remove the entry for {'"screenReader"'}. This will
disappear on next run.
</Text>
)}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
@@ -872,99 +872,118 @@ describe('Kitty Sequence Parsing', () => {
vi.useRealTimers();
});
// Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
describe('Cross-terminal Alt key handling (simulating macOS)', () => {
let originalPlatform: NodeJS.Platform;
// Key mappings: letter -> [keycode, accented character]
const keys: Record<string, [number, string]> = {
a: [97, 'å'],
o: [111, 'ø'],
m: [109, 'µ'],
};
it.each(
terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
return {
terminal,
key,
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
kittyProtocol: true,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
return {
terminal,
key,
kitty: false,
chunk: `\x1b${key}`,
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
paste: false,
sequence: accentedChar,
},
};
}
}),
),
)(
'should handle Alt+$key in $terminal',
({
chunk,
expected,
kitty = true,
}: {
chunk: string;
expected: Partial<Key>;
kitty?: boolean;
}) => {
const keyHandler = vi.fn();
const testWrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider kittyProtocolEnabled={kitty}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: testWrapper,
beforeEach(() => {
originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
});
act(() => result.current.subscribe(keyHandler));
});
act(() => stdin.write(chunk));
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected),
);
},
);
// Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
// Key mappings: letter -> [keycode, accented character]
const keys: Record<string, [number, string]> = {
b: [98, '\u222B'],
f: [102, '\u0192'],
m: [109, '\u00B5'],
};
it.each(
terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
return {
terminal,
key,
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
kittyProtocol: true,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
return {
terminal,
key,
kitty: false,
chunk: `\x1b${key}`,
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
paste: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
paste: false,
sequence: accentedChar,
},
};
}
}),
),
)(
'should handle Alt+$key in $terminal',
({
chunk,
expected,
kitty = true,
}: {
chunk: string;
expected: Partial<Key>;
kitty?: boolean;
}) => {
const keyHandler = vi.fn();
const testWrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider kittyProtocolEnabled={kitty}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: testWrapper,
});
act(() => result.current.subscribe(keyHandler));
act(() => stdin.write(chunk));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected),
);
},
);
});
describe('Backslash key handling', () => {
beforeEach(() => {
@@ -48,33 +48,12 @@ export const PASTE_CODE_TIMEOUT_MS = 50; // Flush incomplete paste code after 50
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
const ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u00E5': 'a',
'\u222B': 'b',
'\u00E7': 'c',
'\u2202': 'd',
'\u00B4': 'e',
'\u0192': 'f',
'\u00A9': 'g',
'\u02D9': 'h',
'\u02C6': 'i',
'\u2206': 'j',
'\u02DA': 'k',
'\u00AC': 'l',
'\u00B5': 'm',
'\u02DC': 'n',
'\u00F8': 'o',
'\u03C0': 'p',
'\u0153': 'q',
'\u00AE': 'r',
'\u00DF': 's',
'\u2020': 't',
'\u00A8': 'u',
'\u221A': 'v',
'\u2211': 'w',
'\u2248': 'x',
'\u00A5': 'y',
'\u03A9': 'z',
// On Mac, hitting alt+char will yield funny characters.
// Remap these three since we listen for them.
const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u222B': 'b', // "∫" back one word
'\u0192': 'f', // "ƒ" forward one word
'\u00B5': 'm', // "µ" toggle markup view
};
/**
@@ -615,8 +594,8 @@ export function KeypressProvider({
return;
}
const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence];
if (mappedLetter && !key.meta) {
const mappedLetter = MAC_ALT_KEY_CHARACTER_MAP[key.sequence];
if (process.platform === 'darwin' && mappedLetter && !key.meta) {
broadcast({
name: mappedLetter,
ctrl: false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+29 -1
View File
@@ -139,6 +139,21 @@ vi.mock('../agents/subagent-tool-wrapper.js', () => ({
SubagentToolWrapper: vi.fn(),
}));
const mockCoreEvents = vi.hoisted(() => ({
emitFeedback: vi.fn(),
emitModelChanged: vi.fn(),
}));
const mockSetGlobalProxy = vi.hoisted(() => vi.fn());
vi.mock('../utils/events.js', () => ({
coreEvents: mockCoreEvents,
}));
vi.mock('../utils/fetch.js', () => ({
setGlobalProxy: mockSetGlobalProxy,
}));
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { tokenLimit } from '../core/tokenLimits.js';
import { uiTelemetryService } from '../telemetry/index.js';
@@ -563,7 +578,7 @@ describe('Server Config (config.ts)', () => {
useModelRouter: true,
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getUseModelRouter()).toBe(false);
expect(config.getUseModelRouter()).toBe(true);
});
it('should enable model router by default for other auth types', async () => {
@@ -634,6 +649,19 @@ describe('Server Config (config.ts)', () => {
expect(config.getUseModelRouter()).toBe(true);
expect(config.getModel()).toBe(chosenModel);
});
it('should NOT switch to auto model if cli provides specific model, even if router is enabled', async () => {
const config = new Config({
...baseParams,
useModelRouter: true,
model: 'gemini-flash-latest',
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(config.getUseModelRouter()).toBe(true);
expect(config.getModel()).toBe('gemini-flash-latest');
});
});
describe('ContinueOnFailedApiCall Configuration', () => {
+11 -15
View File
@@ -41,6 +41,7 @@ import {
DEFAULT_OTLP_ENDPOINT,
uiTelemetryService,
} from '../telemetry/index.js';
import { coreEvents } from '../utils/events.js';
import { tokenLimit } from '../core/tokenLimits.js';
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -92,10 +93,6 @@ export interface BugCommandSettings {
urlTemplate: string;
}
export interface ChatCompressionSettings {
contextPercentageThreshold?: number;
}
export interface SummarizeToolOutputSettings {
tokenBudget?: number;
}
@@ -261,7 +258,7 @@ export interface ConfigParameters {
folderTrust?: boolean;
ideMode?: boolean;
loadMemoryFromIncludeDirectories?: boolean;
chatCompression?: ChatCompressionSettings;
compressionThreshold?: number;
interactive?: boolean;
trustedFolder?: boolean;
useRipgrep?: boolean;
@@ -357,7 +354,7 @@ export class Config {
| undefined;
private readonly experimentalZedIntegration: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly chatCompression: ChatCompressionSettings | undefined;
private readonly compressionThreshold: number | undefined;
private readonly interactive: boolean;
private readonly ptyInfo: string;
private readonly trustedFolder: boolean | undefined;
@@ -460,7 +457,7 @@ export class Config {
this.ideMode = params.ideMode ?? false;
this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false;
this.chatCompression = params.chatCompression;
this.compressionThreshold = params.compressionThreshold;
this.interactive = params.interactive ?? false;
this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder;
@@ -483,9 +480,7 @@ export class Config {
this.useWriteTodos = params.useWriteTodos ?? false;
this.initialUseModelRouter = params.useModelRouter ?? false;
this.useModelRouter = this.initialUseModelRouter;
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [
AuthType.LOGIN_WITH_GOOGLE,
];
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
this.enableMessageBusIntegration =
params.enableMessageBusIntegration ?? false;
this.codebaseInvestigatorSettings = {
@@ -565,8 +560,6 @@ export class Config {
if (this.model === DEFAULT_GEMINI_MODEL_AUTO) {
this.model = DEFAULT_GEMINI_MODEL;
}
} else if (this.useModelRouter && this.model === DEFAULT_GEMINI_MODEL) {
this.model = DEFAULT_GEMINI_MODEL_AUTO;
}
// Vertex and Genai have incompatible encryption and sending history with
@@ -644,7 +637,10 @@ export class Config {
return;
}
this.model = newModel;
if (this.model !== newModel) {
this.model = newModel;
coreEvents.emitModelChanged(newModel);
}
}
isInFallbackMode(): boolean {
@@ -994,8 +990,8 @@ export class Config {
this.fileSystemService = fileSystemService;
}
getChatCompression(): ChatCompressionSettings | undefined {
return this.chatCompression;
getCompressionThreshold(): number | undefined {
return this.compressionThreshold;
}
isInteractiveShellEnabled(): boolean {
+2 -2
View File
@@ -12,8 +12,8 @@ export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Some thinking models do not default to dynamic thinking which is done by a value of -1
export const DEFAULT_THINKING_MODE = -1;
// Cap the thinking at 8192 to prevent run-away thinking loops.
export const DEFAULT_THINKING_MODE = 8192;
/**
* Determines the effective model to use, applying fallback logic if necessary.
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents } from '../utils/events.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
@@ -34,7 +34,7 @@ vi.mock('../config/storage.js', () => ({
},
}));
vi.mock('@google/gemini-cli-core', () => ({
vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
},
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents } from '../utils/events.js';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
@@ -72,7 +72,7 @@ describe('findCompressSplitPoint', () => {
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
});
it('should return earlier splitpoint if no valid ones are after threshhold', () => {
it('should return earlier splitpoint if no valid ones are after threshold', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
@@ -116,7 +116,7 @@ describe('ChatCompressionService', () => {
getHistory: vi.fn(),
} as unknown as GeminiChat;
mockConfig = {
getChatCompression: vi.fn(),
getCompressionThreshold: vi.fn(),
getContentGenerator: vi.fn(),
} as unknown as Config;
@@ -17,10 +17,10 @@ import { makeChatCompressionEvent } from '../telemetry/types.js';
import { getInitialChatHistory } from '../utils/environmentContext.js';
/**
* Threshold for compression token count as a fraction of the model's token limit.
* If the chat history exceeds this threshold, it will be compressed.
* Default threshold for compression token count as a fraction of the model's
* token limit. If the chat history exceeds this threshold, it will be compressed.
*/
export const COMPRESSION_TOKEN_THRESHOLD = 0.7;
export const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.2;
/**
* The fraction of the latest chat history to keep. A value of 0.3
@@ -104,13 +104,10 @@ export class ChatCompressionService {
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
const contextPercentageThreshold =
config.getChatCompression()?.contextPercentageThreshold;
// Don't compress if not forced and we are under the limit.
if (!force) {
const threshold =
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
config.getCompressionThreshold() ?? DEFAULT_COMPRESSION_TOKEN_THRESHOLD;
if (originalTokenCount < threshold * tokenLimit(model)) {
return {
newHistory: null,
@@ -143,6 +143,19 @@ describe('LoopDetectionService', () => {
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
it('should stop reporting a loop if disabled after detection', () => {
const event = createToolCallRequestEvent('testTool', { param: 'value' });
for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) {
service.addAndCheck(event);
}
expect(service.addAndCheck(event)).toBe(true);
service.disableForSession();
// Should now return false even though a loop was previously detected
expect(service.addAndCheck(event)).toBe(false);
});
});
describe('Content Loop Detection', () => {
@@ -123,7 +123,11 @@ export class LoopDetectionService {
* @returns true if a loop is detected, false otherwise
*/
addAndCheck(event: ServerGeminiStreamEvent): boolean {
if (this.loopDetected || this.disabledForSession) {
if (this.disabledForSession) {
return false;
}
if (this.loopDetected) {
return this.loopDetected;
}
+17 -4
View File
@@ -786,12 +786,16 @@ describe('loggers', () => {
const mockMetrics = {
recordToolCallMetrics: vi.fn(),
recordLinesChanged: vi.fn(),
};
beforeEach(() => {
vi.spyOn(metrics, 'recordToolCallMetrics').mockImplementation(
mockMetrics.recordToolCallMetrics,
);
vi.spyOn(metrics, 'recordLinesChanged').mockImplementation(
mockMetrics.recordLinesChanged,
);
mockLogger.emit.mockReset();
});
@@ -888,10 +892,6 @@ describe('loggers', () => {
success: true,
decision: ToolCallDecision.ACCEPT,
tool_type: 'native',
model_added_lines: 1,
model_removed_lines: 2,
user_added_lines: 5,
user_removed_lines: 6,
},
);
@@ -900,6 +900,19 @@ describe('loggers', () => {
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
expect(mockMetrics.recordLinesChanged).toHaveBeenCalledWith(
mockConfig,
1,
'added',
{ function_name: 'test-function' },
);
expect(mockMetrics.recordLinesChanged).toHaveBeenCalledWith(
mockConfig,
2,
'removed',
{ function_name: 'test-function' },
);
});
it('should log a tool call with a reject decision', () => {
const call: ErroredToolCall = {
+16 -8
View File
@@ -63,6 +63,7 @@ import {
recordTokenUsageMetrics,
recordApiResponseMetrics,
recordAgentRunMetrics,
recordLinesChanged,
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import type { UiEvent } from './uiTelemetry.js';
@@ -118,15 +119,22 @@ export function logToolCall(config: Config, event: ToolCallEvent): void {
success: event.success,
decision: event.decision,
tool_type: event.tool_type,
...(event.metadata
? {
model_added_lines: event.metadata['model_added_lines'],
model_removed_lines: event.metadata['model_removed_lines'],
user_added_lines: event.metadata['user_added_lines'],
user_removed_lines: event.metadata['user_removed_lines'],
}
: {}),
});
if (event.metadata) {
const added = event.metadata['model_added_lines'];
if (typeof added === 'number' && added > 0) {
recordLinesChanged(config, added, 'added', {
function_name: event.function_name,
});
}
const removed = event.metadata['model_removed_lines'];
if (typeof removed === 'number' && removed > 0) {
recordLinesChanged(config, removed, 'removed', {
function_name: event.function_name,
});
}
}
}
export function logToolOutputTruncated(
@@ -94,6 +94,7 @@ describe('Telemetry Metrics', () => {
let recordFlickerFrameModule: typeof import('./metrics.js').recordFlickerFrame;
let recordExitFailModule: typeof import('./metrics.js').recordExitFail;
let recordAgentRunMetricsModule: typeof import('./metrics.js').recordAgentRunMetrics;
let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged;
beforeEach(async () => {
vi.resetModules();
@@ -136,6 +137,7 @@ describe('Telemetry Metrics', () => {
recordFlickerFrameModule = metricsJsModule.recordFlickerFrame;
recordExitFailModule = metricsJsModule.recordExitFail;
recordAgentRunMetricsModule = metricsJsModule.recordAgentRunMetrics;
recordLinesChangedModule = metricsJsModule.recordLinesChanged;
const otelApiModule = await import('@opentelemetry/api');
@@ -348,6 +350,53 @@ describe('Telemetry Metrics', () => {
});
});
describe('recordLinesChanged metric', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
getTelemetryEnabled: () => true,
} as unknown as Config;
it('should not record lines added/removed if not initialized', () => {
recordLinesChangedModule(mockConfig, 10, 'added', {
function_name: 'fn',
});
recordLinesChangedModule(mockConfig, 5, 'removed', {
function_name: 'fn',
});
expect(mockCounterAddFn).not.toHaveBeenCalled();
});
it('should record lines added with function_name after initialization', () => {
initializeMetricsModule(mockConfig);
mockCounterAddFn.mockClear();
recordLinesChangedModule(mockConfig, 10, 'added', {
function_name: 'my-fn',
});
expect(mockCounterAddFn).toHaveBeenCalledWith(10, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
type: 'added',
function_name: 'my-fn',
});
});
it('should record lines removed with function_name after initialization', () => {
initializeMetricsModule(mockConfig);
mockCounterAddFn.mockClear();
recordLinesChangedModule(mockConfig, 7, 'removed', {
function_name: 'my-fn',
});
expect(mockCounterAddFn).toHaveBeenCalledWith(7, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
type: 'removed',
function_name: 'my-fn',
});
});
});
describe('recordFileOperationMetric', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
+26 -5
View File
@@ -24,6 +24,7 @@ const API_REQUEST_LATENCY = 'gemini_cli.api.request.latency';
const TOKEN_USAGE = 'gemini_cli.token.usage';
const SESSION_COUNT = 'gemini_cli.session.count';
const FILE_OPERATION_COUNT = 'gemini_cli.file.operation.count';
const LINES_CHANGED = 'gemini_cli.lines.changed';
const INVALID_CHUNK_COUNT = 'gemini_cli.chat.invalid_chunk.count';
const CONTENT_RETRY_COUNT = 'gemini_cli.chat.content_retry.count';
const CONTENT_RETRY_FAILURE_COUNT =
@@ -72,11 +73,6 @@ const COUNTER_DEFINITIONS = {
success: boolean;
decision?: 'accept' | 'reject' | 'modify' | 'auto_accept';
tool_type?: 'native' | 'mcp';
// Optional diff statistics for file-modifying tools
model_added_lines?: number;
model_removed_lines?: number;
user_added_lines?: number;
user_removed_lines?: number;
},
},
[API_REQUEST_COUNT]: {
@@ -116,6 +112,15 @@ const COUNTER_DEFINITIONS = {
programming_language?: string;
},
},
[LINES_CHANGED]: {
description: 'Number of lines changed (from file diffs).',
valueType: ValueType.INT,
assign: (c: Counter) => (linesChangedCounter = c),
attributes: {} as {
function_name?: string;
type: 'added' | 'removed';
},
},
[INVALID_CHUNK_COUNT]: {
description: 'Counts invalid chunks received from a stream.',
valueType: ValueType.INT,
@@ -454,6 +459,7 @@ let apiRequestLatencyHistogram: Histogram | undefined;
let tokenUsageCounter: Counter | undefined;
let sessionCounter: Counter | undefined;
let fileOperationCounter: Counter | undefined;
let linesChangedCounter: Counter | undefined;
let chatCompressionCounter: Counter | undefined;
let invalidChunkCounter: Counter | undefined;
let contentRetryCounter: Counter | undefined;
@@ -621,6 +627,21 @@ export function recordFileOperationMetric(
});
}
export function recordLinesChanged(
config: Config,
lines: number,
changeType: 'added' | 'removed',
attributes?: { function_name?: string },
): void {
if (!linesChangedCounter || !isMetricsInitialized) return;
if (!Number.isFinite(lines) || lines <= 0) return;
linesChangedCounter.add(lines, {
...baseMetricDefinition.getCommonAttributes(config),
type: changeType,
...(attributes ?? {}),
});
}
// --- New Metric Recording Functions ---
/**
+13
View File
@@ -156,4 +156,17 @@ describe('CoreEventEmitter', () => {
});
expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' });
});
describe('ModelChanged Event', () => {
it('should emit ModelChanged event with correct payload', () => {
const listener = vi.fn();
events.on(CoreEvent.ModelChanged, listener);
const newModel = 'gemini-2.5-pro';
events.emitModelChanged(newModel);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ model: newModel });
});
});
});
+31
View File
@@ -43,9 +43,20 @@ export interface FallbackModeChangedPayload {
isInFallbackMode: boolean;
}
/**
* Payload for the 'model-changed' event.
*/
export interface ModelChangedPayload {
/**
* The new model that was set.
*/
model: string;
}
export enum CoreEvent {
UserFeedback = 'user-feedback',
FallbackModeChanged = 'fallback-mode-changed',
ModelChanged = 'model-changed',
}
export class CoreEventEmitter extends EventEmitter {
@@ -86,6 +97,14 @@ export class CoreEventEmitter extends EventEmitter {
this.emit(CoreEvent.FallbackModeChanged, payload);
}
/**
* Notifies subscribers that the model has changed.
*/
emitModelChanged(model: string): void {
const payload: ModelChangedPayload = { model };
this.emit(CoreEvent.ModelChanged, payload);
}
/**
* Flushes buffered messages. Call this immediately after primary UI listener
* subscribes.
@@ -106,6 +125,10 @@ export class CoreEventEmitter extends EventEmitter {
event: CoreEvent.FallbackModeChanged,
listener: (payload: FallbackModeChangedPayload) => void,
): this;
override on(
event: CoreEvent.ModelChanged,
listener: (payload: ModelChangedPayload) => void,
): this;
override on(
event: string | symbol,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -122,6 +145,10 @@ export class CoreEventEmitter extends EventEmitter {
event: CoreEvent.FallbackModeChanged,
listener: (payload: FallbackModeChangedPayload) => void,
): this;
override off(
event: CoreEvent.ModelChanged,
listener: (payload: ModelChangedPayload) => void,
): this;
override off(
event: string | symbol,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -138,6 +165,10 @@ export class CoreEventEmitter extends EventEmitter {
event: CoreEvent.FallbackModeChanged,
payload: FallbackModeChangedPayload,
): boolean;
override emit(
event: CoreEvent.ModelChanged,
payload: ModelChangedPayload,
): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override emit(event: string | symbol, ...args: any[]): boolean {
return super.emit(event, ...args);
+2 -2
View File
@@ -96,7 +96,7 @@ export function classifyGoogleError(error: unknown): unknown {
const quotaId = violation.quotaId ?? '';
if (quotaId.includes('PerDay') || quotaId.includes('Daily')) {
return new TerminalQuotaError(
`${googleApiError.message}\nExpected quota reset within 24h.`,
`You have exhausted your daily quota on this model.`,
googleApiError,
);
}
@@ -139,7 +139,7 @@ export function classifyGoogleError(error: unknown): unknown {
const quotaLimit = errorInfo.metadata?.['quota_limit'] ?? '';
if (quotaLimit.includes('PerDay') || quotaLimit.includes('Daily')) {
return new TerminalQuotaError(
`${googleApiError.message}\nExpected quota reset within 24h.`,
`You have exhausted your daily quota on this model.`,
googleApiError,
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.12.0-nightly.20251022.0542de95",
"version": "0.12.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {