From 54003283572f9bc8f6606877899728531da55ec1 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 09:48:17 +0000 Subject: [PATCH 01/32] Added image pasting functionality --- package-lock.json | 290 +++++++++++++++++ packages/cli/package.json | 2 + .../cli/src/ui/utils/clipboardUtils.test.ts | 301 +++++++++++++++--- packages/cli/src/ui/utils/clipboardUtils.ts | 168 ++++++---- .../cli/src/ui/utils/screenshotUtils.test.ts | 279 ++++++++++++++++ packages/cli/src/ui/utils/screenshotUtils.ts | 228 +++++++++++++ 6 files changed, 1165 insertions(+), 103 deletions(-) create mode 100644 packages/cli/src/ui/utils/screenshotUtils.test.ts create mode 100644 packages/cli/src/ui/utils/screenshotUtils.ts diff --git a/package-lock.json b/package-lock.json index 6ca4d8c31cb..d3142889c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -500,6 +500,150 @@ "node": ">=0.1.90" } }, + "node_modules/@crosscopy/clipboard": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard/-/clipboard-0.2.8.tgz", + "integrity": "sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@crosscopy/clipboard-darwin-arm64": "0.2.8", + "@crosscopy/clipboard-darwin-universal": "0.2.8", + "@crosscopy/clipboard-darwin-x64": "0.2.8", + "@crosscopy/clipboard-linux-arm64-gnu": "0.2.8", + "@crosscopy/clipboard-linux-riscv64-gnu": "0.2.8", + "@crosscopy/clipboard-linux-x64-gnu": "0.2.8", + "@crosscopy/clipboard-win32-arm64-msvc": "0.2.8", + "@crosscopy/clipboard-win32-x64-msvc": "0.2.8" + } + }, + "node_modules/@crosscopy/clipboard-darwin-arm64": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.2.8.tgz", + "integrity": "sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-darwin-universal": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-universal/-/clipboard-darwin-universal-0.2.8.tgz", + "integrity": "sha512-btGV1tLpJWZ4iKa66niahvpZpVRJzgQnYUE+PUX3YYZzaWD0ESuHuVtKVC8sR+b4dsXIiWW5skXbcRmLsF4rtA==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-darwin-x64": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-darwin-x64/-/clipboard-darwin-x64-0.2.8.tgz", + "integrity": "sha512-0QMKf0XrLZrprYYXU4lgaTuzbnYPh9wH6PvsfDB1FZvWf6rOi0syTaBZYnoghbQe700qwLPEfBRjgljJ3Tn6oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-arm64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.2.8.tgz", + "integrity": "sha512-8YrU03MRsygymqEcHkNgqCqSCQbYRmJCnMXeS4i8FYeOkAxBEeRvPbHoNmI10uppXJZNZgfIKM7Qqk9tEHiwqQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-riscv64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.2.8.tgz", + "integrity": "sha512-/QWLhnb0QYVjEv5GOAC1q+1DaezYU8Th+IoDKUCsR5i43Cqm+g+N/I2K35yo3J+HHkK9XNbtIDZDXlFgK6tRUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-linux-x64-gnu": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.2.8.tgz", + "integrity": "sha512-j17eaF/onP/6VAGGKtxA1KmmkErmdjta9gMdMV/yUmgeBYzJ9fMpWUzbk2vmaOyXfhaSzR/sk1P6VLBmvCpqHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-win32-arm64-msvc": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.2.8.tgz", + "integrity": "sha512-MVkMyuYN3y5v0s4HrijM0iA8hZVmpUhHd8X4zKG30t4nE6MbOjOt/8EabMrVmGZlsLeOL2sa0o8Wo9bvhWU+vA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@crosscopy/clipboard-win32-x64-msvc": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@crosscopy/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.2.8.tgz", + "integrity": "sha512-/GpiB4B3lSgg7eCLDQw9NfFjtQFjo0S88IL+EK54Hx7ZgAP4Ad/ezP/8dw0cA+N/M6iPYy0reCIjW9st82/uxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -12521,6 +12665,150 @@ "node": ">=18" } }, + "node_modules/node-screenshots": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots/-/node-screenshots-0.2.1.tgz", + "integrity": "sha512-1UY7VY/34uE6Giq/Winl0J7022KKwWt9T9Gu5ZBCxhXkWrv9q5pTVQRgZCcUIsIHq3zu8UFu5s8rqgauK2CnLA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "node-screenshots-darwin-arm64": "0.2.1", + "node-screenshots-darwin-universal": "0.2.1", + "node-screenshots-darwin-x64": "0.2.1", + "node-screenshots-linux-x64-gnu": "0.2.1", + "node-screenshots-linux-x64-musl": "0.2.1", + "node-screenshots-win32-arm64-msvc": "0.2.1", + "node-screenshots-win32-ia32-msvc": "0.2.1", + "node-screenshots-win32-x64-msvc": "0.2.1" + } + }, + "node_modules/node-screenshots-darwin-arm64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-arm64/-/node-screenshots-darwin-arm64-0.2.1.tgz", + "integrity": "sha512-mcNcdn5zABYNVXIb1vq58mItFlpr03T8VJetD892qy+hqNAQdZd/vvplw+ZIlb4tuH7sR1gia67WRsBRO5nrQA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-darwin-universal": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-universal/-/node-screenshots-darwin-universal-0.2.1.tgz", + "integrity": "sha512-cNqBasCyMU/P87Ej3hK/vedAk86DrVkpoxd2zz5qLA3h850Ew9qb/7g0MTYsRatbZFoLhw7MgFwAZkiNh4Mr9g==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-darwin-x64": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-darwin-x64/-/node-screenshots-darwin-x64-0.2.1.tgz", + "integrity": "sha512-8TOou5WwytgGV+IuV1vnnYaGzwfYgIw6XkOoZtt4qhSmTEW0K8EGa46Uq42/W5qQPxKt3GLqjlY3wL69eZWIyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-linux-x64-gnu": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-linux-x64-gnu/-/node-screenshots-linux-x64-gnu-0.2.1.tgz", + "integrity": "sha512-P2h511my2JytMSAW8+uvO+lGj1BwapERWbC6i56u5WbLIy/zgT1SQwNvcZNbPE8sxb1q8pqF3MerLcftnW0f+w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-linux-x64-musl": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-linux-x64-musl/-/node-screenshots-linux-x64-musl-0.2.1.tgz", + "integrity": "sha512-+B37/VYzH86ywWyF9XkYYicyf/BTan4TADsmxPlK+a/UHHZEnp1YjEM66sBIQTIVhVhv1DC6YUv9AppBpoV9AA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-arm64-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-arm64-msvc/-/node-screenshots-win32-arm64-msvc-0.2.1.tgz", + "integrity": "sha512-+sj1FAF4qufcWO1KdmCOhPRMELfWu7hRv2qvr8e3jLPLM0/XGU8UTWWej9qAhf3UY83LaAsvxMhzai9JLIis1w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-ia32-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-ia32-msvc/-/node-screenshots-win32-ia32-msvc-0.2.1.tgz", + "integrity": "sha512-8fzmFqbotHAzwIARG9fI8eD+Vw2g98Bl7aKlhu57nCjCXNrdl4Ck+b3uvMjdQD+v7rKC/sT+NZK973y4YgRoMA==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/node-screenshots-win32-x64-msvc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-screenshots-win32-x64-msvc/-/node-screenshots-win32-x64-msvc-0.2.1.tgz", + "integrity": "sha512-dfxAck3LR9eTYpc/hVtomQYxpP/80p4+vP9k1whDBCClTzRlHvgx1U9b6c5bYpe8JZFtCZsJL75S5p0kQJk7Dg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -17902,6 +18190,7 @@ "name": "@google/gemini-cli", "version": "0.19.0-nightly.20251120.8e531dc02", "dependencies": { + "@crosscopy/clipboard": "^0.2.8", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -17923,6 +18212,7 @@ "latest-version": "^9.0.0", "lowlight": "^3.3.0", "mnemonist": "^0.40.3", + "node-screenshots": "^0.2.1", "open": "^10.1.2", "prompts": "^2.4.2", "react": "^19.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f33667ec83..e20e0bf8aa3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-nightly.20251120.8e531dc02" }, "dependencies": { + "@crosscopy/clipboard": "^0.2.8", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -35,6 +36,7 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "node-screenshots": "^0.2.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889edb..64abeb3e4d8 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,73 +4,300 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock the @crosscopy/clipboard module +vi.mock('@crosscopy/clipboard', () => ({ + default: { + hasImage: vi.fn(), + getImageBase64: vi.fn(), + }, + })); + +// Import the mocked module +import Clipboard from '@crosscopy/clipboard'; + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('clipboardHasImage', () => { - it('should return false on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await clipboardHasImage(); - expect(result).toBe(false); - } else { - // Skip on macOS as it would require actual clipboard state - expect(true).toBe(true); - } + it('should return true when clipboard has an image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(Clipboard.hasImage).toHaveBeenCalledTimes(1); }); - it('should return boolean on macOS', async () => { - if (process.platform === 'darwin') { - const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on non-macOS - expect(true).toBe(true); - } + it('should return false when clipboard has no image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(false); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + expect(Clipboard.hasImage).toHaveBeenCalledTimes(1); + }); + + it('should return false on error', async () => { + vi.mocked(Clipboard.hasImage).mockRejectedValue( + new Error('Clipboard access error'), + ); + + const result = await clipboardHasImage(); + expect(result).toBe(false); }); }); describe('saveClipboardImage', () => { - it('should return null on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await saveClipboardImage(); - expect(result).toBe(null); - } else { - // Skip on macOS - expect(true).toBe(true); + const testTempDir = path.join(process.cwd(), '.gemini-clipboard-test'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors } }); + it('should return null when clipboard has no image', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(false); + + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + + it('should save PNG image from clipboard', async () => { + // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A + const pngData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const base64PNG = pngData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64PNG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toContain('.gemini-clipboard-test'); + expect(result).toMatch(/clipboard-\d+\.png$/); + + // Verify file exists and has correct content + if (result) { + const fileContent = await fs.readFile(result); + expect(fileContent).toEqual(pngData); + } + }); + + it('should save JPEG image from clipboard', async () => { + // JPEG magic bytes: FF D8 FF E0 + const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const base64JPEG = jpegData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64JPEG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.jpg$/); + }); + + it('should save GIF image from clipboard', async () => { + // GIF magic bytes: 47 49 46 38 39 61 + const gifData = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + const base64GIF = gifData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64GIF); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.gif$/); + }); + + it('should save WebP image from clipboard', async () => { + // WebP magic bytes: RIFF....WEBP + const webpData = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ]); + const base64WebP = webpData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64WebP); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.webp$/); + }); + + it('should default to PNG for unknown format', async () => { + // Random bytes that don't match any known format + const unknownData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + const base64Unknown = unknownData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64Unknown); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/clipboard-\d+\.png$/); + }); + + it('should return null when getImageBase64 returns empty string', async () => { + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(''); + + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', + vi.mocked(Clipboard.hasImage).mockRejectedValue( + new Error('Clipboard error'), ); - if (process.platform === 'darwin') { - // On macOS, might return null due to various errors - expect(result === null || typeof result === 'string').toBe(true); - } else { - // On other platforms, should always return null - expect(result).toBe(null); - } + const result = await saveClipboardImage(testTempDir); + expect(result).toBe(null); + }); + + it('should create .gemini-clipboard directory if it does not exist', async () => { + const pngData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const base64PNG = pngData.toString('base64'); + + vi.mocked(Clipboard.hasImage).mockResolvedValue(true); + vi.mocked(Clipboard.getImageBase64).mockResolvedValue(base64PNG); + + const result = await saveClipboardImage(testTempDir); + + expect(result).not.toBe(null); + + // Verify directory was created + const dirExists = await fs + .access(path.join(testTempDir, '.gemini-clipboard')) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + const testTempDir = path.join(process.cwd(), '.gemini-clipboard-cleanup'); + const clipboardDir = path.join(testTempDir, '.gemini-clipboard'); + + beforeEach(async () => { + // Create test directory structure + await fs.mkdir(clipboardDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should not throw errors when directory does not exist', async () => { await expect( cleanupOldClipboardImages('/path/that/does/not/exist'), ).resolves.not.toThrow(); }); it('should complete without errors on valid directory', async () => { - await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); + await expect( + cleanupOldClipboardImages(testTempDir), + ).resolves.not.toThrow(); + }); + + it('should remove old clipboard images', async () => { + // Create an old file (2 hours ago) + const oldFile = path.join(clipboardDir, 'clipboard-1000000000.png'); + await fs.writeFile(oldFile, 'old image data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldClipboardImages(testTempDir); + + // File should be deleted + const fileExists = await fs + .access(oldFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(false); + }); + + it('should keep recent clipboard images', async () => { + // Create a recent file + const recentFile = path.join(clipboardDir, 'clipboard-2000000000.png'); + await fs.writeFile(recentFile, 'recent image data'); + + await cleanupOldClipboardImages(testTempDir); + + // File should still exist + const fileExists = await fs + .access(recentFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should only clean up clipboard image files', async () => { + // Create a non-clipboard file + const otherFile = path.join(clipboardDir, 'other-file.txt'); + await fs.writeFile(otherFile, 'other file data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(otherFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldClipboardImages(testTempDir); + + // File should still exist (not a clipboard file) + const fileExists = await fs + .access(otherFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should handle multiple file formats', async () => { + const formats = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff']; + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + + // Create old files with different formats + for (const format of formats) { + const oldFile = path.join( + clipboardDir, + `clipboard-${Date.now()}.${format}`, + ); + await fs.writeFile(oldFile, `old ${format} data`); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + } + + await cleanupOldClipboardImages(testTempDir); + + // All files should be deleted + const files = await fs.readdir(clipboardDir); + const clipboardFiles = files.filter((f) => f.startsWith('clipboard-')); + expect(clipboardFiles.length).toBe(0); }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 86da766efaa..2ebd706b73d 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,41 +6,103 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { debugLogger, spawnAsync } from '@google/gemini-cli-core'; +import { debugLogger } from '@google/gemini-cli-core'; +import Clipboard from '@crosscopy/clipboard'; /** - * Checks if the system clipboard contains an image (macOS only for now) + * Checks if the system clipboard contains an image (cross-platform) * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { - if (process.platform !== 'darwin') { + try { + return await Clipboard.hasImage(); + } catch (error) { + debugLogger.warn('Error checking clipboard for image:', error); return false; } +} +/** + * Detects image format from base64 data by checking magic bytes + * @param base64Data The base64 encoded image data + * @returns The detected file extension (png, jpg, gif, bmp, webp) or 'png' as default + */ +function detectImageFormat(base64Data: string): string { try { - // Use osascript to check clipboard type - const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']); - const imageRegex = - /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; - return imageRegex.test(stdout); + // Decode first few bytes to check magic numbers + const buffer = Buffer.from(base64Data.slice(0, 16), 'base64'); + + // PNG: 89 50 4E 47 + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e) { + return 'png'; + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'jpg'; + } + + // GIF: 47 49 46 + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return 'gif'; + } + + // BMP: 42 4D + if (buffer[0] === 0x42 && buffer[1] === 0x4d) { + return 'bmp'; + } + + // WebP: 52 49 46 46 ... 57 45 42 50 + if ( + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) { + return 'webp'; + } + + // TIFF (little-endian): 49 49 2A 00 + if (buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a) { + return 'tiff'; + } + + // TIFF (big-endian): 4D 4D 00 2A + if (buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00) { + return 'tiff'; + } + + // Default to PNG if format is unknown + return 'png'; } catch { - return false; + return 'png'; } } /** - * Saves the image from clipboard to a temporary file (macOS only for now) + * Saves the image from clipboard to a temporary file (cross-platform) * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir?: string, ): Promise { - if (process.platform !== 'darwin') { - return null; - } - try { + // Check if clipboard has an image + if (!(await clipboardHasImage())) { + return null; + } + + // Get the image data as base64 + const base64Data = await Clipboard.getImageBase64(); + if (!base64Data) { + return null; + } + // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory const baseDir = targetDir || process.cwd(); @@ -50,59 +112,28 @@ export async function saveClipboardImage( // Generate a unique filename with timestamp const timestamp = new Date().getTime(); - // Try different image formats in order of preference - const formats = [ - { class: 'PNGf', extension: 'png' }, - { class: 'JPEG', extension: 'jpg' }, - { class: 'TIFF', extension: 'tiff' }, - { class: 'GIFf', extension: 'gif' }, - ]; - - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); + // Detect image format from magic bytes + const extension = detectImageFormat(base64Data); + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${extension}`, + ); - // Try to save clipboard as this format - const script = ` - try - set imageData to the clipboard as «class ${format.class}» - set fileRef to open for access POSIX file "${tempFilePath}" with write permission - write imageData to fileRef - close access fileRef - return "success" - on error errMsg - try - close access POSIX file "${tempFilePath}" - end try - return "error" - end try - `; - - const { stdout } = await spawnAsync('osascript', ['-e', script]); - - if (stdout.trim() === 'success') { - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist, continue to next format - } - } + // Convert base64 to buffer and save to file + const imageBuffer = Buffer.from(base64Data, 'base64'); + await fs.writeFile(tempFilePath, imageBuffer); - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } + // Verify the file was created and has content + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + debugLogger.debug( + `Saved clipboard image to ${tempFilePath} (${stats.size} bytes, format: ${extension})`, + ); + return tempFilePath; } - // No format worked + // File is empty, clean up and return null + await fs.unlink(tempFilePath); return null; } catch (error) { debugLogger.warn('Error saving clipboard image:', error); @@ -129,17 +160,22 @@ export async function cleanupOldClipboardImages( file.startsWith('clipboard-') && (file.endsWith('.png') || file.endsWith('.jpg') || + file.endsWith('.jpeg') || file.endsWith('.tiff') || - file.endsWith('.gif')) + file.endsWith('.gif') || + file.endsWith('.bmp') || + file.endsWith('.webp')) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < oneHourAgo) { await fs.unlink(filePath); + debugLogger.debug(`Cleaned up old clipboard image: ${filePath}`); } } } - } catch { - // Ignore errors in cleanup + } catch (error) { + // Ignore errors in cleanup - directory might not exist yet + debugLogger.debug('Clipboard cleanup skipped:', error); } } diff --git a/packages/cli/src/ui/utils/screenshotUtils.test.ts b/packages/cli/src/ui/utils/screenshotUtils.test.ts new file mode 100644 index 00000000000..bd9940f85db --- /dev/null +++ b/packages/cli/src/ui/utils/screenshotUtils.test.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + isScreenshotAvailable, + listDisplays, + captureScreenshot, + captureScreenshotFromDisplay, + cleanupOldScreenshots, +} from './screenshotUtils.js'; + +// Mock node-screenshots module +vi.mock('node-screenshots', () => { + const mockImage = { + width: 1920, + height: 1080, + toPngSync: vi.fn(), + }; + + const mockMonitor = { + id: 1, + name: 'Mock Display', + width: 1920, + height: 1080, + x: 0, + y: 0, + rotation: 0, + scaleFactor: 1, + frequency: 60, + isPrimary: true, + captureImageSync: vi.fn(() => mockImage), + }; + + return { + Monitor: { + all: vi.fn(() => [mockMonitor]), + }, + Image: vi.fn(), + Window: vi.fn(), + }; +}); + +describe('screenshotUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isScreenshotAvailable', () => { + it('should return true when node-screenshots is available', async () => { + const result = await isScreenshotAvailable(); + expect(result).toBe(true); + }); + }); + + describe('listDisplays', () => { + it('should return list of available displays', async () => { + const displays = await listDisplays(); + expect(displays).toHaveLength(1); + expect(displays[0]).toEqual({ + id: 1, + name: 'Mock Display', + width: 1920, + height: 1080, + }); + }); + + it('should handle errors gracefully', async () => { + // Mock Monitor.all to throw error + const { Monitor } = await import('node-screenshots'); + vi.mocked(Monitor.all).mockImplementationOnce(() => { + throw new Error('Display error'); + }); + + const displays = await listDisplays(); + expect(displays).toEqual([]); + }); + }); + + describe('captureScreenshot', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-test'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should capture screenshot from primary display', async () => { + // Mock screenshot data + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshot(testTempDir); + + expect(result).not.toBe(null); + expect(result).toContain('.gemini-screenshots-test'); + expect(result).toMatch(/screenshot-\d+\.png$/); + + // Verify file exists and has correct content + if (result) { + const fileContent = await fs.readFile(result); + expect(fileContent).toEqual(mockImageData); + } + }); + + it('should return null when capture fails', async () => { + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(mockMonitor.captureImageSync).mockReturnValue(null as any); + + const result = await captureScreenshot(testTempDir); + expect(result).toBe(null); + }); + + it('should create screenshot directory if it does not exist', async () => { + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshot(testTempDir); + expect(result).not.toBe(null); + + // Verify directory was created + const dirExists = await fs + .access(path.join(testTempDir, '.gemini-screenshots')) + .then(() => true) + .catch(() => false); + expect(dirExists).toBe(true); + }); + }); + + describe('captureScreenshotFromDisplay', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-test2'); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should capture screenshot from specified display', async () => { + const mockImageData = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + const { Monitor } = await import('node-screenshots'); + const mockMonitor = Monitor.all()[0]; + const mockImage = mockMonitor.captureImageSync(); + if (mockImage) { + vi.mocked(mockImage.toPngSync).mockReturnValue(mockImageData); + } + + const result = await captureScreenshotFromDisplay(1, testTempDir); + + expect(result).not.toBe(null); + expect(result).toMatch(/screenshot-\d+\.png$/); + }); + + it('should return null for invalid display ID', async () => { + const result = await captureScreenshotFromDisplay(999, testTempDir); + expect(result).toBe(null); + }); + + it('should return null for negative display ID', async () => { + const result = await captureScreenshotFromDisplay(-1, testTempDir); + expect(result).toBe(null); + }); + }); + + describe('cleanupOldScreenshots', () => { + const testTempDir = path.join(process.cwd(), '.gemini-screenshots-cleanup'); + const screenshotDir = path.join(testTempDir, '.gemini-screenshots'); + + beforeEach(async () => { + // Create test directory structure + await fs.mkdir(screenshotDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testTempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should not throw errors when directory does not exist', async () => { + await expect( + cleanupOldScreenshots('/path/that/does/not/exist'), + ).resolves.not.toThrow(); + }); + + it('should complete without errors on valid directory', async () => { + await expect(cleanupOldScreenshots(testTempDir)).resolves.not.toThrow(); + }); + + it('should remove old screenshot files', async () => { + // Create an old file (2 hours ago) + const oldFile = path.join(screenshotDir, 'screenshot-1000000000.png'); + await fs.writeFile(oldFile, 'old screenshot data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(oldFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldScreenshots(testTempDir); + + // File should be deleted + const fileExists = await fs + .access(oldFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(false); + }); + + it('should keep recent screenshot files', async () => { + // Create a recent file + const recentFile = path.join(screenshotDir, 'screenshot-2000000000.png'); + await fs.writeFile(recentFile, 'recent screenshot data'); + + await cleanupOldScreenshots(testTempDir); + + // File should still exist + const fileExists = await fs + .access(recentFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + + it('should only clean up screenshot files', async () => { + // Create a non-screenshot file + const otherFile = path.join(screenshotDir, 'other-file.txt'); + await fs.writeFile(otherFile, 'other file data'); + + // Set modification time to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + await fs.utimes(otherFile, twoHoursAgo, twoHoursAgo); + + await cleanupOldScreenshots(testTempDir); + + // File should still exist (not a screenshot file) + const fileExists = await fs + .access(otherFile) + .then(() => true) + .catch(() => false); + expect(fileExists).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/screenshotUtils.ts b/packages/cli/src/ui/utils/screenshotUtils.ts new file mode 100644 index 00000000000..370394b398e --- /dev/null +++ b/packages/cli/src/ui/utils/screenshotUtils.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Dynamically import node-screenshots to handle optional dependency +let screenshots: typeof import('node-screenshots') | null = null; + +/** + * Lazily loads the node-screenshots module + * @returns The screenshots module or null if not available + */ +async function getScreenshotsModule() { + if (screenshots) { + return screenshots; + } + + try { + screenshots = await import('node-screenshots'); + return screenshots; + } catch (error) { + debugLogger.warn('node-screenshots not available:', error); + return null; + } +} + +/** + * Checks if screenshot capture is available on this platform + * @returns true if screenshot capture is supported + */ +export async function isScreenshotAvailable(): Promise { + const module = await getScreenshotsModule(); + return module !== null; +} + +/** + * Lists all available displays/monitors + * @returns Array of display information or empty array if not available + */ +export async function listDisplays(): Promise< + Array<{ id: number; name: string; width: number; height: number }> +> { + try { + const module = await getScreenshotsModule(); + if (!module) { + return []; + } + + const monitors = module.Monitor.all(); + return monitors.map((monitor) => ({ + id: monitor.id, + name: monitor.name || `Display ${monitor.id}`, + width: monitor.width, + height: monitor.height, + })); + } catch (error) { + debugLogger.warn('Error listing displays:', error); + return []; + } +} + +/** + * Captures a screenshot from the primary display + * @param targetDir The target directory to save the screenshot + * @returns The path to the saved screenshot file, or null on error + */ +export async function captureScreenshot( + targetDir?: string, +): Promise { + try { + const module = await getScreenshotsModule(); + if (!module) { + debugLogger.warn('Screenshot capture not available'); + return null; + } + + // Get all monitors + const monitors = module.Monitor.all(); + if (monitors.length === 0) { + debugLogger.warn('No monitors available for capture'); + return null; + } + + // Find the primary monitor or use the first one + const primaryMonitor = monitors.find((m) => m.isPrimary) || monitors[0]; + + // Capture the screenshot + const image = primaryMonitor.captureImageSync(); + if (!image) { + debugLogger.warn('Failed to capture screenshot'); + return null; + } + + // Convert to PNG + const pngBuffer = image.toPngSync(); + + // Create screenshot directory + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + await fs.mkdir(screenshotDir, { recursive: true }); + + // Generate unique filename with timestamp + const timestamp = new Date().getTime(); + const screenshotPath = path.join( + screenshotDir, + `screenshot-${timestamp}.png`, + ); + + // Save the screenshot + await fs.writeFile(screenshotPath, pngBuffer); + + // Verify the file was created + const stats = await fs.stat(screenshotPath); + if (stats.size > 0) { + debugLogger.debug( + `Captured screenshot to ${screenshotPath} (${stats.size} bytes)`, + ); + return screenshotPath; + } + + // File is empty, clean up + await fs.unlink(screenshotPath); + return null; + } catch (error) { + debugLogger.warn('Error capturing screenshot:', error); + return null; + } +} + +/** + * Captures a screenshot from a specific display + * @param displayId The ID of the display to capture from + * @param targetDir The target directory to save the screenshot + * @returns The path to the saved screenshot file, or null on error + */ +export async function captureScreenshotFromDisplay( + displayId: number, + targetDir?: string, +): Promise { + try { + const module = await getScreenshotsModule(); + if (!module) { + debugLogger.warn('Screenshot capture not available'); + return null; + } + + const monitors = module.Monitor.all(); + const monitor = monitors.find((m) => m.id === displayId); + + if (!monitor) { + debugLogger.warn(`Monitor with ID ${displayId} not found`); + return null; + } + + const image = monitor.captureImageSync(); + if (!image) { + debugLogger.warn('Failed to capture screenshot'); + return null; + } + + // Convert to PNG + const pngBuffer = image.toPngSync(); + + // Create screenshot directory + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + await fs.mkdir(screenshotDir, { recursive: true }); + + // Generate unique filename with timestamp + const timestamp = new Date().getTime(); + const screenshotPath = path.join( + screenshotDir, + `screenshot-${timestamp}.png`, + ); + + // Save the screenshot + await fs.writeFile(screenshotPath, pngBuffer); + + // Verify the file was created + const stats = await fs.stat(screenshotPath); + if (stats.size > 0) { + debugLogger.debug( + `Captured screenshot to ${screenshotPath} (${stats.size} bytes)`, + ); + return screenshotPath; + } + + // File is empty, clean up + await fs.unlink(screenshotPath); + return null; + } catch (error) { + debugLogger.warn('Error capturing screenshot:', error); + return null; + } +} + +/** + * Cleans up old screenshot files + * Removes files older than 1 hour + * @param targetDir The target directory where screenshots are stored + */ +export async function cleanupOldScreenshots(targetDir?: string): Promise { + try { + const baseDir = targetDir || process.cwd(); + const screenshotDir = path.join(baseDir, '.gemini-screenshots'); + const files = await fs.readdir(screenshotDir); + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + for (const file of files) { + if (file.startsWith('screenshot-') && file.endsWith('.png')) { + const filePath = path.join(screenshotDir, file); + const stats = await fs.stat(filePath); + if (stats.mtimeMs < oneHourAgo) { + await fs.unlink(filePath); + debugLogger.debug(`Cleaned up old screenshot: ${filePath}`); + } + } + } + } catch (error) { + // Ignore errors in cleanup - directory might not exist yet + debugLogger.debug('Screenshot cleanup skipped:', error); + } +} From d41bf2cd39c07bf19f039bb50ab03a7f0b8697aa Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 09:52:38 +0000 Subject: [PATCH 02/32] Add WSL support for clipboard image pasting via PowerShell --- packages/cli/src/ui/utils/clipboardUtils.ts | 166 +++++++++++++++++++- 1 file changed, 163 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 2ebd706b73d..c82acd187a5 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -5,15 +5,169 @@ */ import * as fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import * as path from 'node:path'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, spawnAsync } from '@google/gemini-cli-core'; import Clipboard from '@crosscopy/clipboard'; /** - * Checks if the system clipboard contains an image (cross-platform) + * Detects if the system is running in WSL (Windows Subsystem for Linux) + * @returns true if running in WSL + */ +function isWSL(): boolean { + try { + if (process.platform !== 'linux') { + return false; + } + // Check for WSL-specific indicators + const releaseInfo = readFileSync('/proc/version', 'utf8'); + return releaseInfo.toLowerCase().includes('microsoft'); + } catch { + return false; + } +} + +/** + * Checks if PowerShell is available (for WSL) + * @returns path to powershell.exe or null if not available + */ +async function getPowerShellPath(): Promise { + try { + // Try pwsh.exe first (PowerShell 7+) + const { stdout: pwshPath } = await spawnAsync('which', ['pwsh.exe']); + if (pwshPath.trim()) { + return 'pwsh.exe'; + } + } catch { + // pwsh.exe not found + } + + try { + // Try powershell.exe (Windows PowerShell) + const { stdout: psPath } = await spawnAsync('which', ['powershell.exe']); + if (psPath.trim()) { + return 'powershell.exe'; + } + } catch { + // powershell.exe not found + } + + return null; +} + +/** + * Checks if clipboard has image using PowerShell (WSL) + * @returns true if clipboard contains an image + */ +async function wslClipboardHasImage(): Promise { + try { + const powershell = await getPowerShellPath(); + if (!powershell) { + debugLogger.warn('PowerShell not found in WSL'); + return false; + } + + const script = ` + Add-Type -AssemblyName System.Windows.Forms; + [System.Windows.Forms.Clipboard]::ContainsImage() + `; + + const { stdout } = await spawnAsync(powershell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + return stdout.trim().toLowerCase() === 'true'; + } catch (error) { + debugLogger.warn('Error checking WSL clipboard for image:', error); + return false; + } +} + +/** + * Saves clipboard image using PowerShell (WSL) + * @param targetDir The target directory to save the image + * @returns The path to the saved image file, or null on error + */ +async function wslSaveClipboardImage( + targetDir?: string, +): Promise { + try { + const powershell = await getPowerShellPath(); + if (!powershell) { + debugLogger.warn('PowerShell not found in WSL'); + return null; + } + + // Create temp directory in WSL + const baseDir = targetDir || process.cwd(); + const tempDir = path.join(baseDir, '.gemini-clipboard'); + await fs.mkdir(tempDir, { recursive: true }); + + // Generate unique filename + const timestamp = new Date().getTime(); + const wslPath = path.join(tempDir, `clipboard-${timestamp}.png`); + + // Convert WSL path to Windows path for PowerShell + const { stdout: winPath } = await spawnAsync('wslpath', ['-w', wslPath]); + const windowsPath = winPath.trim(); + + // PowerShell script to save clipboard image + const script = ` + Add-Type -AssemblyName System.Windows.Forms; + Add-Type -AssemblyName System.Drawing; + + $image = [System.Windows.Forms.Clipboard]::GetImage(); + if ($image -ne $null) { + $image.Save('${windowsPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png); + Write-Output 'success'; + } else { + Write-Output 'no-image'; + } + `; + + const { stdout } = await spawnAsync(powershell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + if (stdout.trim() !== 'success') { + return null; + } + + // Verify file exists and has content + const stats = await fs.stat(wslPath); + if (stats.size > 0) { + debugLogger.debug( + `Saved WSL clipboard image to ${wslPath} (${stats.size} bytes)`, + ); + return wslPath; + } + + // File is empty, clean up + await fs.unlink(wslPath); + return null; + } catch (error) { + debugLogger.warn('Error saving WSL clipboard image:', error); + return null; + } +} + +/** + * Checks if the system clipboard contains an image (cross-platform + WSL) * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { + // Check if running in WSL + if (isWSL()) { + return await wslClipboardHasImage(); + } + + // Use cross-platform clipboard library try { return await Clipboard.hasImage(); } catch (error) { @@ -84,13 +238,19 @@ function detectImageFormat(base64Data: string): string { } /** - * Saves the image from clipboard to a temporary file (cross-platform) + * Saves the image from clipboard to a temporary file (cross-platform + WSL) * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir?: string, ): Promise { + // Check if running in WSL + if (isWSL()) { + return await wslSaveClipboardImage(targetDir); + } + + // Use cross-platform clipboard library try { // Check if clipboard has an image if (!(await clipboardHasImage())) { From 251fceddd2ff7e81e7e361279dba244887fb9093 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 09:57:46 +0000 Subject: [PATCH 03/32] Add testing documentation and scripts for WSL clipboard functionality --- TEST-CLIPBOARD-PASTE.md | 138 ++++++++++++++++++++++++++++++++++++++++ test-clipboard-wsl.sh | 44 +++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 TEST-CLIPBOARD-PASTE.md create mode 100755 test-clipboard-wsl.sh diff --git a/TEST-CLIPBOARD-PASTE.md b/TEST-CLIPBOARD-PASTE.md new file mode 100644 index 00000000000..9a90d53a318 --- /dev/null +++ b/TEST-CLIPBOARD-PASTE.md @@ -0,0 +1,138 @@ +# Testing Clipboard Image Pasting in WSL + +## ✅ Status: WORKING! + +The clipboard image pasting functionality is working correctly in WSL. Here's +how to test it: + +## How to Test + +### Step 1: Take a Screenshot on Windows + +1. Press `Win + Shift + S` to open Snipping Tool +2. Select an area and take a screenshot +3. The screenshot is automatically copied to your Windows clipboard + +### Step 2: Verify Image is in Clipboard + +Run this command in WSL: + +```bash +./test-clipboard-wsl.sh +``` + +Expected output: + +``` +✅ Clipboard contains an image! +``` + +### Step 3: Run Gemini CLI + +```bash +npm start +``` + +### Step 4: Paste the Image + +In the Gemini CLI prompt, press **Ctrl+V** + +You should see the image path inserted automatically: + +``` +@.gemini-clipboard/clipboard-.png +``` + +### Step 5: Send Your Message + +Type your question and press Enter. The image will be sent to Gemini! + +## What Happens Behind the Scenes + +1. **Ctrl+V** triggers `handleClipboardPaste()` +2. Checks if clipboard has an image via PowerShell +3. If yes: Saves image to `.gemini-clipboard/` directory +4. Inserts `@.gemini-clipboard/clipboard-.png` into the input +5. You can then send it with your message + +## Troubleshooting + +### "No image detected in clipboard" + +- Make sure you copied an **image**, not just text +- Try taking a fresh screenshot with `Win + Shift + S` +- Run `./test-clipboard-wsl.sh` to verify + +### "File path must be within workspace directories" + +- This is normal! The pasted image is automatically saved to + `.gemini-clipboard/` +- You should see `@.gemini-clipboard/clipboard-XXXXX.png` appear in your input +- If you're manually trying to reference a file outside the workspace, you need + to copy it into your workspace first + +### Nothing happens when pressing Ctrl+V + +1. Make sure gemini-cli has focus +2. Check if an image is actually in the clipboard: + + ```bash + pwsh.exe -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()" + ``` + + Should output: `True` + +3. If still not working, check for errors in the terminal + +## Example Workflow + +```bash +# 1. Copy an image (Win+Shift+S on Windows) + +# 2. Start gemini-cli +npm start + +# 3. In gemini-cli, press Ctrl+V +# You'll see: @.gemini-clipboard/clipboard-1234567890.png + +# 4. Type your question +What's in this image? + +# 5. Press Enter - Gemini will analyze the image! +``` + +## Technical Details + +**Platform Detection:** + +- Automatically detects WSL environment +- Uses PowerShell to access Windows clipboard +- Converts paths between WSL and Windows using `wslpath` + +**Supported Image Formats:** + +- PNG (most common from screenshots) +- JPEG +- BMP +- GIF +- WebP +- TIFF + +**Cleanup:** + +- Old clipboard images (>1 hour) are automatically cleaned up +- Saves space and keeps your workspace tidy + +## Verified Working ✅ + +Test result from `/home/user/documents/code/gemini-cli`: + +``` +Testing clipboardHasImage... +Has image: true + +Testing saveClipboardImage... +Saved WSL clipboard image to .gemini-clipboard/clipboard-1763719008753.png (133 bytes) + +✅ SUCCESS! Clipboard image pasting works in WSL! +``` diff --git a/test-clipboard-wsl.sh b/test-clipboard-wsl.sh new file mode 100755 index 00000000000..a96141e4dc7 --- /dev/null +++ b/test-clipboard-wsl.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +echo "======================================" +echo "WSL Clipboard Image Test" +echo "======================================" +echo "" + +# Check if we're in WSL +if grep -qi microsoft /proc/version; then + echo "✅ Running in WSL" +else + echo "❌ Not running in WSL" + exit 1 +fi + +# Check if PowerShell is available +if command -v powershell.exe &> /dev/null; then + echo "✅ PowerShell found: $(which powershell.exe)" +else + echo "❌ PowerShell not found" + exit 1 +fi + +# Check if clipboard has an image +echo "" +echo "Checking Windows clipboard..." +HAS_IMAGE=$(powershell.exe -NoProfile -NonInteractive -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()" | tr -d '\r') + +if [ "$HAS_IMAGE" = "True" ]; then + echo "✅ Clipboard contains an image!" + echo "" + echo "Test successful! You can now use Ctrl+V in gemini-cli to paste images." +else + echo "⚠️ No image in clipboard" + echo "" + echo "To test:" + echo "1. On Windows, press Win+Shift+S" + echo "2. Take a screenshot" + echo "3. Run this script again" + echo "4. Then try Ctrl+V in gemini-cli" +fi + +echo "" +echo "======================================" From 94b7a1b9133d22b4dbf46baa416f088661dfe439 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 10:05:35 +0000 Subject: [PATCH 04/32] Add debug logging to clipboard paste handler for troubleshooting --- .gemini-clipboard/clipboard-1763719008753.png | Bin 0 -> 133 bytes .../cli/src/ui/components/InputPrompt.tsx | 23 ++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .gemini-clipboard/clipboard-1763719008753.png diff --git a/.gemini-clipboard/clipboard-1763719008753.png b/.gemini-clipboard/clipboard-1763719008753.png new file mode 100644 index 0000000000000000000000000000000000000000..964f067e4e678bb96278f7a36de734dc87d04ee7 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4F%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW? = ({ // Handle clipboard image pasting with Ctrl+V const handleClipboardPaste = useCallback(async () => { try { - if (await clipboardHasImage()) { + console.log('[DEBUG] Ctrl+V pressed - checking for clipboard image...'); + const hasImage = await clipboardHasImage(); + console.log('[DEBUG] Clipboard has image:', hasImage); + + if (hasImage) { + console.log('[DEBUG] Attempting to save clipboard image...'); const imagePath = await saveClipboardImage(config.getTargetDir()); + console.log('[DEBUG] Image saved to:', imagePath); + if (imagePath) { // Clean up old images cleanupOldClipboardImages(config.getTargetDir()).catch(() => { @@ -333,6 +340,7 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); + console.log('[DEBUG] Relative path:', relativePath); // Insert @path reference at cursor position const insertText = `@${relativePath}`; @@ -352,17 +360,28 @@ export const InputPrompt: React.FC = ({ textToInsert = textToInsert + ' '; } + console.log('[DEBUG] Inserting text:', textToInsert); // Insert at cursor position buffer.replaceRangeByOffset(offset, offset, textToInsert); + console.log('[DEBUG] Image path inserted successfully!'); return; + } else { + console.log('[DEBUG] Failed to save clipboard image'); } + } else { + console.log('[DEBUG] No image in clipboard, pasting text instead...'); } const textToInsert = await clipboardy.read(); + console.log('[DEBUG] Pasting text from clipboard'); const offset = buffer.getOffset(); buffer.replaceRangeByOffset(offset, offset, textToInsert); } catch (error) { - console.error('Error handling clipboard image:', error); + console.error('[ERROR] Error handling clipboard paste:', error); + console.error( + '[ERROR] Stack trace:', + error instanceof Error ? error.stack : 'No stack trace', + ); } }, [buffer, config]); From 939da3fc991829b31444cc005e684f2cf7b12103 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 10:14:31 +0000 Subject: [PATCH 05/32] Change debug logging to write to .gemini-debug.log file instead of console --- copy-test-image.ps1 | 10 ++++++ .../cli/src/ui/components/InputPrompt.tsx | 34 +++++++++++-------- 2 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 copy-test-image.ps1 diff --git a/copy-test-image.ps1 b/copy-test-image.ps1 new file mode 100644 index 00000000000..ebdf6c2d8a7 --- /dev/null +++ b/copy-test-image.ps1 @@ -0,0 +1,10 @@ +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +$bitmap = New-Object System.Drawing.Bitmap(100, 100) +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +$graphics.Clear([System.Drawing.Color]::Blue) +$graphics.Dispose() + +[System.Windows.Forms.Clipboard]::SetImage($bitmap) +Write-Output 'Blue test image copied to clipboard' diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 121200c7982..b5e9f1a39dc 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -36,6 +36,7 @@ import { cleanupOldClipboardImages, } from '../utils/clipboardUtils.js'; import * as path from 'node:path'; +import * as fs from 'node:fs'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -322,15 +323,21 @@ export const InputPrompt: React.FC = ({ // Handle clipboard image pasting with Ctrl+V const handleClipboardPaste = useCallback(async () => { + const debugLog = (msg: string) => { + const logPath = path.join(config.getTargetDir(), '.gemini-debug.log'); + const timestamp = new Date().toISOString(); + fs.appendFileSync(logPath, `${timestamp} ${msg}\n`); + }; + try { - console.log('[DEBUG] Ctrl+V pressed - checking for clipboard image...'); + debugLog('[DEBUG] Ctrl+V pressed - checking for clipboard image...'); const hasImage = await clipboardHasImage(); - console.log('[DEBUG] Clipboard has image:', hasImage); + debugLog(`[DEBUG] Clipboard has image: ${hasImage}`); if (hasImage) { - console.log('[DEBUG] Attempting to save clipboard image...'); + debugLog('[DEBUG] Attempting to save clipboard image...'); const imagePath = await saveClipboardImage(config.getTargetDir()); - console.log('[DEBUG] Image saved to:', imagePath); + debugLog(`[DEBUG] Image saved to: ${imagePath}`); if (imagePath) { // Clean up old images @@ -340,7 +347,7 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); - console.log('[DEBUG] Relative path:', relativePath); + debugLog(`[DEBUG] Relative path: ${relativePath}`); // Insert @path reference at cursor position const insertText = `@${relativePath}`; @@ -360,27 +367,26 @@ export const InputPrompt: React.FC = ({ textToInsert = textToInsert + ' '; } - console.log('[DEBUG] Inserting text:', textToInsert); + debugLog(`[DEBUG] Inserting text: ${textToInsert}`); // Insert at cursor position buffer.replaceRangeByOffset(offset, offset, textToInsert); - console.log('[DEBUG] Image path inserted successfully!'); + debugLog('[DEBUG] Image path inserted successfully!'); return; } else { - console.log('[DEBUG] Failed to save clipboard image'); + debugLog('[DEBUG] Failed to save clipboard image'); } } else { - console.log('[DEBUG] No image in clipboard, pasting text instead...'); + debugLog('[DEBUG] No image in clipboard, pasting text instead...'); } const textToInsert = await clipboardy.read(); - console.log('[DEBUG] Pasting text from clipboard'); + debugLog('[DEBUG] Pasting text from clipboard'); const offset = buffer.getOffset(); buffer.replaceRangeByOffset(offset, offset, textToInsert); } catch (error) { - console.error('[ERROR] Error handling clipboard paste:', error); - console.error( - '[ERROR] Stack trace:', - error instanceof Error ? error.stack : 'No stack trace', + debugLog(`[ERROR] Error handling clipboard paste: ${error}`); + debugLog( + `[ERROR] Stack trace: ${error instanceof Error ? error.stack : 'No stack trace'}`, ); } }, [buffer, config]); From 0667ec849186c0e0f96a374e3852747e5300c6a0 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 10:22:42 +0000 Subject: [PATCH 06/32] Clean up debug files and logging for PR - Remove debug logging from InputPrompt.tsx - Remove test scripts (test-clipboard-wsl.sh, copy-test-image.ps1) - Remove TEST-CLIPBOARD-PASTE.md - Add .gemini-debug.log to .gitignore - Remove test clipboard image artifacts --- .gemini-clipboard/clipboard-1763719008753.png | Bin 133 -> 0 bytes .gitignore | 1 + TEST-CLIPBOARD-PASTE.md | 138 ------------------ copy-test-image.ps1 | 10 -- .../cli/src/ui/components/InputPrompt.tsx | 29 +--- test-clipboard-wsl.sh | 44 ------ 6 files changed, 3 insertions(+), 219 deletions(-) delete mode 100644 .gemini-clipboard/clipboard-1763719008753.png delete mode 100644 TEST-CLIPBOARD-PASTE.md delete mode 100644 copy-test-image.ps1 delete mode 100755 test-clipboard-wsl.sh diff --git a/.gemini-clipboard/clipboard-1763719008753.png b/.gemini-clipboard/clipboard-1763719008753.png deleted file mode 100644 index 964f067e4e678bb96278f7a36de734dc87d04ee7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4F%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?.png -``` - -### Step 5: Send Your Message - -Type your question and press Enter. The image will be sent to Gemini! - -## What Happens Behind the Scenes - -1. **Ctrl+V** triggers `handleClipboardPaste()` -2. Checks if clipboard has an image via PowerShell -3. If yes: Saves image to `.gemini-clipboard/` directory -4. Inserts `@.gemini-clipboard/clipboard-.png` into the input -5. You can then send it with your message - -## Troubleshooting - -### "No image detected in clipboard" - -- Make sure you copied an **image**, not just text -- Try taking a fresh screenshot with `Win + Shift + S` -- Run `./test-clipboard-wsl.sh` to verify - -### "File path must be within workspace directories" - -- This is normal! The pasted image is automatically saved to - `.gemini-clipboard/` -- You should see `@.gemini-clipboard/clipboard-XXXXX.png` appear in your input -- If you're manually trying to reference a file outside the workspace, you need - to copy it into your workspace first - -### Nothing happens when pressing Ctrl+V - -1. Make sure gemini-cli has focus -2. Check if an image is actually in the clipboard: - - ```bash - pwsh.exe -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()" - ``` - - Should output: `True` - -3. If still not working, check for errors in the terminal - -## Example Workflow - -```bash -# 1. Copy an image (Win+Shift+S on Windows) - -# 2. Start gemini-cli -npm start - -# 3. In gemini-cli, press Ctrl+V -# You'll see: @.gemini-clipboard/clipboard-1234567890.png - -# 4. Type your question -What's in this image? - -# 5. Press Enter - Gemini will analyze the image! -``` - -## Technical Details - -**Platform Detection:** - -- Automatically detects WSL environment -- Uses PowerShell to access Windows clipboard -- Converts paths between WSL and Windows using `wslpath` - -**Supported Image Formats:** - -- PNG (most common from screenshots) -- JPEG -- BMP -- GIF -- WebP -- TIFF - -**Cleanup:** - -- Old clipboard images (>1 hour) are automatically cleaned up -- Saves space and keeps your workspace tidy - -## Verified Working ✅ - -Test result from `/home/user/documents/code/gemini-cli`: - -``` -Testing clipboardHasImage... -Has image: true - -Testing saveClipboardImage... -Saved WSL clipboard image to .gemini-clipboard/clipboard-1763719008753.png (133 bytes) - -✅ SUCCESS! Clipboard image pasting works in WSL! -``` diff --git a/copy-test-image.ps1 b/copy-test-image.ps1 deleted file mode 100644 index ebdf6c2d8a7..00000000000 --- a/copy-test-image.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms - -$bitmap = New-Object System.Drawing.Bitmap(100, 100) -$graphics = [System.Drawing.Graphics]::FromImage($bitmap) -$graphics.Clear([System.Drawing.Color]::Blue) -$graphics.Dispose() - -[System.Windows.Forms.Clipboard]::SetImage($bitmap) -Write-Output 'Blue test image copied to clipboard' diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b5e9f1a39dc..a6e8f8df8ba 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -36,7 +36,6 @@ import { cleanupOldClipboardImages, } from '../utils/clipboardUtils.js'; import * as path from 'node:path'; -import * as fs from 'node:fs'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -323,22 +322,9 @@ export const InputPrompt: React.FC = ({ // Handle clipboard image pasting with Ctrl+V const handleClipboardPaste = useCallback(async () => { - const debugLog = (msg: string) => { - const logPath = path.join(config.getTargetDir(), '.gemini-debug.log'); - const timestamp = new Date().toISOString(); - fs.appendFileSync(logPath, `${timestamp} ${msg}\n`); - }; - try { - debugLog('[DEBUG] Ctrl+V pressed - checking for clipboard image...'); - const hasImage = await clipboardHasImage(); - debugLog(`[DEBUG] Clipboard has image: ${hasImage}`); - - if (hasImage) { - debugLog('[DEBUG] Attempting to save clipboard image...'); + if (await clipboardHasImage()) { const imagePath = await saveClipboardImage(config.getTargetDir()); - debugLog(`[DEBUG] Image saved to: ${imagePath}`); - if (imagePath) { // Clean up old images cleanupOldClipboardImages(config.getTargetDir()).catch(() => { @@ -347,7 +333,6 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); - debugLog(`[DEBUG] Relative path: ${relativePath}`); // Insert @path reference at cursor position const insertText = `@${relativePath}`; @@ -367,27 +352,17 @@ export const InputPrompt: React.FC = ({ textToInsert = textToInsert + ' '; } - debugLog(`[DEBUG] Inserting text: ${textToInsert}`); // Insert at cursor position buffer.replaceRangeByOffset(offset, offset, textToInsert); - debugLog('[DEBUG] Image path inserted successfully!'); return; - } else { - debugLog('[DEBUG] Failed to save clipboard image'); } - } else { - debugLog('[DEBUG] No image in clipboard, pasting text instead...'); } const textToInsert = await clipboardy.read(); - debugLog('[DEBUG] Pasting text from clipboard'); const offset = buffer.getOffset(); buffer.replaceRangeByOffset(offset, offset, textToInsert); } catch (error) { - debugLog(`[ERROR] Error handling clipboard paste: ${error}`); - debugLog( - `[ERROR] Stack trace: ${error instanceof Error ? error.stack : 'No stack trace'}`, - ); + console.error('Error handling clipboard paste:', error); } }, [buffer, config]); diff --git a/test-clipboard-wsl.sh b/test-clipboard-wsl.sh deleted file mode 100755 index a96141e4dc7..00000000000 --- a/test-clipboard-wsl.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -echo "======================================" -echo "WSL Clipboard Image Test" -echo "======================================" -echo "" - -# Check if we're in WSL -if grep -qi microsoft /proc/version; then - echo "✅ Running in WSL" -else - echo "❌ Not running in WSL" - exit 1 -fi - -# Check if PowerShell is available -if command -v powershell.exe &> /dev/null; then - echo "✅ PowerShell found: $(which powershell.exe)" -else - echo "❌ PowerShell not found" - exit 1 -fi - -# Check if clipboard has an image -echo "" -echo "Checking Windows clipboard..." -HAS_IMAGE=$(powershell.exe -NoProfile -NonInteractive -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()" | tr -d '\r') - -if [ "$HAS_IMAGE" = "True" ]; then - echo "✅ Clipboard contains an image!" - echo "" - echo "Test successful! You can now use Ctrl+V in gemini-cli to paste images." -else - echo "⚠️ No image in clipboard" - echo "" - echo "To test:" - echo "1. On Windows, press Win+Shift+S" - echo "2. Take a screenshot" - echo "3. Run this script again" - echo "4. Then try Ctrl+V in gemini-cli" -fi - -echo "" -echo "======================================" From a2390eebe8872706cefa6460b88b2c888f485c92 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 11:22:22 +0000 Subject: [PATCH 07/32] Remove .gemini-debug.log from gitignore (no longer used) --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index a3212001f89..4852eb2ea8c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,5 @@ gha-creds-*.json # Log files patch_output.log -.gemini-debug.log .genkit From e543accde8e2ccef49243c52cb9e0a8630d971cb Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 12:39:26 +0000 Subject: [PATCH 08/32] Improve clipboard image pasting: sequential numbering and PowerShell support - Changed image filenames from timestamp-based (clipboard-1234567890.png) to sequential numbering (image-1.png, image-2.png) - Updated display format to show friendly labels: [image #1] @path instead of just @path - Added Shift+Insert as alternative paste key binding for PowerShell compatibility (PowerShell intercepts Ctrl+V) - Updated cleanup function to handle both old and new filename formats This addresses the PowerShell Ctrl+V limitation and improves UX with clearer image references. --- packages/cli/src/config/keyBindings.ts | 5 +- .../cli/src/ui/components/InputPrompt.tsx | 11 ++-- packages/cli/src/ui/utils/clipboardUtils.ts | 52 ++++++++++++++----- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 497b359f2ef..8535593a316 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -192,7 +192,10 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - [Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }], + [Command.PASTE_CLIPBOARD]: [ + { key: 'v', ctrl: true }, + { key: 'insert', shift: true }, + ], // App level bindings [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index a6e8f8df8ba..b3f4a991915 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -334,12 +334,17 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; + // Extract image number from filename (e.g., "image-1.png" -> 1) + const filename = path.basename(imagePath); + const imageNumberMatch = filename.match(/image-(\d+)\./); + const imageNumber = imageNumberMatch ? imageNumberMatch[1] : '?'; + + // Insert with friendly label: [image #1] @path + const insertText = `[image #${imageNumber}] @${relativePath}`; const currentText = buffer.text; const offset = buffer.getOffset(); - // Add spaces around the path if needed + // Add spaces around the text if needed let textToInsert = insertText; const charBefore = offset > 0 ? currentText[offset - 1] : ''; const charAfter = diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index c82acd187a5..11dd7e6b751 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -86,6 +86,35 @@ async function wslClipboardHasImage(): Promise { } } +/** + * Gets the next available image number by checking existing files + * @param targetDir The target directory + * @returns The next image number + */ +async function getNextImageNumber(targetDir: string): Promise { + try { + const tempDir = path.join(targetDir, '.gemini-clipboard'); + await fs.mkdir(tempDir, { recursive: true }); + + const files = await fs.readdir(tempDir); + const imageFiles = files.filter((f) => f.match(/^image-(\d+)\.png$/)); + + if (imageFiles.length === 0) { + return 1; + } + + // Extract numbers and find the max + const numbers = imageFiles.map((f) => { + const match = f.match(/^image-(\d+)\.png$/); + return match ? parseInt(match[1], 10) : 0; + }); + + return Math.max(...numbers) + 1; + } catch { + return 1; + } +} + /** * Saves clipboard image using PowerShell (WSL) * @param targetDir The target directory to save the image @@ -106,9 +135,9 @@ async function wslSaveClipboardImage( const tempDir = path.join(baseDir, '.gemini-clipboard'); await fs.mkdir(tempDir, { recursive: true }); - // Generate unique filename - const timestamp = new Date().getTime(); - const wslPath = path.join(tempDir, `clipboard-${timestamp}.png`); + // Get next image number + const imageNumber = await getNextImageNumber(baseDir); + const wslPath = path.join(tempDir, `image-${imageNumber}.png`); // Convert WSL path to Windows path for PowerShell const { stdout: winPath } = await spawnAsync('wslpath', ['-w', wslPath]); @@ -269,14 +298,14 @@ export async function saveClipboardImage( const tempDir = path.join(baseDir, '.gemini-clipboard'); await fs.mkdir(tempDir, { recursive: true }); - // Generate a unique filename with timestamp - const timestamp = new Date().getTime(); + // Get next image number + const imageNumber = await getNextImageNumber(baseDir); // Detect image format from magic bytes const extension = detectImageFormat(base64Data); const tempFilePath = path.join( tempDir, - `clipboard-${timestamp}.${extension}`, + `image-${imageNumber}.${extension}`, ); // Convert base64 to buffer and save to file @@ -317,14 +346,9 @@ export async function cleanupOldClipboardImages( for (const file of files) { if ( - file.startsWith('clipboard-') && - (file.endsWith('.png') || - file.endsWith('.jpg') || - file.endsWith('.jpeg') || - file.endsWith('.tiff') || - file.endsWith('.gif') || - file.endsWith('.bmp') || - file.endsWith('.webp')) + file.match( + /^(clipboard-\d+|image-\d+)\.(png|jpg|jpeg|tiff|gif|bmp|webp)$/, + ) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); From c55413fe4966c19d69dd44f1c665bfbb689e6116 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 12:46:01 +0000 Subject: [PATCH 09/32] Simplify clipboard image paste format to just @path Changed from "[image #N] @path" to just "@path" for cleaner display. Users will see "@.gemini-clipboard/image-1.png" instead of the verbose format. --- packages/cli/src/ui/components/InputPrompt.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b3f4a991915..572cc253a4a 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -334,13 +334,8 @@ export const InputPrompt: React.FC = ({ // Get relative path from current directory const relativePath = path.relative(config.getTargetDir(), imagePath); - // Extract image number from filename (e.g., "image-1.png" -> 1) - const filename = path.basename(imagePath); - const imageNumberMatch = filename.match(/image-(\d+)\./); - const imageNumber = imageNumberMatch ? imageNumberMatch[1] : '?'; - - // Insert with friendly label: [image #1] @path - const insertText = `[image #${imageNumber}] @${relativePath}`; + // Insert @path reference + const insertText = `@${relativePath}`; const currentText = buffer.text; const offset = buffer.getOffset(); From 5a355fea8700ec8ccbf5542553456a0a2cf2e8b5 Mon Sep 17 00:00:00 2001 From: fancivez <384514351@qq.com> Date: Fri, 21 Nov 2025 22:59:42 +0800 Subject: [PATCH 10/32] docs: fix typos in source code and documentation (#13577) --- docs/get-started/authentication.md | 2 +- packages/a2a-server/development-extension-rfc.md | 4 ++-- packages/a2a-server/src/agent/task.ts | 2 +- packages/cli/src/test-utils/mockCommandContext.ts | 2 +- packages/cli/src/ui/components/DebugProfiler.tsx | 2 +- packages/cli/src/ui/components/InputPrompt.tsx | 2 +- packages/cli/src/ui/hooks/useSlashCompletion.test.ts | 2 +- packages/cli/src/ui/types.ts | 2 +- packages/core/src/agents/codebase-investigator.ts | 2 +- packages/core/src/telemetry/semantic.ts | 2 +- packages/core/src/test-utils/mock-tool.ts | 2 +- packages/core/src/utils/schemaValidator.ts | 2 +- packages/vscode-ide-companion/src/ide-server.ts | 2 +- 13 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 97d4d9fa505..7e1b2fdfcaf 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -256,7 +256,7 @@ To avoid setting environment variables in every terminal session, you can: ## Non-interactive mode / headless environments -Non-interative mode / headless environments will use your existing +Non-interactive mode / headless environments will use your existing authentication method, if an existing authentication credential is cached. If you have not already logged in with an authentication credential (such as a diff --git a/packages/a2a-server/development-extension-rfc.md b/packages/a2a-server/development-extension-rfc.md index 6307e2d8e72..c004919a9d2 100644 --- a/packages/a2a-server/development-extension-rfc.md +++ b/packages/a2a-server/development-extension-rfc.md @@ -153,7 +153,7 @@ syntax = "proto3"; import "google/protobuf/struct.proto"; -// ToolCall is the central message represeting a tool's execution lifecycle. +// ToolCall is the central message representing a tool's execution lifecycle. // The entire object is sent from the agent to client on every update. message ToolCall { // A unique identifier, assigned by the agent @@ -197,7 +197,7 @@ enum ToolCallStatus { CANCELLED = 5; } -// ToolOuput represents the final, successful, output of a tool +// ToolOutput represents the final, successful, output of a tool message ToolOutput { oneof result { string text = 1; diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 21fe313a5e4..12f58be8b9a 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -460,7 +460,7 @@ export class Task { ): Message { const messageParts: Part[] = []; - // Create a serializable version of the ToolCall (pick necesssary + // Create a serializable version of the ToolCall (pick necessary // properties/avoid methods causing circular reference errors) const serializableToolCall: Partial = this._pickFields( tc, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index b07bccaacfd..37a0edcb194 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -88,7 +88,7 @@ export const createMockCommandContext = ( const targetValue = output[key]; if ( - // We only want to recursivlty merge plain objects + // We only want to recursively merge plain objects Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx index ba380a2aeed..16eb8d69f9a 100644 --- a/packages/cli/src/ui/components/DebugProfiler.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.tsx @@ -185,7 +185,7 @@ export const DebugProfiler = () => { return; } // Only update the UX infrequently as updating the UX itself will cause - // frames to run so can disturb what we are measuing. + // frames to run so can disturb what we are measuring. const forceRefreshInterval = setInterval(() => { setForceRefresh((f) => f + 1); profiler.reportAction(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 640ee5b2ca7..20f454059bb 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -708,7 +708,7 @@ export const InputPrompt: React.FC = ({ // newline that was part of the paste. // This has the added benefit that in the worst case at least users // get some feedback that their keypress was handled rather than - // wondering why it was completey ignored. + // wondering why it was completely ignored. buffer.newline(); return; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 290c752651f..ffa957b41a9 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -293,7 +293,7 @@ describe('useSlashCompletion', () => { const hook = renderHook(() => useTestHarnessForSlashCompletion( true, - '/usag', + '/usage', slashCommands, mockCommandContext, ), diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ff8f550f18a..491a1eede1f 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -19,7 +19,7 @@ import { type ReactNode } from 'react'; export type { ThoughtSummary }; export enum AuthState { - // Attemtping to authenticate or re-authenticate + // Attempting to authenticate or re-authenticate Unauthenticated = 'unauthenticated', // Auth dialog is open for user to select auth method Updating = 'updating', diff --git a/packages/core/src/agents/codebase-investigator.ts b/packages/core/src/agents/codebase-investigator.ts index 94181205db8..adda3e96a5d 100644 --- a/packages/core/src/agents/codebase-investigator.ts +++ b/packages/core/src/agents/codebase-investigator.ts @@ -96,7 +96,7 @@ You are a sub-agent in a larger system. Your only responsibility is to provide d - **DO:** Find the key modules, classes, and functions that are part of the problem and its solution. - **DO:** Understand *why* the code is written the way it is. Question everything. - **DO:** Foresee the ripple effects of a change. If \`function A\` is modified, you must check its callers. If a data structure is altered, you must identify where its type definitions need to be updated. -- **DO:** provide a conclusion and insights to the main agent that invoked you. If the agent is trying to solve a bug, you should provide the root cause of the bug, its impacts, how to fix it etc. If it's a new feature, you should provide insights on where to implement it, what chagnes are necessary etc. +- **DO:** provide a conclusion and insights to the main agent that invoked you. If the agent is trying to solve a bug, you should provide the root cause of the bug, its impacts, how to fix it etc. If it's a new feature, you should provide insights on where to implement it, what changes are necessary etc. - **DO NOT:** Write the final implementation code yourself. - **DO NOT:** Stop at the first relevant file. Your goal is a comprehensive understanding of the entire relevant subsystem. You operate in a non-interactive loop and must reason based on the information provided and the output of your tools. diff --git a/packages/core/src/telemetry/semantic.ts b/packages/core/src/telemetry/semantic.ts index b2ee296793a..6192a6e94d6 100644 --- a/packages/core/src/telemetry/semantic.ts +++ b/packages/core/src/telemetry/semantic.ts @@ -109,7 +109,7 @@ export function toFinishReasons(candidates?: Candidate[]): OTelFinishReason[] { export function toOutputType(requested_mime?: string): string | undefined { switch (requested_mime) { - // explictly support the known good values of responseMimeType + // explicitly support the known good values of responseMimeType case 'text/plain': return OTelOutputType.TEXT; case 'application/json': diff --git a/packages/core/src/test-utils/mock-tool.ts b/packages/core/src/test-utils/mock-tool.ts index 75bdf26c5e5..cdfc649d469 100644 --- a/packages/core/src/test-utils/mock-tool.ts +++ b/packages/core/src/test-utils/mock-tool.ts @@ -182,7 +182,7 @@ export class MockModifiableTool extends BaseDeclarativeTool, ToolResult> implements ModifiableDeclarativeTool> { - // Should be overrided in test file. Functionality will be updated in follow + // Should be overridden in test file. Functionality will be updated in follow // up PR which has MockModifiableTool expect MockTool executeFn: (params: Record) => ToolResult | undefined = () => undefined; diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 9c7376e4d9d..a97b67b20b8 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -30,7 +30,7 @@ addFormatsFunc(ajValidator); */ export class SchemaValidator { /** - * Returns null if the data confroms to the schema described by schema (or if schema + * Returns null if the data conforms to the schema described by schema (or if schema * is null). Otherwise, returns a string describing the error. */ static validate(schema: unknown | undefined, data: unknown): string | null { diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index b23f488020b..05d10d35ed3 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -453,7 +453,7 @@ const createMcpServer = ( 'openDiff', { description: - '(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejcted.', + '(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejected.', inputSchema: OpenDiffRequestSchema.shape, }, async ({ filePath, newContent }: z.infer) => { From 2ec02d976ead79b8dacffd23639cc1d69d082946 Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 20:47:54 +0530 Subject: [PATCH 11/32] Handle clipboard image tokens --- .../src/ui/components/InputPrompt.test.tsx | 51 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 25 +++++++-- packages/cli/src/ui/utils/highlight.ts | 2 +- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 33e76b0f69d..e33afc340f1 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -555,6 +555,57 @@ describe('InputPrompt', () => { unmount(); }); + it('should render clipboard images with a friendly label', async () => { + const clipboardToken = '@.gemini-clipboard/image-5.png'; + props.buffer.text = clipboardToken; + props.buffer.lines = [clipboardToken]; + props.buffer.viewportVisualLines = [clipboardToken]; + props.buffer.allVisualLines = [clipboardToken]; + props.buffer.visualToLogicalMap = [[0, 0]]; + props.buffer.visualCursor = [0, clipboardToken.length]; + + const { stdout, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await waitFor(() => { + const frame = clean(stdout.lastFrame()); + expect(frame).toContain('[image #5]'); + expect(frame).not.toContain('.gemini-clipboard'); + }); + + unmount(); + }); + + it('should render clipboard images with backslash paths', async () => { + const clipboardToken = '@.gemini-clipboard\\image-7.png'; + props.buffer.text = clipboardToken; + props.buffer.lines = [clipboardToken]; + props.buffer.viewportVisualLines = [clipboardToken]; + props.buffer.allVisualLines = [clipboardToken]; + props.buffer.visualToLogicalMap = [[0, 0]]; + props.buffer.visualCursor = [0, clipboardToken.length]; + + const { stdout, unmount } = renderWithProviders( + , + { + uiActions, + }, + ); + + await waitFor(() => { + const frame = clean(stdout.lastFrame()); + expect(frame).toContain('[image #7]'); + expect(frame).not.toContain('.gemini-clipboard'); + expect(frame).not.toContain('\\image-7.png'); + }); + + unmount(); + }); + it('should handle errors during clipboard operations', async () => { const consoleErrorSpy = vi .spyOn(console, 'error') diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 572cc253a4a..9059aff7c25 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -101,6 +101,15 @@ export const calculatePromptWidths = (mainContentWidth: number) => { } as const; }; +const CLIPBOARD_IMAGE_TOKEN_REGEX = + /^@\.gemini-clipboard[\\/](?:clipboard|image)-(\d+)\.(?:png|jpe?g|gif|bmp|webp|tiff)$/i; + +const getClipboardImageLabel = (tokenText: string): string | null => { + const match = CLIPBOARD_IMAGE_TOKEN_REGEX.exec(tokenText); + if (!match) return null; + return `[image #${match[1]}]`; +}; + export const InputPrompt: React.FC = ({ buffer, onSubmit, @@ -1074,17 +1083,25 @@ export const InputPrompt: React.FC = ({ let charCount = 0; segments.forEach((seg, segIdx) => { const segLen = cpLen(seg.text); - let display = seg.text; + const clipboardLabel = getClipboardImageLabel(seg.text); + let display = clipboardLabel ?? seg.text; if (isOnCursorLine) { const relativeVisualColForHighlight = cursorVisualColAbsolute; const segStart = charCount; const segEnd = segStart + segLen; - if ( + + const cursorInSegment = relativeVisualColForHighlight >= segStart && - relativeVisualColForHighlight < segEnd - ) { + relativeVisualColForHighlight < segEnd; + + if (clipboardLabel) { + display = + cursorInSegment && showCursor + ? chalk.inverse(clipboardLabel) + : clipboardLabel; + } else if (cursorInSegment) { const charToHighlight = cpSlice( seg.text, relativeVisualColForHighlight - segStart, diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index 1e48b36f13d..4126469b8de 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -11,7 +11,7 @@ export type HighlightToken = { type: 'default' | 'command' | 'file'; }; -const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g; +const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_.\\\\/:-])+)/g; export function parseInputForHighlighting( text: string, From 0876bbdec52bd1f36cf394ffb40e3d11c6102544 Mon Sep 17 00:00:00 2001 From: Megha Bansal Date: Fri, 21 Nov 2025 21:08:33 +0530 Subject: [PATCH 12/32] Improved code coverage for cli/src/zed-integration (#13570) --- .../src/zed-integration/connection.test.ts | 216 +++++ .../zed-integration/fileSystemService.test.ts | 131 +++ .../zed-integration/zedIntegration.test.ts | 768 ++++++++++++++++++ .../cli/src/zed-integration/zedIntegration.ts | 8 +- 4 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/zed-integration/connection.test.ts create mode 100644 packages/cli/src/zed-integration/fileSystemService.test.ts create mode 100644 packages/cli/src/zed-integration/zedIntegration.test.ts diff --git a/packages/cli/src/zed-integration/connection.test.ts b/packages/cli/src/zed-integration/connection.test.ts new file mode 100644 index 00000000000..20bd709fcae --- /dev/null +++ b/packages/cli/src/zed-integration/connection.test.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Connection, RequestError } from './connection.js'; +import { ReadableStream, WritableStream } from 'node:stream/web'; + +describe('Connection', () => { + let toPeer: WritableStream; + let fromPeer: ReadableStream; + let peerController: ReadableStreamDefaultController; + let receivedChunks: string[] = []; + let connection: Connection; + let handler: ReturnType; + + beforeEach(() => { + receivedChunks = []; + toPeer = new WritableStream({ + write(chunk) { + const str = new TextDecoder().decode(chunk); + receivedChunks.push(str); + }, + }); + + fromPeer = new ReadableStream({ + start(controller) { + peerController = controller; + }, + }); + + handler = vi.fn(); + connection = new Connection(handler, toPeer, fromPeer); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should send a request and receive a response', async () => { + const responsePromise = connection.sendRequest('testMethod', { + key: 'value', + }); + + // Verify request was sent + await vi.waitFor(() => { + expect(receivedChunks.length).toBeGreaterThan(0); + }); + const request = JSON.parse(receivedChunks[0]); + expect(request).toMatchObject({ + jsonrpc: '2.0', + method: 'testMethod', + params: { key: 'value' }, + }); + expect(request.id).toBeDefined(); + + // Simulate response + const response = { + jsonrpc: '2.0', + id: request.id, + result: { success: true }, + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(response) + '\n'), + ); + + const result = await responsePromise; + expect(result).toEqual({ success: true }); + }); + + it('should send a notification', async () => { + await connection.sendNotification('notifyMethod', { key: 'value' }); + + await vi.waitFor(() => { + expect(receivedChunks.length).toBeGreaterThan(0); + }); + const notification = JSON.parse(receivedChunks[0]); + expect(notification).toMatchObject({ + jsonrpc: '2.0', + method: 'notifyMethod', + params: { key: 'value' }, + }); + expect(notification.id).toBeUndefined(); + }); + + it('should handle incoming requests', async () => { + handler.mockResolvedValue({ result: 'ok' }); + + const request = { + jsonrpc: '2.0', + id: 1, + method: 'incomingMethod', + params: { foo: 'bar' }, + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(request) + '\n'), + ); + + // Wait for handler to be called and response to be written + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith('incomingMethod', { foo: 'bar' }); + expect(receivedChunks.length).toBeGreaterThan(0); + }); + + const response = JSON.parse(receivedChunks[receivedChunks.length - 1]); + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { result: 'ok' }, + }); + }); + + it('should handle incoming notifications', async () => { + const notification = { + jsonrpc: '2.0', + method: 'incomingNotify', + params: { foo: 'bar' }, + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(notification) + '\n'), + ); + + // Wait for handler to be called + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith('incomingNotify', { foo: 'bar' }); + }); + // Notifications don't send responses + expect(receivedChunks.length).toBe(0); + }); + + it('should handle request errors from handler', async () => { + handler.mockRejectedValue(new Error('Handler failed')); + + const request = { + jsonrpc: '2.0', + id: 2, + method: 'failMethod', + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(request) + '\n'), + ); + + await vi.waitFor(() => { + expect(receivedChunks.length).toBeGreaterThan(0); + }); + + const response = JSON.parse(receivedChunks[receivedChunks.length - 1]); + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 2, + error: { + code: -32603, + message: 'Internal error', + data: { details: 'Handler failed' }, + }, + }); + }); + + it('should handle RequestError from handler', async () => { + handler.mockRejectedValue(RequestError.methodNotFound('Unknown method')); + + const request = { + jsonrpc: '2.0', + id: 3, + method: 'unknown', + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(request) + '\n'), + ); + + await vi.waitFor(() => { + expect(receivedChunks.length).toBeGreaterThan(0); + }); + + const response = JSON.parse(receivedChunks[receivedChunks.length - 1]); + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 3, + error: { + code: -32601, + message: 'Method not found', + data: { details: 'Unknown method' }, + }, + }); + }); + + it('should handle response errors', async () => { + const responsePromise = connection.sendRequest('testMethod'); + + // Verify request was sent + await vi.waitFor(() => { + expect(receivedChunks.length).toBeGreaterThan(0); + }); + const request = JSON.parse(receivedChunks[0]); + + // Simulate error response + const response = { + jsonrpc: '2.0', + id: request.id, + error: { + code: -32000, + message: 'Custom error', + }, + }; + peerController.enqueue( + new TextEncoder().encode(JSON.stringify(response) + '\n'), + ); + + await expect(responsePromise).rejects.toMatchObject({ + code: -32000, + message: 'Custom error', + }); + }); +}); diff --git a/packages/cli/src/zed-integration/fileSystemService.test.ts b/packages/cli/src/zed-integration/fileSystemService.test.ts new file mode 100644 index 00000000000..e274df0618c --- /dev/null +++ b/packages/cli/src/zed-integration/fileSystemService.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { AcpFileSystemService } from './fileSystemService.js'; +import type { Client } from './acp.js'; +import type { FileSystemService } from '@google/gemini-cli-core'; + +describe('AcpFileSystemService', () => { + let mockClient: Mocked; + let mockFallback: Mocked; + let service: AcpFileSystemService; + + beforeEach(() => { + mockClient = { + requestPermission: vi.fn(), + sessionUpdate: vi.fn(), + writeTextFile: vi.fn(), + readTextFile: vi.fn(), + }; + mockFallback = { + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + findFiles: vi.fn(), + }; + }); + + describe('readTextFile', () => { + it.each([ + { + capability: true, + desc: 'client if capability exists', + setup: () => { + mockClient.readTextFile.mockResolvedValue({ content: 'content' }); + }, + verify: () => { + expect(mockClient.readTextFile).toHaveBeenCalledWith({ + path: '/path/to/file', + sessionId: 'session-1', + line: null, + limit: null, + }); + expect(mockFallback.readTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: false, + desc: 'fallback if capability missing', + setup: () => { + mockFallback.readTextFile.mockResolvedValue('content'); + }, + verify: () => { + expect(mockFallback.readTextFile).toHaveBeenCalledWith( + '/path/to/file', + ); + expect(mockClient.readTextFile).not.toHaveBeenCalled(); + }, + }, + ])('should use $desc', async ({ capability, setup, verify }) => { + service = new AcpFileSystemService( + mockClient, + 'session-1', + { readTextFile: capability, writeTextFile: true }, + mockFallback, + ); + setup(); + + const result = await service.readTextFile('/path/to/file'); + + expect(result).toBe('content'); + verify(); + }); + }); + + describe('writeTextFile', () => { + it.each([ + { + capability: true, + desc: 'client if capability exists', + verify: () => { + expect(mockClient.writeTextFile).toHaveBeenCalledWith({ + path: '/path/to/file', + content: 'content', + sessionId: 'session-1', + }); + expect(mockFallback.writeTextFile).not.toHaveBeenCalled(); + }, + }, + { + capability: false, + desc: 'fallback if capability missing', + verify: () => { + expect(mockFallback.writeTextFile).toHaveBeenCalledWith( + '/path/to/file', + 'content', + ); + expect(mockClient.writeTextFile).not.toHaveBeenCalled(); + }, + }, + ])('should use $desc', async ({ capability, verify }) => { + service = new AcpFileSystemService( + mockClient, + 'session-1', + { writeTextFile: capability, readTextFile: true }, + mockFallback, + ); + + await service.writeTextFile('/path/to/file', 'content'); + + verify(); + }); + }); + + it('should always use fallback for findFiles', () => { + service = new AcpFileSystemService( + mockClient, + 'session-1', + { readTextFile: true, writeTextFile: true }, + mockFallback, + ); + mockFallback.findFiles.mockReturnValue(['file1', 'file2']); + + const result = service.findFiles('pattern', ['/path']); + + expect(mockFallback.findFiles).toHaveBeenCalledWith('pattern', ['/path']); + expect(result).toEqual(['file1', 'file2']); + }); +}); diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts new file mode 100644 index 00000000000..48a741be208 --- /dev/null +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -0,0 +1,768 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, +} from 'vitest'; +import { GeminiAgent, Session } from './zedIntegration.js'; +import * as acp from './acp.js'; +import { + AuthType, + ToolConfirmationOutcome, + StreamEventType, + isWithinRoot, + ReadManyFilesTool, + type GeminiChat, + type Config, +} from '@google/gemini-cli-core'; +import { SettingScope, type LoadedSettings } from '../config/settings.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +vi.mock('../config/config.js', () => ({ + loadCliConfig: vi.fn(), +})); + +vi.mock('node:crypto', () => ({ + randomUUID: () => 'test-session-id', +})); + +vi.mock('node:fs/promises'); +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolve: vi.fn(), + }; +}); + +// Mock ReadManyFilesTool +vi.mock( + '@google/gemini-cli-core', + async ( + importOriginal: () => Promise, + ) => { + const actual = await importOriginal(); + return { + ...actual, + ReadManyFilesTool: vi.fn().mockImplementation(() => ({ + name: 'read_many_files', + kind: 'native', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockResolvedValue({ + llmContent: ['--- file.txt ---\n\nFile content\n\n'], + }), + }), + })), + logToolCall: vi.fn(), + isWithinRoot: vi.fn().mockReturnValue(true), + }; + }, +); + +// Helper to create mock streams +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function* createMockStream(items: any[]) { + for (const item of items) { + yield item; + } +} + +describe('GeminiAgent', () => { + let mockConfig: Mocked>>; + let mockSettings: Mocked; + let mockArgv: CliArgs; + let mockClient: Mocked; + let agent: GeminiAgent; + + beforeEach(() => { + mockConfig = { + refreshAuth: vi.fn(), + initialize: vi.fn(), + getFileSystemService: vi.fn(), + setFileSystemService: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue({ + startChat: vi.fn().mockResolvedValue({}), + }), + } as unknown as Mocked>>; + mockSettings = { + merged: { + security: { auth: { selectedType: 'login_with_google' } }, + mcpServers: {}, + }, + setValue: vi.fn(), + } as unknown as Mocked; + mockArgv = {} as unknown as CliArgs; + mockClient = { + sessionUpdate: vi.fn(), + } as unknown as Mocked; + + (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig); + + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockClient); + }); + + it('should initialize correctly', async () => { + const response = await agent.initialize({ + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + protocolVersion: 1, + }); + + expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION); + expect(response.authMethods).toHaveLength(3); + expect(response.agentCapabilities.loadSession).toBe(false); + }); + + it('should authenticate correctly', async () => { + await agent.authenticate({ + methodId: AuthType.LOGIN_WITH_GOOGLE, + authMethod: { + id: AuthType.LOGIN_WITH_GOOGLE, + name: 'Log in with Google', + description: null, + }, + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should create a new session', async () => { + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.sessionId).toBe('test-session-id'); + expect(loadCliConfig).toHaveBeenCalled(); + expect(mockConfig.initialize).toHaveBeenCalled(); + expect(mockConfig.getGeminiClient).toHaveBeenCalled(); + }); + + it('should create a new session with mcp servers', async () => { + const mcpServers = [ + { + name: 'test-server', + command: 'node', + args: ['server.js'], + env: [{ name: 'KEY', value: 'VALUE' }], + }, + ]; + + await agent.newSession({ + cwd: '/tmp', + mcpServers, + }); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: expect.objectContaining({ + 'test-server': expect.objectContaining({ + command: 'node', + args: ['server.js'], + env: { KEY: 'VALUE' }, + }), + }), + }), + 'test-session-id', + mockArgv, + '/tmp', + ); + }); + + it('should handle authentication failure gracefully', async () => { + mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed')); + const debugSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Should throw RequestError.authRequired() + await expect( + agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }), + ).rejects.toMatchObject({ + message: 'Authentication required', + }); + + debugSpy.mockRestore(); + }); + + it('should initialize file system service if client supports it', async () => { + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockClient); + await agent.initialize({ + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + protocolVersion: 1, + }); + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(mockConfig.setFileSystemService).toHaveBeenCalled(); + }); + + it('should cancel a session', async () => { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + // Mock the session's cancelPendingPrompt + const session = ( + agent as unknown as { sessions: Map } + ).sessions.get('test-session-id'); + if (!session) throw new Error('Session not found'); + session.cancelPendingPrompt = vi.fn(); + + await agent.cancel({ sessionId: 'test-session-id' }); + + expect(session.cancelPendingPrompt).toHaveBeenCalled(); + }); + + it('should throw error when cancelling non-existent session', async () => { + await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow( + 'Session not found', + ); + }); + + it('should delegate prompt to session', async () => { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const session = ( + agent as unknown as { sessions: Map } + ).sessions.get('test-session-id'); + if (!session) throw new Error('Session not found'); + session.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' }); + + const result = await agent.prompt({ + sessionId: 'test-session-id', + prompt: [], + }); + + expect(session.prompt).toHaveBeenCalled(); + expect(result).toEqual({ stopReason: 'end_turn' }); + }); +}); + +describe('Session', () => { + let mockChat: Mocked; + let mockConfig: Mocked; + let mockClient: Mocked; + let session: Session; + let mockToolRegistry: { getTool: Mock }; + let mockTool: { kind: string; build: Mock }; + + beforeEach(() => { + mockChat = { + sendMessageStream: vi.fn(), + addHistory: vi.fn(), + } as unknown as Mocked; + mockTool = { + kind: 'native', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }), + }; + mockToolRegistry = { + getTool: vi.fn().mockReturnValue(mockTool), + }; + mockConfig = { + isInFallbackMode: vi.fn().mockReturnValue(false), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getPreviewFeatures: vi.fn().mockReturnValue({}), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getFileService: vi.fn().mockReturnValue({ + shouldIgnoreFile: vi.fn().mockReturnValue(false), + }), + getFileFilteringOptions: vi.fn().mockReturnValue({}), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), + getDebugMode: vi.fn().mockReturnValue(false), + } as unknown as Mocked; + mockClient = { + sessionUpdate: vi.fn(), + requestPermission: vi.fn(), + sendNotification: vi.fn(), + } as unknown as Mocked; + + session = new Session('session-1', mockChat, mockConfig, mockClient); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should handle prompt with text response', async () => { + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalled(); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello' }, + }, + }); + expect(result).toEqual({ stopReason: 'end_turn' }); + }); + + it('should handle tool calls', async () => { + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: { foo: 'bar' } }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Result' }] } }], + }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool'); + expect(mockTool.build).toHaveBeenCalledWith({ foo: 'bar' }); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call', + status: 'in_progress', + }), + }), + ); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'completed', + }), + }), + ); + expect(result).toEqual({ stopReason: 'end_turn' }); + }); + + it('should handle tool call permission request', async () => { + const confirmationDetails = { + type: 'info', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockClient.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockClient.requestPermission).toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + }); + + it('should handle tool call cancellation by user', async () => { + const confirmationDetails = { + type: 'info', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockClient.requestPermission.mockResolvedValue({ + outcome: { outcome: 'cancelled' }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + // When cancelled, it sends an error response to the model + // We can verify that the second call to sendMessageStream contains the error + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + const secondCallArgs = mockChat.sendMessageStream.mock.calls[1]; + const parts = secondCallArgs[1]; // parts + expect(parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + functionResponse: expect.objectContaining({ + response: { + error: expect.stringContaining('canceled by the user'), + }, + }), + }), + ]), + ); + }); + + it('should handle @path resolution', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => false, + }); + (isWithinRoot as unknown as Mock).mockReturnValue(true); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'Read' }, + { + type: 'resource_link', + uri: 'file://file.txt', + mimeType: 'text/plain', + name: 'file.txt', + }, + ], + }); + + expect(path.resolve).toHaveBeenCalled(); + expect(fs.stat).toHaveBeenCalled(); + + // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) + // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining('Content from @file.txt'), + }), + ]), + expect.anything(), + expect.anything(), + ); + }); + + it('should handle cancellation during prompt', async () => { + let streamController: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + + let streamStarted: (value: unknown) => void; + const streamStartedPromise = new Promise((resolve) => { + streamStarted = resolve; + }); + + // Adapt web stream to async iterable + async function* asyncStream() { + process.stdout.write('TEST: asyncStream started\n'); + streamStarted(true); + const reader = stream.getReader(); + try { + while (true) { + process.stdout.write('TEST: waiting for read\n'); + const { done, value } = await reader.read(); + process.stdout.write(`TEST: read returned done=${done}\n`); + if (done) break; + yield value; + } + } finally { + process.stdout.write('TEST: releasing lock\n'); + reader.releaseLock(); + } + } + + mockChat.sendMessageStream.mockResolvedValue(asyncStream()); + + process.stdout.write('TEST: calling prompt\n'); + const promptPromise = session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + process.stdout.write('TEST: waiting for streamStarted\n'); + await streamStartedPromise; + process.stdout.write('TEST: streamStarted\n'); + await session.cancelPendingPrompt(); + process.stdout.write('TEST: cancelled\n'); + + // Close the stream to allow prompt loop to continue and check aborted signal + streamController!.close(); + process.stdout.write('TEST: stream closed\n'); + + const result = await promptPromise; + process.stdout.write(`TEST: result received ${JSON.stringify(result)}\n`); + expect(result).toEqual({ stopReason: 'cancelled' }); + }); + + it('should handle rate limit error', async () => { + const error = new Error('Rate limit'); + (error as unknown as { status: number }).status = 429; + mockChat.sendMessageStream.mockRejectedValue(error); + + await expect( + session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }), + ).rejects.toMatchObject({ + code: 429, + message: 'Rate limit exceeded. Try again later.', + }); + }); + + it('should handle tool execution error', async () => { + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockRejectedValue(new Error('Tool failed')), + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'failed', + content: expect.arrayContaining([ + expect.objectContaining({ + content: expect.objectContaining({ text: 'Tool failed' }), + }), + ]), + }), + }), + ); + }); + + it('should handle missing tool', async () => { + mockToolRegistry.getTool.mockReturnValue(undefined); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'unknown_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + // Should send error response to model + expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2); + const secondCallArgs = mockChat.sendMessageStream.mock.calls[1]; + const parts = secondCallArgs[1]; + expect(parts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + functionResponse: expect.objectContaining({ + response: { + error: expect.stringContaining('not found in registry'), + }, + }), + }), + ]), + ); + }); + + it('should ignore files based on configuration', async () => { + ( + mockConfig.getFileService().shouldIgnoreFile as unknown as Mock + ).mockReturnValue(true); + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { + type: 'resource_link', + uri: 'file://ignored.txt', + mimeType: 'text/plain', + name: 'ignored.txt', + }, + ], + }); + + // Should not read file + expect(mockToolRegistry.getTool).not.toHaveBeenCalledWith( + 'read_many_files', + ); + }); + + it('should handle directory resolution with glob', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/dir'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => true, + }); + (isWithinRoot as unknown as Mock).mockReturnValue(true); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { + type: 'resource_link', + uri: 'file://dir', + mimeType: 'text/plain', + name: 'dir', + }, + ], + }); + + // Should use glob + // ReadManyFilesTool is instantiated directly, so we check if the mock instance's build method was called + const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; + const mockInstance = + MockReadManyFilesTool.mock.results[ + MockReadManyFilesTool.mock.results.length - 1 + ].value; + expect(mockInstance.build).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 43f8bb8a73e..2f314c5349d 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -66,7 +66,7 @@ export async function runZedIntegration( ); } -class GeminiAgent { +export class GeminiAgent { private sessions: Map = new Map(); private clientCapabilities: acp.ClientCapabilities | undefined; @@ -209,7 +209,7 @@ class GeminiAgent { } } -class Session { +export class Session { private pendingPrompt: AbortController | null = null; constructor( @@ -296,6 +296,10 @@ class Session { functionCalls.push(...resp.value.functionCalls); } } + + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; + } } catch (error) { if (getErrorStatus(error) === 429) { throw new acp.RequestError( From 215bd2ac832a8b6ab30c2772ab815437fea53e05 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:16:56 -0700 Subject: [PATCH 13/32] feat(ui): build interactive session browser component (#13351) --- .../src/ui/components/SessionBrowser.test.tsx | 371 +++++++ .../cli/src/ui/components/SessionBrowser.tsx | 933 ++++++++++++++++++ .../src/ui/hooks/useSessionBrowser.test.ts | 761 ++++---------- .../cli/src/ui/hooks/useSessionBrowser.ts | 101 +- packages/cli/src/utils/sessionCleanup.test.ts | 38 + packages/cli/src/utils/sessionUtils.test.ts | 69 +- packages/cli/src/utils/sessionUtils.ts | 166 +++- packages/cli/src/utils/sessions.test.ts | 42 + packages/cli/src/utils/sessions.ts | 6 +- 9 files changed, 1895 insertions(+), 592 deletions(-) create mode 100644 packages/cli/src/ui/components/SessionBrowser.test.tsx create mode 100644 packages/cli/src/ui/components/SessionBrowser.tsx diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx new file mode 100644 index 00000000000..e063af40aaf --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import type { Config } from '@google/gemini-cli-core'; +import { SessionBrowser } from './SessionBrowser.js'; +import type { SessionBrowserProps } from './SessionBrowser.js'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; + +// Collect key handlers registered via useKeypress so tests can +// simulate input without going through the full stdin pipeline. +const keypressHandlers: Array<(key: unknown) => void> = []; + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, rows: 24 }), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + // The real hook subscribes to the KeypressContext. Here we just + // capture the handler so tests can call it directly. + useKeypress: ( + handler: (key: unknown) => void, + options: { isActive: boolean }, + ) => { + if (options?.isActive) { + keypressHandlers.push(handler); + } + }, +})); + +// Mock the component itself to bypass async loading +vi.mock('./SessionBrowser.js', async (importOriginal) => { + const original = await importOriginal(); + const React = await import('react'); + + const TestSessionBrowser = ( + props: SessionBrowserProps & { + testSessions?: SessionInfo[]; + testError?: string | null; + }, + ) => { + const state = original.useSessionBrowserState( + props.testSessions || [], + false, // Not loading + props.testError || null, + ); + const moveSelection = original.useMoveSelection(state); + const cycleSortOrder = original.useCycleSortOrder(state); + original.useSessionBrowserInput( + state, + moveSelection, + cycleSortOrder, + props.onResumeSession, + props.onDeleteSession, + props.onExit, + ); + + return React.createElement(original.SessionBrowserView, { state }); + }; + + return { + ...original, + SessionBrowser: TestSessionBrowser, + }; +}); + +// Cast SessionBrowser to a type that includes the test-only props so TypeScript doesn't complain +const TestSessionBrowser = SessionBrowser as unknown as React.FC< + SessionBrowserProps & { + testSessions?: SessionInfo[]; + testError?: string | null; + } +>; + +const createMockConfig = (overrides: Partial = {}): Config => + ({ + storage: { + getProjectTempDir: () => '/tmp/test', + }, + getSessionId: () => 'default-session-id', + ...overrides, + }) as Config; + +const triggerKey = ( + partialKey: Partial<{ + name: string; + ctrl: boolean; + meta: boolean; + shift: boolean; + paste: boolean; + insertable: boolean; + sequence: string; + }>, +) => { + const handler = keypressHandlers[keypressHandlers.length - 1]; + if (!handler) { + throw new Error('No keypress handler registered'); + } + + const key = { + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + insertable: false, + sequence: '', + ...partialKey, + }; + + act(() => { + handler(key); + }); +}; + +const createSession = (overrides: Partial): SessionInfo => ({ + id: 'session-id', + file: 'session-id', + fileName: 'session-id.json', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messageCount: 1, + displayName: 'Test Session', + firstUserMessage: 'Test Session', + isCurrentSession: false, + index: 0, + ...overrides, +}); + +describe('SessionBrowser component', () => { + beforeEach(() => { + keypressHandlers.length = 0; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows empty state when no sessions exist', () => { + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('No auto-saved conversations found.'); + expect(lastFrame()).toContain('Press q to exit'); + }); + + it('renders a list of sessions and marks current session as disabled', () => { + const session1 = createSession({ + id: 'abc123', + file: 'abc123', + displayName: 'First conversation about cats', + lastUpdated: '2025-01-01T10:05:00Z', + messageCount: 2, + index: 0, + }); + const session2 = createSession({ + id: 'def456', + file: 'def456', + displayName: 'Second conversation about dogs', + lastUpdated: '2025-01-01T11:30:00Z', + messageCount: 5, + isCurrentSession: true, + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Chat Sessions (2 total'); + expect(output).toContain('First conversation about cats'); + expect(output).toContain('Second conversation about dogs'); + expect(output).toContain('(current)'); + }); + + it('enters search mode, filters sessions, and renders match snippets', async () => { + const searchSession = createSession({ + id: 'search1', + file: 'search1', + displayName: 'Query is here and another query.', + firstUserMessage: 'Query is here and another query.', + fullContent: 'Query is here and another query.', + messages: [ + { + role: 'user', + content: 'Query is here and another query.', + }, + ], + index: 0, + }); + + const otherSession = createSession({ + id: 'other', + file: 'other', + displayName: 'Nothing interesting here.', + firstUserMessage: 'Nothing interesting here.', + fullContent: 'Nothing interesting here.', + messages: [ + { + role: 'user', + content: 'Nothing interesting here.', + }, + ], + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Chat Sessions (2 total'); + + // Enter search mode. + triggerKey({ sequence: '/', name: '/' }); + + await waitFor(() => { + expect(lastFrame()).toContain('Search:'); + }); + + // Type the query "query". + for (const ch of ['q', 'u', 'e', 'r', 'y']) { + triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false }); + } + + await waitFor(() => { + const output = lastFrame(); + expect(output).toContain('Chat Sessions (1 total, filtered'); + expect(output).toContain('Query is here'); + expect(output).not.toContain('Nothing interesting here.'); + + expect(output).toContain('You:'); + expect(output).toContain('query'); + expect(output).toContain('(+1 more)'); + }); + }); + + it('handles keyboard navigation and resumes the selected session', () => { + const session1 = createSession({ + id: 'one', + file: 'one', + displayName: 'First session', + index: 0, + }); + const session2 = createSession({ + id: 'two', + file: 'two', + displayName: 'Second session', + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Chat Sessions (2 total'); + + // Move selection down. + triggerKey({ name: 'down', sequence: '[B' }); + + // Press Enter. + triggerKey({ name: 'return', sequence: '\r' }); + + expect(onResumeSession).toHaveBeenCalledTimes(1); + const [resumedSession] = onResumeSession.mock.calls[0]; + expect(resumedSession).toEqual(session2); + }); + + it('does not allow resuming or deleting the current session', () => { + const currentSession = createSession({ + id: 'current', + file: 'current', + displayName: 'Current session', + isCurrentSession: true, + index: 0, + }); + const otherSession = createSession({ + id: 'other', + file: 'other', + displayName: 'Other session', + isCurrentSession: false, + index: 1, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onDeleteSession = vi.fn(); + const onExit = vi.fn(); + + render( + , + ); + + // Active selection is at 0 (current session). + triggerKey({ name: 'return', sequence: '\r' }); + expect(onResumeSession).not.toHaveBeenCalled(); + + // Attempt delete. + triggerKey({ sequence: 'x', name: 'x' }); + expect(onDeleteSession).not.toHaveBeenCalled(); + }); + + it('shows an error state when loading sessions fails', () => { + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Error: storage failure'); + expect(output).toContain('Press q to exit'); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx new file mode 100644 index 00000000000..ff52945f7bf --- /dev/null +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -0,0 +1,933 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../colors.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import path from 'node:path'; +import type { Config } from '@google/gemini-cli-core'; +import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js'; +import { + cleanMessage, + formatRelativeTime, + getSessionFiles, +} from '../../utils/sessionUtils.js'; + +/** + * Props for the main SessionBrowser component. + */ +export interface SessionBrowserProps { + /** Application configuration object */ + config: Config; + /** Callback when user selects a session to resume */ + onResumeSession: (session: SessionInfo) => void; + /** Callback when user deletes a session */ + onDeleteSession?: (session: SessionInfo) => void; + /** Callback when user exits the session browser */ + onExit: () => void; +} + +/** + * Centralized state interface for SessionBrowser component. + * Eliminates prop drilling by providing all state in a single object. + */ +export interface SessionBrowserState { + // Data state + /** All loaded sessions */ + sessions: SessionInfo[]; + /** Sessions after filtering and sorting */ + filteredAndSortedSessions: SessionInfo[]; + + // UI state + /** Whether sessions are currently loading */ + loading: boolean; + /** Error message if loading failed */ + error: string | null; + /** Index of currently selected session */ + activeIndex: number; + /** Current scroll offset for pagination */ + scrollOffset: number; + /** Terminal width for layout calculations */ + terminalWidth: number; + + // Search state + /** Current search query string */ + searchQuery: string; + /** Whether user is in search input mode */ + isSearchMode: boolean; + /** Whether full content has been loaded for search */ + hasLoadedFullContent: boolean; + + // Sort state + /** Current sort criteria */ + sortOrder: 'date' | 'messages' | 'name'; + /** Whether sort order is reversed */ + sortReverse: boolean; + + // Computed values + /** Total number of filtered sessions */ + totalSessions: number; + /** Start index for current page */ + startIndex: number; + /** End index for current page */ + endIndex: number; + /** Sessions visible on current page */ + visibleSessions: SessionInfo[]; + + // State setters + /** Update sessions array */ + setSessions: React.Dispatch>; + /** Update loading state */ + setLoading: React.Dispatch>; + /** Update error state */ + setError: React.Dispatch>; + /** Update active session index */ + setActiveIndex: React.Dispatch>; + /** Update scroll offset */ + setScrollOffset: React.Dispatch>; + /** Update search query */ + setSearchQuery: React.Dispatch>; + /** Update search mode state */ + setIsSearchMode: React.Dispatch>; + /** Update sort order */ + setSortOrder: React.Dispatch< + React.SetStateAction<'date' | 'messages' | 'name'> + >; + /** Update sort reverse flag */ + setSortReverse: React.Dispatch>; + setHasLoadedFullContent: React.Dispatch>; +} + +const SESSIONS_PER_PAGE = 20; +// Approximate total width reserved for non-message columns and separators +// (prefix, index, message count, age, pipes, and padding) in a session row. +// If the SessionItem layout changes, update this accordingly. +const FIXED_SESSION_COLUMNS_WIDTH = 30; + +const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => ( + <> + {name}: {shortcut} + +); + +/** + * Loading state component displayed while sessions are being loaded. + */ +const SessionBrowserLoading = (): React.JSX.Element => ( + + Loading sessions… + +); + +/** + * Error state component displayed when session loading fails. + */ +const SessionBrowserError = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Error: {state.error} + Press q to exit + +); + +/** + * Empty state component displayed when no sessions are found. + */ +const SessionBrowserEmpty = (): React.JSX.Element => ( + + No auto-saved conversations found. + Press q to exit + +); + +/** + * Sorts an array of sessions by the specified criteria. + * @param sessions - Array of sessions to sort + * @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName) + * @param reverse - Whether to reverse the sort order (ascending instead of descending) + * @returns New sorted array of sessions + */ +const sortSessions = ( + sessions: SessionInfo[], + sortBy: 'date' | 'messages' | 'name', + reverse: boolean, +): SessionInfo[] => { + const sorted = [...sessions].sort((a, b) => { + switch (sortBy) { + case 'date': + return ( + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime() + ); + case 'messages': + return b.messageCount - a.messageCount; + case 'name': + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return reverse ? sorted.reverse() : sorted; +}; + +/** + * Finds all text matches for a search query within conversation messages. + * Creates TextMatch objects with context (10 chars before/after) and role information. + * @param messages - Array of messages to search through + * @param query - Search query string (case-insensitive) + * @returns Array of TextMatch objects containing match context and metadata + */ +const findTextMatches = ( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + query: string, +): TextMatch[] => { + if (!query.trim()) return []; + + const lowerQuery = query.toLowerCase(); + const matches: TextMatch[] = []; + + for (const message of messages) { + const m = cleanMessage(message.content); + const lowerContent = m.toLowerCase(); + let startIndex = 0; + + while (true) { + const matchIndex = lowerContent.indexOf(lowerQuery, startIndex); + if (matchIndex === -1) break; + + const contextStart = Math.max(0, matchIndex - 10); + const contextEnd = Math.min(m.length, matchIndex + query.length + 10); + + const snippet = m.slice(contextStart, contextEnd); + const relativeMatchStart = matchIndex - contextStart; + const relativeMatchEnd = relativeMatchStart + query.length; + + let before = snippet.slice(0, relativeMatchStart); + const match = snippet.slice(relativeMatchStart, relativeMatchEnd); + let after = snippet.slice(relativeMatchEnd); + + if (contextStart > 0) before = '…' + before; + if (contextEnd < m.length) after = after + '…'; + + matches.push({ before, match, after, role: message.role }); + startIndex = matchIndex + 1; + } + } + + return matches; +}; + +/** + * Filters sessions based on a search query, checking titles, IDs, and full content. + * Also populates matchSnippets and matchCount for sessions with content matches. + * @param sessions - Array of sessions to filter + * @param query - Search query string (case-insensitive) + * @returns Filtered array of sessions that match the query + */ +const filterSessions = ( + sessions: SessionInfo[], + query: string, +): SessionInfo[] => { + if (!query.trim()) { + return sessions.map((session) => ({ + ...session, + matchSnippets: undefined, + matchCount: undefined, + })); + } + + const lowerQuery = query.toLowerCase(); + return sessions.filter((session) => { + const titleMatch = + session.displayName.toLowerCase().includes(lowerQuery) || + session.id.toLowerCase().includes(lowerQuery) || + session.firstUserMessage.toLowerCase().includes(lowerQuery); + + const contentMatch = session.fullContent + ?.toLowerCase() + .includes(lowerQuery); + + if (titleMatch || contentMatch) { + if (session.messages) { + session.matchSnippets = findTextMatches(session.messages, query); + session.matchCount = session.matchSnippets.length; + } + return true; + } + + return false; + }); +}; + +/** + * Search input display component. + */ +const SearchModeDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Search: + {state.searchQuery} + (Esc to cancel) + +); + +/** + * Header component showing session count and sort information. + */ +const SessionListHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + Chat Sessions ({state.totalSessions} total + {state.searchQuery ? `, filtered` : ''}) + + + sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'} + + +); + +/** + * Navigation help component showing keyboard shortcuts. + */ +const NavigationHelp = (): React.JSX.Element => ( + + + + {' '} + + {' '} + + {' '} + + {' '} + + + + + {' '} + + {' '} + + + +); + +/** + * Table header component with column labels and scroll indicators. + */ +const SessionTableHeader = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + {state.scrollOffset > 0 ? : ' '} + + + + Index + + + + + + Msgs + + + + + + Age + + + + + + {state.searchQuery ? 'Match' : 'Name'} + + + +); + +/** + * No results display component for empty search results. + */ +const NoResultsDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + + No sessions found matching '{state.searchQuery}'. + + +); + +/** + * Match snippet display component for search results. + */ +const MatchSnippetDisplay = ({ + session, + textColor, +}: { + session: SessionInfo; + textColor: (color?: string) => string; +}): React.JSX.Element | null => { + if (!session.matchSnippets || session.matchSnippets.length === 0) { + return null; + } + + const firstMatch = session.matchSnippets[0]; + const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:'; + const roleColor = textColor( + firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue, + ); + + return ( + <> + + {rolePrefix}{' '} + + {firstMatch.before} + + {firstMatch.match} + + {firstMatch.after} + + ); +}; + +/** + * Individual session row component. + */ +const SessionItem = ({ + session, + state, + terminalWidth, + formatRelativeTime, +}: { + session: SessionInfo; + state: SessionBrowserState; + terminalWidth: number; + formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; +}): React.JSX.Element => { + const originalIndex = + state.startIndex + state.visibleSessions.indexOf(session); + const isActive = originalIndex === state.activeIndex; + const isDisabled = session.isCurrentSession; + const textColor = (c: string = Colors.Foreground) => { + if (isDisabled) { + return Colors.Gray; + } + return isActive ? Colors.AccentPurple : c; + }; + + const prefix = isActive ? '❯ ' : ' '; + let additionalInfo = ''; + let matchDisplay = null; + + // Add "(current)" label for the current session + if (session.isCurrentSession) { + additionalInfo = ' (current)'; + } + + // Show match snippets if searching and matches exist + if ( + state.searchQuery && + session.matchSnippets && + session.matchSnippets.length > 0 + ) { + matchDisplay = ( + + ); + + if (session.matchCount && session.matchCount > 1) { + additionalInfo += ` (+${session.matchCount - 1} more)`; + } + } + + const availableMessageWidth = Math.max( + 20, + terminalWidth - FIXED_SESSION_COLUMNS_WIDTH, + ); + + const truncatedMessage = + matchDisplay || + (session.displayName.length === 0 ? ( + + (No messages) + + ) : session.displayName.length > availableMessageWidth ? ( + session.displayName.slice(0, availableMessageWidth - 1) + '…' + ) : ( + session.displayName + )); + + return ( + + + {prefix} + + + + #{originalIndex + 1} + + + + {' '} + │{' '} + + + + {session.messageCount} + + + + {' '} + │{' '} + + + + {formatRelativeTime(session.lastUpdated, 'short')} + + + + {' '} + │{' '} + + + + {truncatedMessage} + {additionalInfo && ( + + {additionalInfo} + + )} + + + + ); +}; + +/** + * Session list container component. + */ +const SessionList = ({ + state, + formatRelativeTime, +}: { + state: SessionBrowserState; + formatRelativeTime: (dateString: string, style: 'short' | 'long') => string; +}): React.JSX.Element => ( + + {/* Table Header */} + + {!state.isSearchMode && } + + + + {state.visibleSessions.map((session) => ( + + ))} + + + {state.endIndex < state.totalSessions ? <>▼ : } + + +); + +/** + * Hook to manage all SessionBrowser state. + */ +export const useSessionBrowserState = ( + initialSessions: SessionInfo[] = [], + initialLoading = true, + initialError: string | null = null, +): SessionBrowserState => { + const { columns: terminalWidth } = useTerminalSize(); + const [sessions, setSessions] = useState(initialSessions); + const [loading, setLoading] = useState(initialLoading); + const [error, setError] = useState(initialError); + const [activeIndex, setActiveIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [sortOrder, setSortOrder] = useState<'date' | 'messages' | 'name'>( + 'date', + ); + const [sortReverse, setSortReverse] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchMode, setIsSearchMode] = useState(false); + const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false); + const loadingFullContentRef = useRef(false); + + const filteredAndSortedSessions = useMemo(() => { + const filtered = filterSessions(sessions, searchQuery); + return sortSessions(filtered, sortOrder, sortReverse); + }, [sessions, searchQuery, sortOrder, sortReverse]); + + // Reset full content flag when search is cleared + useEffect(() => { + if (!searchQuery) { + setHasLoadedFullContent(false); + loadingFullContentRef.current = false; + } + }, [searchQuery]); + + const totalSessions = filteredAndSortedSessions.length; + const startIndex = scrollOffset; + const endIndex = Math.min(scrollOffset + SESSIONS_PER_PAGE, totalSessions); + const visibleSessions = filteredAndSortedSessions.slice(startIndex, endIndex); + + const state: SessionBrowserState = { + sessions, + setSessions, + loading, + setLoading, + error, + setError, + activeIndex, + setActiveIndex, + scrollOffset, + setScrollOffset, + searchQuery, + setSearchQuery, + isSearchMode, + setIsSearchMode, + hasLoadedFullContent, + setHasLoadedFullContent, + sortOrder, + setSortOrder, + sortReverse, + setSortReverse, + terminalWidth, + filteredAndSortedSessions, + totalSessions, + startIndex, + endIndex, + visibleSessions, + }; + + return state; +}; + +/** + * Hook to load sessions on mount. + */ +const useLoadSessions = (config: Config, state: SessionBrowserState) => { + const { + setSessions, + setLoading, + setError, + isSearchMode, + hasLoadedFullContent, + setHasLoadedFullContent, + } = state; + + useEffect(() => { + const loadSessions = async () => { + try { + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + const sessionData = await getSessionFiles( + chatsDir, + config.getSessionId(), + ); + setSessions(sessionData); + setLoading(false); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load sessions', + ); + setLoading(false); + } + }; + + loadSessions(); + }, [config, setSessions, setLoading, setError]); + + useEffect(() => { + const loadFullContent = async () => { + if (isSearchMode && !hasLoadedFullContent) { + try { + const chatsDir = path.join( + config.storage.getProjectTempDir(), + 'chats', + ); + const sessionData = await getSessionFiles( + chatsDir, + config.getSessionId(), + { includeFullContent: true }, + ); + setSessions(sessionData); + setHasLoadedFullContent(true); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to load full session content', + ); + } + } + }; + + loadFullContent(); + }, [ + isSearchMode, + hasLoadedFullContent, + config, + setSessions, + setHasLoadedFullContent, + setError, + ]); +}; + +/** + * Hook to handle selection movement. + */ +export const useMoveSelection = (state: SessionBrowserState) => { + const { + totalSessions, + activeIndex, + scrollOffset, + setActiveIndex, + setScrollOffset, + } = state; + + return useCallback( + (delta: number) => { + const newIndex = Math.max( + 0, + Math.min(totalSessions - 1, activeIndex + delta), + ); + setActiveIndex(newIndex); + + // Adjust scroll offset if needed + if (newIndex < scrollOffset) { + setScrollOffset(newIndex); + } else if (newIndex >= scrollOffset + SESSIONS_PER_PAGE) { + setScrollOffset(newIndex - SESSIONS_PER_PAGE + 1); + } + }, + [totalSessions, activeIndex, scrollOffset, setActiveIndex, setScrollOffset], + ); +}; + +/** + * Hook to handle sort order cycling. + */ +export const useCycleSortOrder = (state: SessionBrowserState) => { + const { sortOrder, setSortOrder } = state; + + return useCallback(() => { + const orders: Array<'date' | 'messages' | 'name'> = [ + 'date', + 'messages', + 'name', + ]; + const currentIndex = orders.indexOf(sortOrder); + const nextIndex = (currentIndex + 1) % orders.length; + setSortOrder(orders[nextIndex]); + }, [sortOrder, setSortOrder]); +}; + +/** + * Hook to handle SessionBrowser input. + */ +export const useSessionBrowserInput = ( + state: SessionBrowserState, + moveSelection: (delta: number) => void, + cycleSortOrder: () => void, + onResumeSession: (session: SessionInfo) => void, + onDeleteSession: ((session: SessionInfo) => void) | undefined, + onExit: () => void, +) => { + useKeypress( + (key) => { + if (state.isSearchMode) { + // Search-specific input handling. Only control/symbols here. + if (key.name === 'escape') { + state.setIsSearchMode(false); + state.setSearchQuery(''); + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if (key.name === 'backspace') { + state.setSearchQuery((prev) => prev.slice(0, -1)); + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if ( + key.sequence && + !key.ctrl && + !key.meta && + key.sequence.length === 1 + ) { + state.setSearchQuery((prev) => prev + key.sequence); + state.setActiveIndex(0); + state.setScrollOffset(0); + } + } else { + // Navigation mode input handling. We're keeping the letter-based controls for non-search + // mode only, because the letters need to act as input for the search. + if (key.sequence === 'g') { + state.setActiveIndex(0); + state.setScrollOffset(0); + } else if (key.sequence === 'G') { + state.setActiveIndex(state.totalSessions - 1); + state.setScrollOffset( + Math.max(0, state.totalSessions - SESSIONS_PER_PAGE), + ); + } + // Sorting controls. + else if (key.sequence === 's') { + cycleSortOrder(); + } else if (key.sequence === 'r') { + state.setSortReverse(!state.sortReverse); + } + // Searching and exit controls. + else if (key.sequence === '/') { + state.setIsSearchMode(true); + } else if ( + key.sequence === 'q' || + key.sequence === 'Q' || + key.name === 'escape' + ) { + onExit(); + } + // Delete session control. + else if (key.sequence === 'x' || key.sequence === 'X') { + const selectedSession = + state.filteredAndSortedSessions[state.activeIndex]; + if ( + selectedSession && + !selectedSession.isCurrentSession && + onDeleteSession + ) { + try { + onDeleteSession(selectedSession); + // Remove the session from the state + state.setSessions( + state.sessions.filter((s) => s.id !== selectedSession.id), + ); + + // Adjust active index if needed + if ( + state.activeIndex >= + state.filteredAndSortedSessions.length - 1 + ) { + state.setActiveIndex( + Math.max(0, state.filteredAndSortedSessions.length - 2), + ); + } + } catch (error) { + state.setError( + `Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } + // less-like u/d controls. + else if (key.sequence === 'd') { + moveSelection(-Math.round(SESSIONS_PER_PAGE / 2)); + } else if (key.sequence === 'u') { + moveSelection(Math.round(SESSIONS_PER_PAGE / 2)); + } + } + + // Handling regardless of search mode. + if ( + key.name === 'return' && + state.filteredAndSortedSessions[state.activeIndex] + ) { + const selectedSession = + state.filteredAndSortedSessions[state.activeIndex]; + // Don't allow resuming the current session + if (!selectedSession.isCurrentSession) { + onResumeSession(selectedSession); + } + } else if (key.name === 'up') { + moveSelection(-1); + } else if (key.name === 'down') { + moveSelection(1); + } else if (key.name === 'pageup') { + moveSelection(-SESSIONS_PER_PAGE); + } else if (key.name === 'pagedown') { + moveSelection(SESSIONS_PER_PAGE); + } + }, + { isActive: true }, + ); +}; + +export function SessionBrowserView({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element { + if (state.loading) { + return ; + } + + if (state.error) { + return ; + } + + if (state.sessions.length === 0) { + return ; + } + return ( + + + + {state.isSearchMode && } + + {state.totalSessions === 0 ? ( + + ) : ( + + )} + + ); +} + +export function SessionBrowser({ + config, + onResumeSession, + onDeleteSession, + onExit, +}: SessionBrowserProps): React.JSX.Element { + // Use all our custom hooks + const state = useSessionBrowserState(); + useLoadSessions(config, state); + const moveSelection = useMoveSelection(state); + const cycleSortOrder = useCycleSortOrder(state); + useSessionBrowserInput( + state, + moveSelection, + cycleSortOrder, + onResumeSession, + onDeleteSession, + onExit, + ); + + return ; +} diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index 3face81079e..80df6f32907 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -4,44 +4,165 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { convertSessionToHistoryFormats } from './useSessionBrowser.js'; -import { MessageType, ToolCallStatus } from '../types.js'; -import type { MessageRecord } from '@google/gemini-cli-core'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { act } from 'react'; +import { + useSessionBrowser, + convertSessionToHistoryFormats, +} from './useSessionBrowser.js'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js'; +import type { + Config, + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; + +// Mock modules +vi.mock('fs/promises'); +vi.mock('path'); +vi.mock('../../utils/sessionUtils.js'); + +const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp'; +const MOCKED_CHATS_DIR = '/test/project/temp/chats'; +const MOCKED_SESSION_ID = 'test-session-123'; +const MOCKED_CURRENT_SESSION_ID = 'current-session-id'; + +describe('useSessionBrowser', () => { + const mockedFs = vi.mocked(fs); + const mockedPath = vi.mocked(path); + const mockedGetSessionFiles = vi.mocked(getSessionFiles); + + const mockConfig = { + storage: { + getProjectTempDir: vi.fn(), + }, + setSessionId: vi.fn(), + getSessionId: vi.fn(), + getGeminiClient: vi.fn().mockReturnValue({ + getChatRecordingService: vi.fn().mockReturnValue({ + deleteSession: vi.fn(), + }), + }), + } as unknown as Config; + + const mockOnLoadHistory = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + mockedPath.join.mockImplementation((...args) => args.join('/')); + vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue( + MOCKED_PROJECT_TEMP_DIR, + ); + vi.mocked(mockConfig.getSessionId).mockReturnValue( + MOCKED_CURRENT_SESSION_ID, + ); + }); + + it('should successfully resume a session', async () => { + const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json'; + const mockConversation: ConversationRecord = { + sessionId: 'existing-session-456', + messages: [{ type: 'user', content: 'Hello' } as MessageRecord], + } as ConversationRecord; + + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedGetSessionFiles.mockResolvedValue([mockSession]); + mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation)); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + expect(mockedFs.readFile).toHaveBeenCalledWith( + `${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`, + 'utf8', + ); + expect(mockConfig.setSessionId).toHaveBeenCalledWith( + 'existing-session-456', + ); + expect(result.current.isSessionBrowserOpen).toBe(false); + expect(mockOnLoadHistory).toHaveBeenCalled(); + }); + + it('should handle file read error', async () => { + const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json'; + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedFs.readFile.mockRejectedValue(new Error('File not found')); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(result.current.isSessionBrowserOpen).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should handle JSON parse error', async () => { + const MOCKED_FILENAME = 'invalid.json'; + const mockSession = { + id: MOCKED_SESSION_ID, + fileName: MOCKED_FILENAME, + } as SessionInfo; + mockedFs.readFile.mockResolvedValue('invalid json'); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { result } = renderHook(() => + useSessionBrowser(mockConfig, mockOnLoadHistory), + ); + + await act(async () => { + await result.current.handleResumeSession(mockSession); + }); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(result.current.isSessionBrowserOpen).toBe(false); + consoleErrorSpy.mockRestore(); + }); +}); + +// The convertSessionToHistoryFormats tests are self-contained and do not need changes. describe('convertSessionToHistoryFormats', () => { it('should convert empty messages array', () => { const result = convertSessionToHistoryFormats([]); - expect(result.uiHistory).toEqual([]); expect(result.clientHistory).toEqual([]); }); - it('should convert basic user and gemini messages', () => { + it('should convert basic user and model messages', () => { const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Hello', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: 'Hi there!', - type: 'gemini', - }, + { type: 'user', content: 'Hello' } as MessageRecord, + { type: 'gemini', content: 'Hi there' } as MessageRecord, ]; const result = convertSessionToHistoryFormats(messages); expect(result.uiHistory).toHaveLength(2); - expect(result.uiHistory[0]).toEqual({ - type: MessageType.USER, - text: 'Hello', - }); - expect(result.uiHistory[1]).toEqual({ - type: MessageType.GEMINI, - text: 'Hi there!', + expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: 'Hello' }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'gemini', + text: 'Hi there', }); expect(result.clientHistory).toHaveLength(2); @@ -51,582 +172,92 @@ describe('convertSessionToHistoryFormats', () => { }); expect(result.clientHistory[1]).toEqual({ role: 'model', - parts: [{ text: 'Hi there!' }], + parts: [{ text: 'Hi there' }], }); }); - it('should convert system, warning, and error messages to appropriate types', () => { + it('should filter out slash commands from client history but keep in UI', () => { const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'System message', - type: 'info', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: 'Warning message', - type: 'warning', - }, - { - id: 'msg-3', - timestamp: '2025-01-01T00:03:00Z', - content: 'Error occurred', - type: 'error', - }, + { type: 'user', content: '/help' } as MessageRecord, + { type: 'info', content: 'Help text' } as MessageRecord, ]; const result = convertSessionToHistoryFormats(messages); - expect(result.uiHistory[0]).toEqual({ - type: MessageType.INFO, - text: 'System message', - }); - expect(result.uiHistory[1]).toEqual({ - type: MessageType.WARNING, - text: 'Warning message', - }); - expect(result.uiHistory[2]).toEqual({ - type: MessageType.ERROR, - text: 'Error occurred', - }); - - // System, warning, and error messages should not be included in client history - expect(result.clientHistory).toEqual([]); - }); - - it('should filter out slash commands from client history', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: '/help', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: '?quit', - type: 'user', - }, - { - id: 'msg-3', - timestamp: '2025-01-01T00:03:00Z', - content: 'Regular message', - type: 'user', - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // All messages should appear in UI history - expect(result.uiHistory).toHaveLength(3); - - // Only non-slash commands should appear in client history - expect(result.clientHistory).toHaveLength(1); - expect(result.clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'Regular message' }], - }); - }); - - it('should handle tool calls correctly', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: "I'll help you with that.", - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - displayName: 'Execute Command', - description: 'Run bash command', - args: { command: 'ls -la' }, - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', - renderOutputAsMarkdown: false, - }, - { - id: 'tool-2', - name: 'read', - displayName: 'Read File', - description: 'Read file contents', - args: { path: '/etc/hosts' }, - status: 'error', - timestamp: '2025-01-01T00:01:45Z', - resultDisplay: 'Permission denied', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(2); // text message + tool group - expect(result.uiHistory[0]).toEqual({ - type: MessageType.GEMINI, - text: "I'll help you with that.", - }); - - expect(result.uiHistory[1].type).toBe('tool_group'); - // This if-statement is only necessary because TypeScript can't tell that the toBe() assertion - // protects the .tools access below. - if (result.uiHistory[1].type === 'tool_group') { - expect(result.uiHistory[1].tools).toHaveLength(2); - expect(result.uiHistory[1].tools[0]).toEqual({ - callId: 'tool-1', - name: 'Execute Command', - description: 'Run bash command', - renderOutputAsMarkdown: false, - status: ToolCallStatus.Success, - resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', - confirmationDetails: undefined, - }); - expect(result.uiHistory[1].tools[1]).toEqual({ - callId: 'tool-2', - name: 'Read File', - description: 'Read file contents', - renderOutputAsMarkdown: true, // default value - status: ToolCallStatus.Error, - resultDisplay: 'Permission denied', - confirmationDetails: undefined, - }); - } - }); - - it('should skip empty tool calls arrays', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Message with empty tools', - type: 'gemini', - toolCalls: [], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(1); // Only text message - expect(result.uiHistory[0]).toEqual({ - type: MessageType.GEMINI, - text: 'Message with empty tools', + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[0]).toMatchObject({ type: 'user', text: '/help' }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'info', + text: 'Help text', }); - }); - - it('should not add tool calls for user messages', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'User message', - type: 'user', - // This would be invalid in real usage, but testing robustness - toolCalls: [ - { - id: 'tool-1', - name: 'invalid', - args: {}, - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - } as MessageRecord, - ]; - const result = convertSessionToHistoryFormats(messages); - - expect(result.uiHistory).toHaveLength(1); // Only user message, no tool group - expect(result.uiHistory[0]).toEqual({ - type: MessageType.USER, - text: 'User message', - }); + expect(result.clientHistory).toHaveLength(0); }); - it('should handle missing tool call fields gracefully', () => { + it('should handle tool calls and responses', () => { const messages: MessageRecord[] = [ + { type: 'user', content: 'What time is it?' } as MessageRecord, { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Message with minimal tool', type: 'gemini', + content: '', toolCalls: [ { - id: 'tool-1', - name: 'minimal_tool', + id: 'call_1', + name: 'get_time', args: {}, status: 'success', - timestamp: '2025-01-01T00:01:30Z', - // Missing optional fields + result: '12:00', }, ], - }, + } as unknown as MessageRecord, ]; const result = convertSessionToHistoryFormats(messages); expect(result.uiHistory).toHaveLength(2); - expect(result.uiHistory[1].type).toBe('tool_group'); - if (result.uiHistory[1].type === 'tool_group') { - expect(result.uiHistory[1].tools[0]).toEqual({ - callId: 'tool-1', - name: 'minimal_tool', // Falls back to name when displayName missing - description: '', // Default empty string - renderOutputAsMarkdown: true, // Default value - status: ToolCallStatus.Success, - resultDisplay: undefined, - confirmationDetails: undefined, - }); - } else { - throw new Error('unreachable'); - } - }); - - describe('tool calls in client history', () => { - it('should convert tool calls to correct Gemini client history format', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'List files', - type: 'user', - }, - { - id: 'msg-2', - timestamp: '2025-01-01T00:02:00Z', - content: "I'll list the files for you.", - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'list_directory', - args: { path: '/home/user' }, - result: { - functionResponse: { - id: 'list_directory-1753650620141-f3b8b9e73919d', - name: 'list_directory', - response: { - output: 'file1.txt\nfile2.txt', - }, - }, - }, - status: 'success', - timestamp: '2025-01-01T00:02:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should have: user message, model with function call, user with function response - expect(result.clientHistory).toHaveLength(3); - - // User message - expect(result.clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'List files' }], - }); - - // Model message with function call - expect(result.clientHistory[1]).toEqual({ - role: 'model', - parts: [ - { text: "I'll list the files for you." }, - { - functionCall: { - name: 'list_directory', - args: { path: '/home/user' }, - id: 'tool-1', - }, - }, - ], - }); - - // Function response - expect(result.clientHistory[2]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'list_directory-1753650620141-f3b8b9e73919d', - name: 'list_directory', - response: { output: 'file1.txt\nfile2.txt' }, - }, - }, - ], - }); + expect(result.uiHistory[0]).toMatchObject({ + type: 'user', + text: 'What time is it?', }); - - it('should handle tool calls without text content', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: '', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - args: { command: 'ls' }, - result: 'file1.txt\nfile2.txt', - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.clientHistory).toHaveLength(2); - - // Model message with only function call (no text) - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { - functionCall: { - name: 'bash', - args: { command: 'ls' }, - id: 'tool-1', - }, - }, - ], - }); - - // Function response - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'bash', - response: { - output: 'file1.txt\nfile2.txt', - }, - }, - }, - ], - }); + expect(result.uiHistory[1]).toMatchObject({ + type: 'tool_group', + tools: [ + expect.objectContaining({ + callId: 'call_1', + name: 'get_time', + status: 'Success', + }), + ], }); - it('should handle multiple tool calls in one message', () => { - const messages: MessageRecord[] = [ - { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Running multiple commands', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'bash', - args: { command: 'pwd' }, - result: '/home/user', - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - { - id: 'tool-2', - name: 'bash', - args: { command: 'ls' }, - result: [ - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { - output: 'file1.txt', - }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { - output: 'file2.txt', - }, - }, - }, - ], - status: 'success', - timestamp: '2025-01-01T00:01:35Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should have: model with both function calls, then one response - expect(result.clientHistory).toHaveLength(2); - - // Model message with both function calls - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { text: 'Running multiple commands' }, - { - functionCall: { - name: 'bash', - args: { command: 'pwd' }, - id: 'tool-1', - }, - }, - { - functionCall: { - name: 'bash', - args: { command: 'ls' }, - id: 'tool-2', - }, - }, - ], - }); - - // First function response - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'bash', - response: { output: '/home/user' }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { output: 'file1.txt' }, - }, - }, - { - functionResponse: { - id: 'tool-2', - name: 'bash', - response: { output: 'file2.txt' }, - }, - }, - ], - }); + expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response) + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'What time is it?' }], }); - - it('should handle Part array results from tools', () => { - const messages: MessageRecord[] = [ + expect(result.clientHistory[1]).toEqual({ + role: 'model', + parts: [ { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Reading file', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'read_file', - args: { path: 'test.txt' }, - result: [ - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: 'Hello', - }, - }, - }, - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: ' World', - }, - }, - }, - ], - status: 'success', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - expect(result.clientHistory).toHaveLength(2); - - // Function response should extract both function responses - expect(result.clientHistory[1]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: 'Hello', - }, - }, - }, - { - functionResponse: { - id: 'tool-1', - name: 'read_file', - response: { - output: ' World', - }, - }, + functionCall: { + name: 'get_time', + args: {}, + id: 'call_1', }, - ], - }); + }, + ], }); - - it('should skip tool calls without results', () => { - const messages: MessageRecord[] = [ + expect(result.clientHistory[2]).toEqual({ + role: 'user', + parts: [ { - id: 'msg-1', - timestamp: '2025-01-01T00:01:00Z', - content: 'Testing tool', - type: 'gemini', - toolCalls: [ - { - id: 'tool-1', - name: 'test_tool', - args: { arg: 'value' }, - // No result field - status: 'error', - timestamp: '2025-01-01T00:01:30Z', - }, - ], - }, - ]; - - const result = convertSessionToHistoryFormats(messages); - - // Should only have the model message with function call, no function response - expect(result.clientHistory).toHaveLength(1); - - expect(result.clientHistory[0]).toEqual({ - role: 'model', - parts: [ - { text: 'Testing tool' }, - { - functionCall: { - name: 'test_tool', - args: { arg: 'value' }, - id: 'tool-1', - }, + functionResponse: { + id: 'call_1', + name: 'get_time', + response: { output: '12:00' }, }, - ], - }); + }, + ], }); }); }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 69a787b0306..2dec70901db 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -4,11 +4,110 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState, useCallback } from 'react'; import type { HistoryItemWithoutId } from '../types.js'; -import type { ConversationRecord } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import type { + Config, + ConversationRecord, + ResumedSessionData, +} from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { partListUnionToString } from '@google/gemini-cli-core'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; import { MessageType, ToolCallStatus } from '../types.js'; + +export const useSessionBrowser = ( + config: Config, + onLoadHistory: ( + uiHistory: HistoryItemWithoutId[], + clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + resumedSessionData: ResumedSessionData, + ) => void, +) => { + const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false); + + return { + isSessionBrowserOpen, + + openSessionBrowser: useCallback(() => { + setIsSessionBrowserOpen(true); + }, []), + + closeSessionBrowser: useCallback(() => { + setIsSessionBrowserOpen(false); + }, []), + + /** + * Loads a conversation by ID, and reinitializes the chat recording service with it. + */ + handleResumeSession: useCallback( + async (session: SessionInfo) => { + try { + const chatsDir = path.join( + config.storage.getProjectTempDir(), + 'chats', + ); + + const fileName = session.fileName; + + const originalFilePath = path.join(chatsDir, fileName); + + // Load up the conversation. + const conversation: ConversationRecord = JSON.parse( + await fs.readFile(originalFilePath, 'utf8'), + ); + + // Use the old session's ID to continue it. + const existingSessionId = conversation.sessionId; + config.setSessionId(existingSessionId); + + const resumedSessionData = { + conversation, + filePath: originalFilePath, + }; + + // We've loaded it; tell the UI about it. + setIsSessionBrowserOpen(false); + const historyData = convertSessionToHistoryFormats( + conversation.messages, + ); + onLoadHistory( + historyData.uiHistory, + historyData.clientHistory, + resumedSessionData, + ); + } catch (error) { + console.error('Error resuming session:', error); + setIsSessionBrowserOpen(false); + } + }, + [config, onLoadHistory], + ), + + /** + * Deletes a session by ID using the ChatRecordingService. + */ + handleDeleteSession: useCallback( + (session: SessionInfo) => { + try { + const chatRecordingService = config + .getGeminiClient() + ?.getChatRecordingService(); + if (chatRecordingService) { + chatRecordingService.deleteSession(session.id); + } + } catch (error) { + console.error('Error deleting session:', error); + throw error; + } + }, + [config], + ), + }; +}; + /** * Converts session/conversation data into UI history and Gemini client history formats. */ diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index e86990e3198..d47be2fc009 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -52,6 +52,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -62,6 +64,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, startTime: oneWeekAgo.toISOString(), lastUpdated: oneWeekAgo.toISOString(), + messageCount: 10, + displayName: 'Recent session', firstUserMessage: 'Recent session', isCurrentSession: false, index: 2, @@ -72,6 +76,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, startTime: twoWeeksAgo.toISOString(), lastUpdated: twoWeeksAgo.toISOString(), + messageCount: 3, + displayName: 'Old session', firstUserMessage: 'Old session', isCurrentSession: false, index: 3, @@ -82,6 +88,8 @@ function createTestSessions(): SessionInfo[] { fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, startTime: oneMonthAgo.toISOString(), lastUpdated: oneMonthAgo.toISOString(), + messageCount: 15, + displayName: 'Ancient session', firstUserMessage: 'Ancient session', isCurrentSession: false, index: 4, @@ -435,6 +443,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -445,6 +455,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}5d.json`, startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + messageCount: 1, + displayName: '5 days old', firstUserMessage: '5 days', isCurrentSession: false, index: 2, @@ -455,6 +467,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}8d.json`, startTime: eightDaysAgo.toISOString(), lastUpdated: eightDaysAgo.toISOString(), + messageCount: 1, + displayName: '8 days old', firstUserMessage: '8 days', isCurrentSession: false, index: 3, @@ -465,6 +479,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}15d.json`, startTime: fifteenDaysAgo.toISOString(), lastUpdated: fifteenDaysAgo.toISOString(), + messageCount: 1, + displayName: '15 days old', firstUserMessage: '15 days', isCurrentSession: false, index: 4, @@ -549,6 +565,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -559,6 +577,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}1d.json`, startTime: oneDayAgo.toISOString(), lastUpdated: oneDayAgo.toISOString(), + messageCount: 1, + displayName: '1 day old', firstUserMessage: '1 day', isCurrentSession: false, index: 2, @@ -569,6 +589,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}7d.json`, startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + messageCount: 1, + displayName: '7 days old', firstUserMessage: '7 days', isCurrentSession: false, index: 3, @@ -579,6 +601,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}13d.json`, startTime: thirteenDaysAgo.toISOString(), lastUpdated: thirteenDaysAgo.toISOString(), + messageCount: 1, + displayName: '13 days old', firstUserMessage: '13 days', isCurrentSession: false, index: 4, @@ -637,6 +661,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current (newest)', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -652,6 +678,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}${i}d.json`, startTime: daysAgo.toISOString(), lastUpdated: daysAgo.toISOString(), + messageCount: 1, + displayName: `${i} days old`, firstUserMessage: `${i} days`, isCurrentSession: false, index: i + 1, @@ -759,6 +787,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}current.json`, startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 1, + displayName: 'Current', firstUserMessage: 'Current', isCurrentSession: true, index: 1, @@ -769,6 +799,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}3d.json`, startTime: threeDaysAgo.toISOString(), lastUpdated: threeDaysAgo.toISOString(), + messageCount: 1, + displayName: '3 days old', firstUserMessage: '3 days', isCurrentSession: false, index: 2, @@ -779,6 +811,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}5d.json`, startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + messageCount: 1, + displayName: '5 days old', firstUserMessage: '5 days', isCurrentSession: false, index: 3, @@ -789,6 +823,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}7d.json`, startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + messageCount: 1, + displayName: '7 days old', firstUserMessage: '7 days', isCurrentSession: false, index: 4, @@ -799,6 +835,8 @@ describe('Session Cleanup', () => { fileName: `${SESSION_FILE_PREFIX}12d.json`, startTime: twelveDaysAgo.toISOString(), lastUpdated: twelveDaysAgo.toISOString(), + messageCount: 1, + displayName: '12 days old', firstUserMessage: '12 days', isCurrentSession: false, index: 5, diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 5c53e8bd54f..51cc95e918c 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -234,6 +234,70 @@ describe('SessionSelector', () => { expect(result.sessionData.messages[0].content).toBe('Latest session'); }); + it('should deduplicate sessions by ID', async () => { + const sessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sessionOriginal = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Original', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const sessionDuplicate = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T11:00:00.000Z', // Newer + messages: [ + { + type: 'user', + content: 'Newer Duplicate', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + // File 1 + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionOriginal, null, 2), + ); + + // File 2 (Simulate a copy or newer version with same ID) + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(sessionDuplicate, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + // Should keep the one with later lastUpdated + expect(sessions[0].lastUpdated).toBe('2024-01-01T11:00:00.000Z'); + }); + it('should throw error for invalid session identifier', async () => { const sessionId1 = randomUUID(); @@ -296,7 +360,7 @@ describe('extractFirstUserMessage', () => { expect(extractFirstUserMessage(messages)).toBe('Hello world'); }); - it('should truncate long messages', () => { + it('should not truncate long messages', () => { const longMessage = 'a'.repeat(150); const messages = [ { @@ -308,8 +372,7 @@ describe('extractFirstUserMessage', () => { ] as MessageRecord[]; const result = extractFirstUserMessage(messages); - expect(result).toBe('a'.repeat(97) + '...'); - expect(result.length).toBe(100); + expect(result).toBe(longMessage); }); it('should return "Empty conversation" for no user messages', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 5cad4303bc2..0995fb3d208 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -10,8 +10,8 @@ import type { MessageRecord, } from '@google/gemini-cli-core'; import { - SESSION_FILE_PREFIX, partListUnionToString, + SESSION_FILE_PREFIX, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -22,6 +22,20 @@ import path from 'node:path'; */ export const RESUME_LATEST = 'latest'; +/** + * Represents a text match found during search with surrounding context. + */ +export interface TextMatch { + /** Text content before the match (with ellipsis if truncated) */ + before: string; + /** The exact matched text */ + match: string; + /** Text content after the match (with ellipsis if truncated) */ + after: string; + /** Role of the message author where the match was found */ + role: 'user' | 'assistant'; +} + /** * Session information for display and selection purposes. */ @@ -34,14 +48,26 @@ export interface SessionInfo { fileName: string; /** ISO timestamp when session started */ startTime: string; + /** Total number of messages in the session */ + messageCount: number; /** ISO timestamp when session was last updated */ lastUpdated: string; + /** Display name for the session (typically first user message) */ + displayName: string; /** Cleaned first user message content */ firstUserMessage: string; /** Whether this is the currently active session */ isCurrentSession: boolean; /** Display index in the list */ index: number; + /** Full concatenated content (only loaded when needed for search) */ + fullContent?: string; + /** Processed messages with normalized roles (only loaded when needed) */ + messages?: Array<{ role: 'user' | 'assistant'; content: string }>; + /** Search result snippets when filtering */ + matchSnippets?: TextMatch[]; + /** Total number of matches found in this session */ + matchCount?: number; } /** @@ -60,30 +86,64 @@ export interface SessionFileEntry { export interface SessionSelectionResult { sessionPath: string; sessionData: ConversationRecord; + displayInfo: string; } +/** + * Cleans and sanitizes message content for display by: + * - Converting newlines to spaces + * - Collapsing multiple whitespace to single spaces + * - Removing non-printable characters (keeping only ASCII 32-126) + * - Trimming leading/trailing whitespace + * @param message - The raw message content to clean + * @returns Sanitized message suitable for display + */ +export const cleanMessage = (message: string): string => + message + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/[^\x20-\x7E]+/g, '') // Non-printable. + .trim(); + /** * Extracts the first meaningful user message from conversation messages. */ export const extractFirstUserMessage = (messages: MessageRecord[]): string => { - const userMessage = messages.find((msg) => { - const content = partListUnionToString(msg.content); - return msg.type === 'user' && content?.trim() && content !== '/resume'; - }); + const userMessage = messages + // First try filtering out slash commands. + .filter((msg) => { + const content = partListUnionToString(msg.content); + return ( + !content.startsWith('/') && + !content.startsWith('?') && + content.trim().length > 0 + ); + }) + .find((msg) => msg.type === 'user'); + + let content: string; if (!userMessage) { - return 'Empty conversation'; + // Fallback to first user message even if it's a slash command + const firstMsg = messages.find((msg) => msg.type === 'user'); + if (!firstMsg) return 'Empty conversation'; + content = cleanMessage(partListUnionToString(firstMsg.content)); + } else { + content = cleanMessage(partListUnionToString(userMessage.content)); } - // Truncate long messages for display - const content = partListUnionToString(userMessage.content).trim(); - return content.length > 100 ? content.slice(0, 97) + '...' : content; + return content; }; /** - * Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago"). + * Formats a timestamp as relative time. + * @param timestamp - The timestamp to format + * @param style - 'long' (e.g. "2 hours ago") or 'short' (e.g. "2h") */ -export const formatRelativeTime = (timestamp: string): string => { +export const formatRelativeTime = ( + timestamp: string, + style: 'long' | 'short' = 'long', +): string => { const now = new Date(); const time = new Date(timestamp); const diffMs = now.getTime() - time.getTime(); @@ -92,17 +152,34 @@ export const formatRelativeTime = (timestamp: string): string => { const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) { - return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; - } else if (diffHours > 0) { - return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; - } else if (diffMinutes > 0) { - return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + if (style === 'short') { + if (diffSeconds < 1) return 'now'; + if (diffSeconds < 60) return `${diffSeconds}s`; + if (diffMinutes < 60) return `${diffMinutes}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 30) return `${diffDays}d`; + const diffMonths = Math.floor(diffDays / 30); + return diffMonths < 12 + ? `${diffMonths}mo` + : `${Math.floor(diffMonths / 12)}y`; } else { - return 'Just now'; + if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else { + return 'Just now'; + } } }; +export interface GetSessionOptions { + /** Whether to load full message content (needed for search) */ + includeFullContent?: boolean; +} + /** * Loads all session files (including corrupted ones) from the chats directory. * @returns Array of session file entries, with sessionInfo null for corrupted files @@ -110,6 +187,7 @@ export const formatRelativeTime = (timestamp: string): string => { export const getAllSessionFiles = async ( chatsDir: string, currentSessionId?: string, + options: GetSessionOptions = {}, ): Promise => { try { const files = await fs.readdir(chatsDir); @@ -142,15 +220,37 @@ export const getAllSessionFiles = async ( ? file.includes(currentSessionId.slice(0, 8)) : false; + let fullContent: string | undefined; + let messages: + | Array<{ role: 'user' | 'assistant'; content: string }> + | undefined; + + if (options.includeFullContent) { + fullContent = content.messages + .map((msg) => partListUnionToString(msg.content)) + .join(' '); + messages = content.messages.map((msg) => ({ + role: + msg.type === 'user' + ? ('user' as const) + : ('assistant' as const), + content: partListUnionToString(msg.content), + })); + } + const sessionInfo: SessionInfo = { id: content.sessionId, file: file.replace('.json', ''), fileName: file, startTime: content.startTime, lastUpdated: content.lastUpdated, + messageCount: content.messages.length, + displayName: firstUserMessage, firstUserMessage, isCurrentSession, index: 0, // Will be set after sorting valid sessions + fullContent, + messages, }; return { fileName: file, sessionInfo }; @@ -179,8 +279,13 @@ export const getAllSessionFiles = async ( export const getSessionFiles = async ( chatsDir: string, currentSessionId?: string, + options: GetSessionOptions = {}, ): Promise => { - const allFiles = await getAllSessionFiles(chatsDir, currentSessionId); + const allFiles = await getAllSessionFiles( + chatsDir, + currentSessionId, + options, + ); // Filter out corrupted files and extract SessionInfo const validSessions = allFiles @@ -190,17 +295,31 @@ export const getSessionFiles = async ( ) .map((entry) => entry.sessionInfo); + // Deduplicate sessions by ID + const uniqueSessionsMap = new Map(); + for (const session of validSessions) { + // If duplicate exists, keep the one with the later lastUpdated timestamp + if ( + !uniqueSessionsMap.has(session.id) || + new Date(session.lastUpdated).getTime() > + new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime() + ) { + uniqueSessionsMap.set(session.id, session); + } + } + const uniqueSessions = Array.from(uniqueSessionsMap.values()); + // Sort by startTime (oldest first) for stable session numbering - validSessions.sort( + uniqueSessions.sort( (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), ); // Set the correct 1-based indexes after sorting - validSessions.forEach((session, index) => { + uniqueSessions.forEach((session, index) => { session.index = index + 1; }); - return validSessions; + return uniqueSessions; }; /** @@ -318,9 +437,12 @@ export class SessionSelector { await fs.readFile(sessionPath, 'utf8'), ); + const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; + return { sessionPath, sessionData, + displayInfo, }; } catch (error) { throw new Error( diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 12e43682e2b..6be54e463c6 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -85,6 +85,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-18T12-00-00-session-1.json', startTime: twoDaysAgo.toISOString(), lastUpdated: twoDaysAgo.toISOString(), + messageCount: 5, + displayName: 'First user message', firstUserMessage: 'First user message', isCurrentSession: false, index: 1, @@ -95,6 +97,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-20T11-00-00-session-2.json', startTime: oneHourAgo.toISOString(), lastUpdated: oneHourAgo.toISOString(), + messageCount: 10, + displayName: 'Second user message', firstUserMessage: 'Second user message', isCurrentSession: false, index: 2, @@ -105,6 +109,8 @@ describe('listSessions', () => { fileName: 'session-2025-01-20T12-00-00-current-s.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 3, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 3, @@ -163,6 +169,8 @@ describe('listSessions', () => { fileName: 'session-2.json', startTime: session2Time.toISOString(), // Middle lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, @@ -173,6 +181,8 @@ describe('listSessions', () => { fileName: 'session-1.json', startTime: session1Time.toISOString(), // Oldest lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, @@ -183,6 +193,8 @@ describe('listSessions', () => { fileName: 'session-3.json', startTime: session3Time.toISOString(), // Newest lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, @@ -219,6 +231,8 @@ describe('listSessions', () => { fileName: 'session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test message', firstUserMessage: 'Test message', isCurrentSession: false, index: 1, @@ -252,6 +266,8 @@ describe('listSessions', () => { fileName: 'session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Only session', firstUserMessage: 'Only session', isCurrentSession: true, index: 1, @@ -348,6 +364,8 @@ describe('deleteSession', () => { fileName: 'session-file-123.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -381,6 +399,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: oneHourAgo.toISOString(), lastUpdated: oneHourAgo.toISOString(), + messageCount: 5, + displayName: 'First session', firstUserMessage: 'First session', isCurrentSession: false, index: 1, @@ -391,6 +411,8 @@ describe('deleteSession', () => { fileName: 'session-file-2.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 10, + displayName: 'Second session', firstUserMessage: 'Second session', isCurrentSession: false, index: 2, @@ -421,6 +443,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -449,6 +473,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -477,6 +503,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -505,6 +533,8 @@ describe('deleteSession', () => { fileName: 'current-session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -533,6 +563,8 @@ describe('deleteSession', () => { fileName: 'current-session-file.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', firstUserMessage: 'Current session', isCurrentSession: true, index: 1, @@ -561,6 +593,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -592,6 +626,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: now.toISOString(), lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', firstUserMessage: 'Test session', isCurrentSession: false, index: 1, @@ -626,6 +662,8 @@ describe('deleteSession', () => { fileName: 'session-file-3.json', startTime: session3Time.toISOString(), // Newest lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, @@ -636,6 +674,8 @@ describe('deleteSession', () => { fileName: 'session-file-1.json', startTime: session1Time.toISOString(), // Oldest lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, @@ -646,6 +686,8 @@ describe('deleteSession', () => { fileName: 'session-file-2.json', startTime: session2Time.toISOString(), // Middle lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index bb583dc72f6..9207069af0f 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -30,8 +30,12 @@ export async function listSessions(config: Config): Promise { .forEach((session, index) => { const current = session.isCurrentSession ? ', current' : ''; const time = formatRelativeTime(session.lastUpdated); + const title = + session.firstUserMessage.length > 100 + ? session.firstUserMessage.slice(0, 97) + '...' + : session.firstUserMessage; console.log( - ` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`, + ` ${index + 1}. ${title} (${time}${current}) [${session.id}]`, ); }); } From d91ca3841d4656ce48e6c018c1d48fb2d49c3d11 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 21 Nov 2025 08:31:47 -0800 Subject: [PATCH 14/32] Fix multiple bugs with auth flow including using the implemented but unused restart support. (#13565) --- packages/cli/index.ts | 15 +- packages/cli/src/gemini.tsx | 33 +-- packages/cli/src/ui/AppContainer.test.tsx | 14 +- packages/cli/src/ui/AppContainer.tsx | 24 ++- packages/cli/src/ui/auth/AuthDialog.test.tsx | 10 +- packages/cli/src/ui/auth/AuthDialog.tsx | 42 ++-- .../cli/src/ui/components/DialogManager.tsx | 7 +- .../cli/src/ui/utils/kittyProtocolDetector.ts | 11 +- packages/cli/src/ui/utils/mouse.ts | 14 +- packages/core/src/code_assist/oauth2.test.ts | 24 ++- packages/core/src/code_assist/oauth2.ts | 197 ++++++++++++------ packages/core/src/index.ts | 1 + packages/core/src/utils/terminal.ts | 50 +++++ 13 files changed, 306 insertions(+), 136 deletions(-) create mode 100644 packages/core/src/utils/terminal.ts diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 8f9a0663277..894b5c1fd46 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,23 +8,26 @@ import './src/gemini.js'; import { main } from './src/gemini.js'; -import { debugLogger, FatalError } from '@google/gemini-cli-core'; +import { FatalError, writeToStderr } from '@google/gemini-cli-core'; +import { runExitCleanup } from './src/utils/cleanup.js'; // --- Global Entry Point --- -main().catch((error) => { +main().catch(async (error) => { + await runExitCleanup(); + if (error instanceof FatalError) { let errorMessage = error.message; if (!process.env['NO_COLOR']) { errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; } - debugLogger.error(errorMessage); + writeToStderr(errorMessage + '\n'); process.exit(error.exitCode); } - debugLogger.error('An unexpected critical error occurred:'); + writeToStderr('An unexpected critical error occurred:'); if (error instanceof Error) { - debugLogger.error(error.stack); + writeToStderr(error.stack + '\n'); } else { - debugLogger.error(String(error)); + writeToStderr(String(error) + '\n'); } process.exit(1); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 15617fa9a84..b4983e94018 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -33,13 +33,11 @@ import { runExitCleanup, } from './utils/cleanup.js'; import { getCliVersion } from './utils/version.js'; -import type { - Config, - ResumedSessionData, - OutputPayload, - ConsoleLogPayload, -} from '@google/gemini-cli-core'; import { + type Config, + type ResumedSessionData, + type OutputPayload, + type ConsoleLogPayload, sessionId, logUserPrompt, AuthType, @@ -53,6 +51,11 @@ import { patchStdio, writeToStdout, writeToStderr, + disableMouseEvents, + enableMouseEvents, + enterAlternateScreen, + disableLineWrapping, + shouldEnterAlternateScreen, } from '@google/gemini-cli-core'; import { initializeApp, @@ -85,9 +88,7 @@ import { deleteSession, listSessions } from './utils/sessions.js'; import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; -import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; -import ansiEscapes from 'ansi-escapes'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { profiler } from './ui/components/DebugProfiler.js'; @@ -176,8 +177,10 @@ export async function startInteractiveUI( // as there is no benefit of alternate buffer mode when using a screen reader // and the Ink alternate buffer mode requires line wrapping harmful to // screen readers. - const useAlternateBuffer = - isAlternateBufferEnabled(settings) && !config.getScreenReader(); + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ); const mouseEventsEnabled = useAlternateBuffer; if (mouseEventsEnabled) { enableMouseEvents(); @@ -481,8 +484,14 @@ export async function main() { // input showing up in the output. process.stdin.setRawMode(true); - if (isAlternateBufferEnabled(settings)) { - writeToStdout(ansiEscapes.enterAlternativeScreen); + if ( + shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ) + ) { + enterAlternateScreen(); + disableLineWrapping(); // Ink will cleanup so there is no need for us to manually cleanup. } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 43a0de7c92f..24f3b128851 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -69,6 +69,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { stdout: process.stdout, stderr: process.stderr, })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), }; }); import type { LoadedSettings } from '../config/settings.js'; @@ -137,10 +139,6 @@ vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); vi.mock('./utils/ConsolePatcher.js'); vi.mock('../utils/cleanup.js'); -vi.mock('./utils/mouse.js', () => ({ - enableMouseEvents: vi.fn(), - disableMouseEvents: vi.fn(), -})); import { useHistory } from './hooks/useHistoryManager.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; @@ -165,9 +163,13 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; -import { ShellExecutionService, writeToStdout } from '@google/gemini-cli-core'; +import { + ShellExecutionService, + writeToStdout, + enableMouseEvents, + disableMouseEvents, +} from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; -import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js'; describe('AppContainer State Management', () => { let mockConfig: Config; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1ae809a96e1..ffe7aa51d0a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -52,6 +52,12 @@ import { refreshServerHierarchicalMemory, type ModelChangedPayload, type MemoryChangedPayload, + writeToStdout, + disableMouseEvents, + enterAlternateScreen, + enableMouseEvents, + disableLineWrapping, + shouldEnterAlternateScreen, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -92,6 +98,7 @@ import { appEvents, AppEvent } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; +import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; @@ -106,11 +113,9 @@ import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; -import { writeToStdout } from '@google/gemini-cli-core'; const WARNING_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -372,16 +377,19 @@ export const AppContainer = (props: AppContainerProps) => { setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, isAlternateBuffer, stdout]); const handleEditorClose = useCallback(() => { - if (isAlternateBuffer) { + if ( + shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader()) + ) { // The editor may have exited alternate buffer mode so we need to // enter it again to be safe. - writeToStdout(ansiEscapes.enterAlternativeScreen); + enterAlternateScreen(); enableMouseEvents(); + disableLineWrapping(); app.rerender(); } enableSupportedProtocol(); refreshStatic(); - }, [refreshStatic, isAlternateBuffer, app]); + }, [refreshStatic, isAlternateBuffer, app, config]); useEffect(() => { coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); @@ -458,12 +466,12 @@ export const AppContainer = (props: AppContainerProps) => { config.isBrowserLaunchSuppressed() ) { await runExitCleanup(); - debugLogger.log(` + writeToStdout(` ---------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. +Logging in with Google... Restarting Gemini CLI to continue. ---------------------------------------------------------------- `); - process.exit(0); + process.exit(RELAUNCH_EXIT_CODE); } } setAuthState(AuthState.Authenticated); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 2b4bcca1285..ca9e235ed57 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -25,6 +25,7 @@ import { validateAuthMethodWithSettings } from './useAuth.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { clearCachedCredentialFile } from '@google/gemini-cli-core'; import { Text } from 'ink'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; // Mocks vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -229,6 +230,7 @@ describe('AuthDialog', () => { }); it('exits process for Login with Google when browser is suppressed', async () => { + vi.useFakeTimers(); const exitSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -241,14 +243,14 @@ describe('AuthDialog', () => { mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + await vi.runAllTimersAsync(); + expect(mockedRunExitCleanup).toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('Please restart Gemini CLI'), - ); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); exitSpy.mockRestore(); logSpy.mockRestore(); + vi.useRealTimers(); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index ecd51f6ed49..da5b6d7dffb 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; @@ -17,13 +17,13 @@ import { SettingScope } from '../../config/settings.js'; import { AuthType, clearCachedCredentialFile, - debugLogger, type Config, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { AuthState } from '../types.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { validateAuthMethodWithSettings } from './useAuth.js'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; interface AuthDialogProps { config: Config; @@ -40,6 +40,7 @@ export function AuthDialog({ authError, onAuthError, }: AuthDialogProps): React.JSX.Element { + const [exiting, setExiting] = useState(false); let items = [ { label: 'Login with Google', @@ -111,6 +112,9 @@ export function AuthDialog({ const onSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { + if (exiting) { + return; + } if (authType) { await clearCachedCredentialFile(); @@ -119,15 +123,12 @@ export function AuthDialog({ authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { - runExitCleanup(); - debugLogger.log( - ` ----------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. ----------------------------------------------------------------- - `, - ); - process.exit(0); + setExiting(true); + setTimeout(async () => { + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }, 100); + return; } } if (authType === AuthType.USE_GEMINI) { @@ -136,7 +137,7 @@ Logging in with Google... Please restart Gemini CLI to continue. } setAuthState(AuthState.Unauthenticated); }, - [settings, config, setAuthState], + [settings, config, setAuthState, exiting], ); const handleAuthSelect = (authMethod: AuthType) => { @@ -169,6 +170,23 @@ Logging in with Google... Please restart Gemini CLI to continue. { isActive: true }, ); + if (exiting) { + return ( + + + Logging in with Google... Restarting Gemini CLI to continue. + + + ); + } + return ( uiActions.closeSettingsDialog()} - onRestartRequest={() => process.exit(0)} + onRestartRequest={async () => { + await runExitCleanup(); + process.exit(RELAUNCH_EXIT_CODE); + }} availableTerminalHeight={terminalHeight - staticExtraHeight} config={config} /> diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.ts index c15e5a052d6..a590eedef4a 100644 --- a/packages/cli/src/ui/utils/kittyProtocolDetector.ts +++ b/packages/cli/src/ui/utils/kittyProtocolDetector.ts @@ -97,6 +97,11 @@ export async function detectAndEnableKittyProtocol(): Promise { }); } +import { + enableKittyKeyboardProtocol, + disableKittyKeyboardProtocol, +} from '@google/gemini-cli-core'; + export function isKittyProtocolEnabled(): boolean { return kittyEnabled; } @@ -104,8 +109,7 @@ export function isKittyProtocolEnabled(): boolean { function disableAllProtocols() { try { if (kittyEnabled) { - // use writeSync to avoid race conditions - fs.writeSync(process.stdout.fd, '\x1b[1u'); + enableKittyKeyboardProtocol(); kittyEnabled = true; } } catch { diff --git a/packages/cli/src/ui/utils/mouse.ts b/packages/cli/src/ui/utils/mouse.ts index e5f2cd82419..3485e5a78fc 100644 --- a/packages/cli/src/ui/utils/mouse.ts +++ b/packages/cli/src/ui/utils/mouse.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { writeToStdout } from '@google/gemini-cli-core'; +import { enableMouseEvents, disableMouseEvents } from '@google/gemini-cli-core'; import { SGR_MOUSE_REGEX, X11_MOUSE_REGEX, @@ -230,14 +230,4 @@ export function isIncompleteMouseSequence(buffer: string): boolean { return true; } -export function enableMouseEvents() { - // Enable mouse tracking with SGR format - // ?1002h = button event tracking (clicks + drags + scroll wheel) - // ?1006h = SGR extended mouse mode (better coordinate handling) - writeToStdout('\u001b[?1002h\u001b[?1006h'); -} - -export function disableMouseEvents() { - // Disable mouse tracking with SGR format - writeToStdout('\u001b[?1006l\u001b[?1002l'); -} +export { enableMouseEvents, disableMouseEvents }; diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 697a1a52d93..4a6758105f3 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -27,6 +27,7 @@ import readline from 'node:readline'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { GEMINI_DIR } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { writeToStdout } from '../utils/stdio.js'; vi.mock('os', async (importOriginal) => { const os = await importOriginal(); @@ -44,6 +45,19 @@ vi.mock('node:readline'); vi.mock('../utils/browser.js', () => ({ shouldAttemptBrowserLaunch: () => true, })); +vi.mock('../utils/stdio.js', () => ({ + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + createInkStdio: vi.fn(() => ({ + stdout: process.stdout, + stderr: process.stderr, + })), + enterAlternateScreen: vi.fn(), + exitAlternateScreen: vi.fn(), + enableLineWrapping: vi.fn(), + disableMouseEvents: vi.fn(), + disableKittyKeyboardProtocol: vi.fn(), +})); vi.mock('./oauth-credential-storage.js', () => ({ OAuthCredentialStorage: { @@ -238,13 +252,10 @@ describe('oauth2', () => { const mockReadline = { question: vi.fn((_query, callback) => callback(mockCode)), close: vi.fn(), + on: vi.fn(), }; (readline.createInterface as Mock).mockReturnValue(mockReadline); - const consoleLogSpy = vi - .spyOn(debugLogger, 'log') - .mockImplementation(() => {}); - const client = await getOauthClient( AuthType.LOGIN_WITH_GOOGLE, mockConfigWithNoBrowser, @@ -255,7 +266,7 @@ describe('oauth2', () => { // Verify the auth flow expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled(); expect(mockGenerateAuthUrl).toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith( + expect(vi.mocked(writeToStdout)).toHaveBeenCalledWith( expect.stringContaining(mockAuthUrl), ); expect(mockReadline.question).toHaveBeenCalledWith( @@ -268,8 +279,6 @@ describe('oauth2', () => { redirect_uri: 'https://codeassist.google.com/authcode', }); expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); - - consoleLogSpy.mockRestore(); }); describe('in Cloud Shell', () => { @@ -932,6 +941,7 @@ describe('oauth2', () => { const mockReadline = { question: vi.fn((_query, callback) => callback('invalid-code')), close: vi.fn(), + on: vi.fn(), }; (readline.createInterface as Mock).mockReturnValue(mockReadline); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 4382195adb2..b0a4cb4baa0 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -19,7 +19,11 @@ import open from 'open'; import path from 'node:path'; import { promises as fs } from 'node:fs'; import type { Config } from '../config/config.js'; -import { getErrorMessage, FatalAuthenticationError } from '../utils/errors.js'; +import { + getErrorMessage, + FatalAuthenticationError, + FatalCancellationError, +} from '../utils/errors.js'; import { UserAccountManager } from '../utils/userAccountManager.js'; import { AuthType } from '../core/contentGenerator.js'; import readline from 'node:readline'; @@ -27,6 +31,19 @@ import { Storage } from '../config/storage.js'; import { OAuthCredentialStorage } from './oauth-credential-storage.js'; import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + writeToStdout, + createInkStdio, + writeToStderr, +} from '../utils/stdio.js'; +import { + enableLineWrapping, + disableMouseEvents, + disableKittyKeyboardProtocol, + enterAlternateScreen, + exitAlternateScreen, +} from '../utils/terminal.js'; +import { coreEvents, CoreEvent } from '../utils/events.js'; const userAccountManager = new UserAccountManager(); @@ -185,16 +202,34 @@ async function initOauthClient( if (config.isBrowserLaunchSuppressed()) { let success = false; const maxRetries = 2; - for (let i = 0; !success && i < maxRetries; i++) { - success = await authWithUserCode(client); - if (!success) { - debugLogger.error( - '\nFailed to authenticate with user code.', - i === maxRetries - 1 ? '' : 'Retrying...\n', - ); + // Enter alternate buffer + enterAlternateScreen(); + // Clear screen and move cursor to top-left. + writeToStdout('\u001B[2J\u001B[H'); + disableMouseEvents(); + disableKittyKeyboardProtocol(); + enableLineWrapping(); + + try { + for (let i = 0; !success && i < maxRetries; i++) { + success = await authWithUserCode(client); + if (!success) { + writeToStderr( + '\nFailed to authenticate with user code.' + + (i === maxRetries - 1 ? '' : ' Retrying...\n'), + ); + } } + } finally { + exitAlternateScreen(); + // If this was triggered from an active Gemini CLI TUI this event ensures + // the TUI will re-initialize the terminal state just like it will when + // another editor like VIM may have modified the buffer of settings. + coreEvents.emit(CoreEvent.ExternalEditorClosed); } + if (!success) { + writeToStderr('Failed to authenticate with user code.\n'); throw new FatalAuthenticationError( 'Failed to authenticate with user code.', ); @@ -202,11 +237,13 @@ async function initOauthClient( } else { const webLogin = await authWithWeb(client); - debugLogger.log( - `\n\nCode Assist login required.\n` + + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: + `\n\nCode Assist login required.\n` + `Attempting to open authentication page in your browser.\n` + - `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, - ); + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, + }); try { // Attempt to open the authentication URL in the default browser. // We do not use the `wait` option here because the main script's execution @@ -218,23 +255,28 @@ async function initOauthClient( // in a minimal Docker container), it will emit an unhandled 'error' event, // causing the entire Node.js process to crash. childProcess.on('error', (error) => { - debugLogger.error( - `Failed to open browser with error:`, - getErrorMessage(error), - `\nPlease try running again with NO_BROWSER=true set.`, - ); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'error', + message: + `Failed to open browser with error: ${getErrorMessage(error)}\n` + + `Please try running again with NO_BROWSER=true set.`, + }); }); } catch (err) { - debugLogger.error( - `Failed to open browser with error:`, - getErrorMessage(err), - `\nPlease try running again with NO_BROWSER=true set.`, - ); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'error', + message: + `Failed to open browser with error: ${getErrorMessage(err)}\n` + + `Please try running again with NO_BROWSER=true set.`, + }); throw new FatalAuthenticationError( `Failed to open browser: ${getErrorMessage(err)}`, ); } - debugLogger.log('Waiting for authentication...'); + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: 'Waiting for authentication...\n', + }); // Add timeout to prevent infinite waiting when browser tab gets stuck const authTimeout = 5 * 60 * 1000; // 5 minutes timeout @@ -250,6 +292,11 @@ async function initOauthClient( }); await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + + coreEvents.emit(CoreEvent.UserFeedback, { + severity: 'info', + message: 'Authentication succeeded\n', + }); } return client; @@ -266,55 +313,77 @@ export async function getOauthClient( } async function authWithUserCode(client: OAuth2Client): Promise { - const redirectUri = 'https://codeassist.google.com/authcode'; - const codeVerifier = await client.generateCodeVerifierAsync(); - const state = crypto.randomBytes(32).toString('hex'); - const authUrl: string = client.generateAuthUrl({ - redirect_uri: redirectUri, - access_type: 'offline', - scope: OAUTH_SCOPE, - code_challenge_method: CodeChallengeMethod.S256, - code_challenge: codeVerifier.codeChallenge, - state, - }); - debugLogger.log( - 'Please visit the following URL to authorize the application:', - ); - debugLogger.log(''); - debugLogger.log(authUrl); - debugLogger.log(''); - - const code = await new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.question('Enter the authorization code: ', (code) => { - rl.close(); - resolve(code.trim()); + try { + const redirectUri = 'https://codeassist.google.com/authcode'; + const codeVerifier = await client.generateCodeVerifierAsync(); + const state = crypto.randomBytes(32).toString('hex'); + const authUrl: string = client.generateAuthUrl({ + redirect_uri: redirectUri, + access_type: 'offline', + scope: OAUTH_SCOPE, + code_challenge_method: CodeChallengeMethod.S256, + code_challenge: codeVerifier.codeChallenge, + state, }); - }); + writeToStdout( + 'Please visit the following URL to authorize the application:\n\n' + + authUrl + + '\n\n', + ); - if (!code) { - debugLogger.error('Authorization code is required.'); - return false; - } + const code = await new Promise((resolve, _) => { + const rl = readline.createInterface({ + input: process.stdin, + output: createInkStdio().stdout, + terminal: true, + }); - try { - const { tokens } = await client.getToken({ - code, - codeVerifier: codeVerifier.codeVerifier, - redirect_uri: redirectUri, + rl.question('Enter the authorization code: ', (code) => { + rl.close(); + resolve(code.trim()); + }); }); - client.setCredentials(tokens); - } catch (error) { + + if (!code) { + writeToStderr('Authorization code is required.\n'); + debugLogger.error('Authorization code is required.'); + return false; + } + + try { + const { tokens } = await client.getToken({ + code, + codeVerifier: codeVerifier.codeVerifier, + redirect_uri: redirectUri, + }); + client.setCredentials(tokens); + } catch (error) { + writeToStderr( + 'Failed to authenticate with authorization code:' + + getErrorMessage(error) + + '\n', + ); + + debugLogger.error( + 'Failed to authenticate with authorization code:', + getErrorMessage(error), + ); + return false; + } + return true; + } catch (err) { + if (err instanceof FatalCancellationError) { + throw err; + } + writeToStderr( + 'Failed to authenticate with user code:' + getErrorMessage(err) + '\n', + ); debugLogger.error( - 'Failed to authenticate with authorization code:', - getErrorMessage(error), + 'Failed to authenticate with user code:', + getErrorMessage(err), ); return false; } - return true; } async function authWithWeb(client: OAuth2Client): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad227b8db7a..46407299223 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,3 +147,4 @@ export * from './hooks/types.js'; // Export stdio utils export * from './utils/stdio.js'; +export * from './utils/terminal.js'; diff --git a/packages/core/src/utils/terminal.ts b/packages/core/src/utils/terminal.ts new file mode 100644 index 00000000000..008919ea491 --- /dev/null +++ b/packages/core/src/utils/terminal.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { writeToStdout } from './stdio.js'; + +export function enableMouseEvents() { + // Enable mouse tracking with SGR format + // ?1002h = button event tracking (clicks + drags + scroll wheel) + // ?1006h = SGR extended mouse mode (better coordinate handling) + writeToStdout('\u001b[?1002h\u001b[?1006h'); +} + +export function disableMouseEvents() { + // Disable mouse tracking with SGR format + writeToStdout('\u001b[?1006l\u001b[?1002l'); +} + +export function enableKittyKeyboardProtocol() { + writeToStdout('\x1b[>1u'); +} + +export function disableKittyKeyboardProtocol() { + writeToStdout('\x1b[ Date: Fri, 21 Nov 2025 11:54:16 -0500 Subject: [PATCH 15/32] feat(core): add modelAvailabilityService for managing and tracking model health (#13426) --- .../modelAvailabilityService.test.ts | 165 ++++++++++++++++++ .../availability/modelAvailabilityService.ts | 131 ++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 packages/core/src/availability/modelAvailabilityService.test.ts create mode 100644 packages/core/src/availability/modelAvailabilityService.ts diff --git a/packages/core/src/availability/modelAvailabilityService.test.ts b/packages/core/src/availability/modelAvailabilityService.test.ts new file mode 100644 index 00000000000..bddb3b49469 --- /dev/null +++ b/packages/core/src/availability/modelAvailabilityService.test.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ModelAvailabilityService } from './modelAvailabilityService.js'; + +describe('ModelAvailabilityService', () => { + let service: ModelAvailabilityService; + const model = 'test-model'; + + beforeEach(() => { + service = new ModelAvailabilityService(); + vi.useRealTimers(); + }); + + it('returns available snapshot when no state recorded', () => { + expect(service.snapshot(model)).toEqual({ available: true }); + }); + + it('tracks retry-once-per-turn failures', () => { + service.markRetryOncePerTurn(model); + expect(service.snapshot(model)).toEqual({ available: true }); + + service.consumeStickyAttempt(model); + expect(service.snapshot(model)).toEqual({ + available: false, + reason: 'retry_once_per_turn', + }); + + service.resetTurn(); + expect(service.snapshot(model)).toEqual({ available: true }); + }); + + it('tracks terminal failures', () => { + service.markTerminal(model, 'quota'); + expect(service.snapshot(model)).toEqual({ + available: false, + reason: 'quota', + }); + }); + + it('does not override terminal failure with sticky failure', () => { + service.markTerminal(model, 'quota'); + service.markRetryOncePerTurn(model); + expect(service.snapshot(model)).toEqual({ + available: false, + reason: 'quota', + }); + }); + + it('selects models respecting terminal and sticky states', () => { + const stickyModel = 'stick-model'; + const healthyModel = 'healthy-model'; + + service.markTerminal(model, 'capacity'); + service.markRetryOncePerTurn(stickyModel); + + const first = service.selectFirstAvailable([ + model, + stickyModel, + healthyModel, + ]); + expect(first).toEqual({ + selected: stickyModel, + attempts: 1, + skipped: [ + { + model, + reason: 'capacity', + }, + ], + }); + + service.consumeStickyAttempt(stickyModel); + const second = service.selectFirstAvailable([ + model, + stickyModel, + healthyModel, + ]); + expect(second).toEqual({ + selected: healthyModel, + skipped: [ + { + model, + reason: 'capacity', + }, + { + model: stickyModel, + reason: 'retry_once_per_turn', + }, + ], + }); + + service.resetTurn(); + const third = service.selectFirstAvailable([ + model, + stickyModel, + healthyModel, + ]); + expect(third).toEqual({ + selected: stickyModel, + attempts: 1, + skipped: [ + { + model, + reason: 'capacity', + }, + ], + }); + }); + + it('preserves consumed state when marking retry-once-per-turn again', () => { + service.markRetryOncePerTurn(model); + service.consumeStickyAttempt(model); + + // It is currently consumed + expect(service.snapshot(model).available).toBe(false); + + // Marking it again should not reset the consumed flag + service.markRetryOncePerTurn(model); + expect(service.snapshot(model).available).toBe(false); + }); + + it('clears consumed state when marked healthy', () => { + service.markRetryOncePerTurn(model); + service.consumeStickyAttempt(model); + expect(service.snapshot(model).available).toBe(false); + + service.markHealthy(model); + expect(service.snapshot(model).available).toBe(true); + + // If we mark it sticky again, it should be fresh (not consumed) + service.markRetryOncePerTurn(model); + expect(service.snapshot(model).available).toBe(true); + }); + + it('resetTurn resets consumed state for multiple sticky models', () => { + const model2 = 'model-2'; + service.markRetryOncePerTurn(model); + service.markRetryOncePerTurn(model2); + + service.consumeStickyAttempt(model); + service.consumeStickyAttempt(model2); + + expect(service.snapshot(model).available).toBe(false); + expect(service.snapshot(model2).available).toBe(false); + + service.resetTurn(); + + expect(service.snapshot(model).available).toBe(true); + expect(service.snapshot(model2).available).toBe(true); + }); + + it('resetTurn does not affect terminal models', () => { + service.markTerminal(model, 'quota'); + service.resetTurn(); + expect(service.snapshot(model)).toEqual({ + available: false, + reason: 'quota', + }); + }); +}); diff --git a/packages/core/src/availability/modelAvailabilityService.ts b/packages/core/src/availability/modelAvailabilityService.ts new file mode 100644 index 00000000000..0a08c28655c --- /dev/null +++ b/packages/core/src/availability/modelAvailabilityService.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ModelId = string; + +export type TerminalUnavailabilityReason = 'quota' | 'capacity'; +export type TurnUnavailabilityReason = 'retry_once_per_turn'; + +export type UnavailabilityReason = + | TerminalUnavailabilityReason + | TurnUnavailabilityReason + | 'unknown'; + +type HealthState = + | { status: 'terminal'; reason: TerminalUnavailabilityReason } + | { + status: 'sticky_retry'; + reason: TurnUnavailabilityReason; + consumed: boolean; + }; + +export interface ModelAvailabilitySnapshot { + available: boolean; + reason?: UnavailabilityReason; +} + +export interface ModelSelectionResult { + selected: ModelId | null; + attempts?: number; + skipped: Array<{ + model: ModelId; + reason: UnavailabilityReason; + }>; +} + +export class ModelAvailabilityService { + private readonly health = new Map(); + + markTerminal(model: ModelId, reason: TerminalUnavailabilityReason) { + this.setState(model, { + status: 'terminal', + reason, + }); + } + + markHealthy(model: ModelId) { + this.clearState(model); + } + + markRetryOncePerTurn(model: ModelId) { + const currentState = this.health.get(model); + // Do not override a terminal failure with a transient one. + if (currentState?.status === 'terminal') { + return; + } + + // Only reset consumption if we are not already in the sticky_retry state. + // This prevents infinite loops if the model fails repeatedly in the same turn. + let consumed = false; + if (currentState?.status === 'sticky_retry') { + consumed = currentState.consumed; + } + + this.setState(model, { + status: 'sticky_retry', + reason: 'retry_once_per_turn', + consumed, + }); + } + + consumeStickyAttempt(model: ModelId) { + const state = this.health.get(model); + if (state?.status === 'sticky_retry') { + this.setState(model, { ...state, consumed: true }); + } + } + + snapshot(model: ModelId): ModelAvailabilitySnapshot { + const state = this.health.get(model); + + if (!state) { + return { available: true }; + } + + if (state.status === 'terminal') { + return { available: false, reason: state.reason }; + } + + if (state.status === 'sticky_retry' && state.consumed) { + return { available: false, reason: state.reason }; + } + + return { available: true }; + } + + selectFirstAvailable(models: ModelId[]): ModelSelectionResult { + const skipped: ModelSelectionResult['skipped'] = []; + + for (const model of models) { + const snapshot = this.snapshot(model); + if (snapshot.available) { + const state = this.health.get(model); + // A sticky model is being attempted, so note that. + const attempts = state?.status === 'sticky_retry' ? 1 : undefined; + return { selected: model, skipped, attempts }; + } else { + skipped.push({ model, reason: snapshot.reason ?? 'unknown' }); + } + } + return { selected: null, skipped }; + } + + resetTurn() { + for (const [model, state] of this.health.entries()) { + if (state.status === 'sticky_retry') { + this.setState(model, { ...state, consumed: false }); + } + } + } + + private setState(model: ModelId, nextState: HealthState) { + this.health.set(model, nextState); + } + + private clearState(model: ModelId) { + this.health.delete(model); + } +} From 92226c0e3d84038b2b5d378447808a33f101601c Mon Sep 17 00:00:00 2001 From: noahacgn Date: Sat, 22 Nov 2025 00:59:45 +0800 Subject: [PATCH 16/32] docs: fix grammar typo "a MCP" to "an MCP" (#13595) --- docs/cli/tutorials.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/tutorials.md b/docs/cli/tutorials.md index ed907e21d59..dff8918b5ef 100644 --- a/docs/cli/tutorials.md +++ b/docs/cli/tutorials.md @@ -8,7 +8,7 @@ This page contains tutorials for interacting with Gemini CLI. > and understand the tools it provides. Your use of third-party servers is at > your own risk. -This tutorial demonstrates how to set up a MCP server, using the +This tutorial demonstrates how to set up an MCP server, using the [GitHub MCP server](https://github.com/github/github-mcp-server) as an example. The GitHub MCP server provides tools for interacting with GitHub repositories, such as creating issues and commenting on pull requests. From d53f7e7ed047bc867f4eb316917c6ab75aeb8df0 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 21 Nov 2025 12:19:34 -0500 Subject: [PATCH 17/32] feat: custom loading phrase when interactive shell requires input (#12535) --- packages/cli/src/ui/AppContainer.tsx | 3 + .../src/ui/components/LoadingIndicator.tsx | 8 +- .../components/messages/ShellToolMessage.tsx | 8 +- .../ui/components/messages/ToolMessage.tsx | 146 +++++++++++++----- packages/cli/src/ui/constants.ts | 2 + .../cli/src/ui/hooks/shellCommandProcessor.ts | 5 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 32 ++-- .../cli/src/ui/hooks/useInactivityTimer.ts | 39 +++++ .../src/ui/hooks/useLoadingIndicator.test.tsx | 54 ++++++- .../cli/src/ui/hooks/useLoadingIndicator.ts | 4 + .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 109 ++++++++++++- packages/cli/src/ui/hooks/usePhraseCycler.ts | 105 ++++++++----- .../cli/src/ui/hooks/useReactToolScheduler.ts | 4 + 13 files changed, 416 insertions(+), 103 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useInactivityTimer.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ffe7aa51d0a..2baf93f4d5c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -713,6 +713,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, + lastOutputTime, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1112,6 +1113,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( streamingState, settings.merged.ui?.customWittyPhrases, + !!activePtyId && !embeddedShellFocused, + lastOutputTime, ); const handleGlobalKeypress = useCallback( diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index bd0b7e81f9b..4917946d3af 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { formatDuration } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; @@ -36,7 +37,12 @@ export const LoadingIndicator: React.FC = ({ return null; } - const primaryText = thought?.subject || currentLoadingPhrase; + // Prioritize the interactive shell waiting phrase over the thought subject + // because it conveys an actionable state for the user (waiting for input). + const primaryText = + currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE + ? currentLoadingPhrase + : thought?.subject || currentLoadingPhrase; const cancelAndTimerContent = streamingState !== StreamingState.WaitingForConfirmation diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 12d322c4fa4..a9197f15c56 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -9,7 +9,11 @@ import { Box, Text, type DOMElement } from 'ink'; import { ToolCallStatus } from '../../types.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { StickyHeader } from '../StickyHeader.js'; -import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; +import { + SHELL_COMMAND_NAME, + SHELL_NAME, + SHELL_FOCUS_HINT_DELAY_MS, +} from '../../constants.js'; import { theme } from '../../semantic-colors.js'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { useUIActions } from '../../contexts/UIActionsContext.js'; @@ -104,7 +108,7 @@ export const ShellToolMessage: React.FC = ({ const timer = setTimeout(() => { setShowFocusHint(true); - }, 5000); + }, SHELL_FOCUS_HINT_DELAY_MS); return () => clearTimeout(timer); }, [lastUpdateTime]); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index d2d41d77664..86ad6968d86 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -5,7 +5,8 @@ */ import type React from 'react'; -import { Box } from 'ink'; +import { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { StickyHeader } from '../StickyHeader.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; @@ -14,7 +15,17 @@ import { ToolInfo, TrailingIndicator, type TextEmphasis, + STATUS_INDICATOR_WIDTH, } from './ToolShared.js'; +import { + SHELL_COMMAND_NAME, + SHELL_FOCUS_HINT_DELAY_MS, +} from '../../constants.js'; +import { theme } from '../../semantic-colors.js'; +import type { Config } from '@google/gemini-cli-core'; +import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; +import { ToolCallStatus } from '../../types.js'; +import { ShellInputPrompt } from '../ShellInputPrompt.js'; export type { TextEmphasis }; @@ -26,6 +37,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { isFirst: boolean; borderColor: string; borderDimColor: boolean; + activeShellPtyId?: number | null; + embeddedShellFocused?: boolean; + ptyId?: number; + config?: Config; } export const ToolMessage: React.FC = ({ @@ -40,41 +55,96 @@ export const ToolMessage: React.FC = ({ isFirst, borderColor, borderDimColor, -}) => ( - - - - - {emphasis === 'high' && } - - - + activeShellPtyId, + embeddedShellFocused, + ptyId, + config, +}) => { + const isThisShellFocused = + (name === SHELL_COMMAND_NAME || name === 'Shell') && + status === ToolCallStatus.Executing && + ptyId === activeShellPtyId && + embeddedShellFocused; + + const [lastUpdateTime, setLastUpdateTime] = useState(null); + const [userHasFocused, setUserHasFocused] = useState(false); + const showFocusHint = useInactivityTimer( + !!lastUpdateTime, + lastUpdateTime ? lastUpdateTime.getTime() : 0, + SHELL_FOCUS_HINT_DELAY_MS, + ); + + useEffect(() => { + if (resultDisplay) { + setLastUpdateTime(new Date()); + } + }, [resultDisplay]); + + useEffect(() => { + if (isThisShellFocused) { + setUserHasFocused(true); + } + }, [isThisShellFocused]); + + const isThisShellFocusable = + (name === SHELL_COMMAND_NAME || name === 'Shell') && + status === ToolCallStatus.Executing && + config?.getEnableInteractiveShell(); + + const shouldShowFocusHint = + isThisShellFocusable && (showFocusHint || userHasFocused); + + return ( + + + + + {shouldShowFocusHint && ( + + + {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} + + + )} + {emphasis === 'high' && } + + + + {isThisShellFocused && config && ( + + + + )} + - -); + ); +}; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 143556f0035..8dd58846c30 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -24,6 +24,8 @@ export const SHELL_NAME = 'Shell'; // usage. export const MAX_GEMINI_MESSAGE_LINES = 65536; +export const SHELL_FOCUS_HINT_DELAY_MS = 5000; + // Tool status symbols used in ToolMessage component export const TOOL_STATUS = { SUCCESS: '✓', diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index a463c0f4dfa..4fee562cbaf 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -76,6 +76,8 @@ export const useShellCommandProcessor = ( terminalHeight?: number, ) => { const [activeShellPtyId, setActiveShellPtyId] = useState(null); + const [lastShellOutputTime, setLastShellOutputTime] = useState(0); + const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { @@ -202,6 +204,7 @@ export const useShellCommandProcessor = ( // Throttle pending UI updates, but allow forced updates. if (shouldUpdate) { + setLastShellOutputTime(Date.now()); setPendingHistoryItem((prevItem) => { if (prevItem?.type === 'tool_group') { return { @@ -366,5 +369,5 @@ export const useShellCommandProcessor = ( ], ); - return { handleShellCommand, activeShellPtyId }; + return { handleShellCommand, activeShellPtyId, lastShellOutputTime }; }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 37a0848e561..979f520dcd2 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -136,6 +136,7 @@ export const useGeminiStream = ( markToolsAsSubmitted, setToolCallsForDisplay, cancelAllToolCalls, + lastToolOutputTime, ] = useReactToolScheduler( async (completedToolCallsFromScheduler) => { // This onComplete is called when ALL scheduled tools for a given batch are done. @@ -211,17 +212,18 @@ export const useGeminiStream = ( await done; setIsResponding(false); }, []); - const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor( - addItem, - setPendingHistoryItem, - onExec, - onDebugMessage, - config, - geminiClient, - setShellInputFocused, - terminalWidth, - terminalHeight, - ); + const { handleShellCommand, activeShellPtyId, lastShellOutputTime } = + useShellCommandProcessor( + addItem, + setPendingHistoryItem, + onExec, + onDebugMessage, + config, + geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, + ); const activePtyId = activeShellPtyId || activeToolPtyId; @@ -681,8 +683,9 @@ export const useGeminiStream = ( [FinishReason.UNEXPECTED_TOOL_CALL]: 'Response stopped due to unexpected tool call.', [FinishReason.IMAGE_PROHIBITED_CONTENT]: - 'Response stopped due to prohibited content.', - [FinishReason.NO_IMAGE]: 'Response stopped due to no image.', + 'Response stopped due to prohibited image content.', + [FinishReason.NO_IMAGE]: + 'Response stopped because no image was generated.', }; const message = finishReasonMessages[finishReason]; @@ -1348,6 +1351,8 @@ export const useGeminiStream = ( storage, ]); + const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime); + return { streamingState, submitQuery, @@ -1359,5 +1364,6 @@ export const useGeminiStream = ( handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, + lastOutputTime, }; }; diff --git a/packages/cli/src/ui/hooks/useInactivityTimer.ts b/packages/cli/src/ui/hooks/useInactivityTimer.ts new file mode 100644 index 00000000000..b4e667a358c --- /dev/null +++ b/packages/cli/src/ui/hooks/useInactivityTimer.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; + +/** + * Returns true after a specified delay of inactivity. + * Inactivity is defined as 'trigger' not changing for 'delayMs' milliseconds. + * + * @param isActive Whether the timer should be running. + * @param trigger Any value that, when changed, resets the inactivity timer. + * @param delayMs The delay in milliseconds before considering the state inactive. + */ +export const useInactivityTimer = ( + isActive: boolean, + trigger: unknown, + delayMs: number = 5000, +): boolean => { + const [isInactive, setIsInactive] = useState(false); + + useEffect(() => { + if (!isActive) { + setIsInactive(false); + return; + } + + setIsInactive(false); + const timer = setTimeout(() => { + setIsInactive(true); + }, delayMs); + + return () => clearTimeout(timer); + }, [isActive, trigger, delayMs]); + + return isInactive; +}; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index c714f60c149..6b1ec2189f3 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -9,8 +9,12 @@ import { act } from 'react'; import { render } from '../../test-utils/render.js'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; -import { PHRASE_CHANGE_INTERVAL_MS } from './usePhraseCycler.js'; +import { + PHRASE_CHANGE_INTERVAL_MS, + INTERACTIVE_SHELL_WAITING_PHRASE, +} from './usePhraseCycler.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import { INFORMATIVE_TIPS } from '../constants/tips.js'; describe('useLoadingIndicator', () => { beforeEach(() => { @@ -25,18 +29,33 @@ describe('useLoadingIndicator', () => { const renderLoadingIndicatorHook = ( initialStreamingState: StreamingState, + initialIsInteractiveShellWaiting: boolean = false, + initialLastOutputTime: number = 0, ) => { let hookResult: ReturnType; function TestComponent({ streamingState, + isInteractiveShellWaiting, + lastOutputTime, }: { streamingState: StreamingState; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; }) { - hookResult = useLoadingIndicator(streamingState); + hookResult = useLoadingIndicator( + streamingState, + undefined, + isInteractiveShellWaiting, + lastOutputTime, + ); return null; } const { rerender } = render( - , + , ); return { result: { @@ -44,8 +63,11 @@ describe('useLoadingIndicator', () => { return hookResult; }, }, - rerender: (newProps: { streamingState: StreamingState }) => - rerender(), + rerender: (newProps: { + streamingState: StreamingState; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; + }) => rerender(), }; }; @@ -58,6 +80,28 @@ describe('useLoadingIndicator', () => { ); }); + it('should show interactive shell waiting phrase when isInteractiveShellWaiting is true after 5s', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { result } = renderLoadingIndicatorHook( + StreamingState.Responding, + true, + 1, + ); + + // Initially should be witty phrase or tip + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + result.current.currentLoadingPhrase, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + expect(result.current.currentLoadingPhrase).toBe( + INTERACTIVE_SHELL_WAITING_PHRASE, + ); + }); + it('should reflect values when Responding', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases const { result } = renderLoadingIndicatorHook(StreamingState.Responding); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index d69df1706d3..a39b0c0e29e 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -12,6 +12,8 @@ import { useState, useEffect, useRef } from 'react'; // Added useRef export const useLoadingIndicator = ( streamingState: StreamingState, customWittyPhrases?: string[], + isInteractiveShellWaiting: boolean = false, + lastOutputTime: number = 0, ) => { const [timerResetKey, setTimerResetKey] = useState(0); const isTimerActive = streamingState === StreamingState.Responding; @@ -23,6 +25,8 @@ export const useLoadingIndicator = ( const currentLoadingPhrase = usePhraseCycler( isPhraseCyclingActive, isWaiting, + isInteractiveShellWaiting, + lastOutputTime, customWittyPhrases, ); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 576b8266ca8..cefa800afd9 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -11,6 +11,7 @@ import { Text } from 'ink'; import { usePhraseCycler, PHRASE_CHANGE_INTERVAL_MS, + INTERACTIVE_SHELL_WAITING_PHRASE, } from './usePhraseCycler.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; @@ -19,13 +20,23 @@ import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; const TestComponent = ({ isActive, isWaiting, + isInteractiveShellWaiting = false, + lastOutputTime = 0, customPhrases, }: { isActive: boolean; isWaiting: boolean; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; customPhrases?: string[]; }) => { - const phrase = usePhraseCycler(isActive, isWaiting, customPhrases); + const phrase = usePhraseCycler( + isActive, + isWaiting, + isInteractiveShellWaiting, + lastOutputTime, + customPhrases, + ); return {phrase}; }; @@ -57,6 +68,102 @@ describe('usePhraseCycler', () => { expect(lastFrame()).toBe('Waiting for user confirmation...'); }); + it('should show interactive shell waiting message when isInteractiveShellWaiting is true after 5s', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame, rerender } = render( + , + ); + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + // Should still be showing a witty phrase or tip initially + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + + it('should reset interactive shell waiting timer when lastOutputTime changes', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame, rerender } = render( + , + ); + + // Advance 3 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + // Should still be witty phrase or tip + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + // Update lastOutputTime + rerender( + , + ); + + // Advance another 3 seconds (total 6s from start, but only 3s from last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + // Should STILL be witty phrase or tip because timer reset + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + // Advance another 2 seconds (total 5s from last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + + it('should prioritize interactive shell waiting over normal waiting after 5s', async () => { + const { lastFrame, rerender } = render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(lastFrame()).toBe('Waiting for user confirmation...'); + + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + it('should not cycle phrases if isActive is false and not waiting', async () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 8985e68b1bc..969fe47135e 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -5,20 +5,28 @@ */ import { useState, useEffect, useRef } from 'react'; +import { SHELL_FOCUS_HINT_DELAY_MS } from '../constants.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import { useInactivityTimer } from './useInactivityTimer.js'; export const PHRASE_CHANGE_INTERVAL_MS = 15000; +export const INTERACTIVE_SHELL_WAITING_PHRASE = + 'Interactive shell awaiting input... press Ctrl+f to focus shell'; /** * Custom hook to manage cycling through loading phrases. * @param isActive Whether the phrase cycling should be active. * @param isWaiting Whether to show a specific waiting phrase. + * @param isInteractiveShellWaiting Whether an interactive shell is waiting for input but not focused. + * @param customPhrases Optional list of custom phrases to use. * @returns The current loading phrase. */ export const usePhraseCycler = ( isActive: boolean, isWaiting: boolean, + isInteractiveShellWaiting: boolean, + lastOutputTime: number = 0, customPhrases?: string[], ) => { const loadingPhrases = @@ -29,58 +37,64 @@ export const usePhraseCycler = ( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( loadingPhrases[0], ); + const showShellFocusHint = useInactivityTimer( + isInteractiveShellWaiting && lastOutputTime > 0, + lastOutputTime, + SHELL_FOCUS_HINT_DELAY_MS, + ); const phraseIntervalRef = useRef(null); const hasShownFirstRequestTipRef = useRef(false); useEffect(() => { + // Always clear on re-run + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + + if (isInteractiveShellWaiting && showShellFocusHint) { + setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE); + return; + } + if (isWaiting) { setCurrentLoadingPhrase('Waiting for user confirmation...'); - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - } else if (isActive) { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - } + return; + } - const setRandomPhrase = () => { - if (customPhrases && customPhrases.length > 0) { - const randomIndex = Math.floor(Math.random() * customPhrases.length); - setCurrentLoadingPhrase(customPhrases[randomIndex]); + if (!isActive) { + setCurrentLoadingPhrase(loadingPhrases[0]); + return; + } + + const setRandomPhrase = () => { + if (customPhrases && customPhrases.length > 0) { + const randomIndex = Math.floor(Math.random() * customPhrases.length); + setCurrentLoadingPhrase(customPhrases[randomIndex]); + } else { + let phraseList; + // Show a tip on the first request after startup, then continue with 1/6 chance + if (!hasShownFirstRequestTipRef.current) { + // Show a tip during the first request + phraseList = INFORMATIVE_TIPS; + hasShownFirstRequestTipRef.current = true; } else { - let phraseList; - // Show a tip on the first request after startup, then continue with 1/6 chance - if (!hasShownFirstRequestTipRef.current) { - // Show a tip during the first request - phraseList = INFORMATIVE_TIPS; - hasShownFirstRequestTipRef.current = true; - } else { - // Roughly 1 in 6 chance to show a tip after the first request - const showTip = Math.random() < 1 / 6; - phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES; - } - const randomIndex = Math.floor(Math.random() * phraseList.length); - setCurrentLoadingPhrase(phraseList[randomIndex]); + // Roughly 1 in 6 chance to show a tip after the first request + const showTip = Math.random() < 1 / 6; + phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES; } - }; + const randomIndex = Math.floor(Math.random() * phraseList.length); + setCurrentLoadingPhrase(phraseList[randomIndex]); + } + }; - // Select an initial random phrase - setRandomPhrase(); + // Select an initial random phrase + setRandomPhrase(); - phraseIntervalRef.current = setInterval(() => { - // Select a new random phrase - setRandomPhrase(); - }, PHRASE_CHANGE_INTERVAL_MS); - } else { - // Idle or other states, clear the phrase interval - // and reset to the first phrase for next active state. - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - setCurrentLoadingPhrase(loadingPhrases[0]); - } + phraseIntervalRef.current = setInterval(() => { + // Select a new random phrase + setRandomPhrase(); + }, PHRASE_CHANGE_INTERVAL_MS); return () => { if (phraseIntervalRef.current) { @@ -88,7 +102,14 @@ export const usePhraseCycler = ( phraseIntervalRef.current = null; } }; - }, [isActive, isWaiting, customPhrases, loadingPhrases]); + }, [ + isActive, + isWaiting, + isInteractiveShellWaiting, + customPhrases, + loadingPhrases, + showShellFocusHint, + ]); return currentLoadingPhrase; }; diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 0b00ce4d784..9c21fe2bcc1 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -74,10 +74,12 @@ export function useReactToolScheduler( MarkToolsAsSubmittedFn, React.Dispatch>, CancelAllFn, + number, ] { const [toolCallsForDisplay, setToolCallsForDisplay] = useState< TrackedToolCall[] >([]); + const [lastToolOutputTime, setLastToolOutputTime] = useState(0); // Store callbacks in refs to keep them up-to-date without causing re-renders. const onCompleteRef = useRef(onComplete); @@ -93,6 +95,7 @@ export function useReactToolScheduler( const outputUpdateHandler: OutputUpdateHandler = useCallback( (toolCallId, outputChunk) => { + setLastToolOutputTime(Date.now()); setToolCallsForDisplay((prevCalls) => prevCalls.map((tc) => { if (tc.request.callId === toolCallId && tc.status === 'executing') { @@ -208,6 +211,7 @@ export function useReactToolScheduler( markToolsAsSubmitted, setToolCallsForDisplay, cancelAllToolCalls, + lastToolOutputTime, ]; } From cdd9f67e9fe6b425f685a1754ac8576fbfea5015 Mon Sep 17 00:00:00 2001 From: JAYADITYA <96861162+JayadityaGit@users.noreply.github.com> Date: Fri, 21 Nov 2025 23:08:59 +0530 Subject: [PATCH 18/32] docs: Update uninstall command to reflect multiple extension support (#13582) --- docs/extensions/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index af886026d70..62b05d3aad4 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -54,10 +54,11 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] ### Uninstalling an extension -To uninstall, run `gemini extensions uninstall `: +To uninstall one or more extensions, run +`gemini extensions uninstall `: ``` -gemini extensions uninstall gemini-cli-security +gemini extensions uninstall gemini-cli-security gemini-cli-another-extension ``` ### Disabling an extension From 059929c8747162188d6d72d9dc75d0841bf36327 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 21 Nov 2025 09:43:20 -0800 Subject: [PATCH 19/32] bug(core): Ensure we use thinking budget on fallback to 2.5 (#13596) --- packages/core/src/core/geminiChat.test.ts | 151 ++++++++++++++++++++-- packages/core/src/core/geminiChat.ts | 9 ++ 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 0a6207739b0..bafc12dcaec 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -20,6 +20,7 @@ import { setSimulate429 } from '../utils/testUtils.js'; import { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, + DEFAULT_THINKING_MODE, PREVIEW_GEMINI_MODEL, } from '../config/models.js'; import { AuthType } from './contentGenerator.js'; @@ -131,15 +132,22 @@ describe('GeminiChat', () => { getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getRetryFetchErrors: vi.fn().mockReturnValue(false), modelConfigService: { - getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({ - model: modelConfigKey.model, - generateContentConfig: { - temperature: 0, - thinkingConfig: { - thinkingBudget: 1000, + getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => { + const thinkingConfig = modelConfigKey.model.startsWith('gemini-3') + ? { + thinkingLevel: ThinkingLevel.HIGH, + } + : { + thinkingBudget: DEFAULT_THINKING_MODE, + }; + return { + model: modelConfigKey.model, + generateContentConfig: { + temperature: 0, + thinkingConfig, }, - }, - })), + }; + }), }, isPreviewModelBypassMode: vi.fn().mockReturnValue(false), setPreviewModelBypassMode: vi.fn(), @@ -976,7 +984,7 @@ describe('GeminiChat', () => { tools: [], temperature: 0, thinkingConfig: { - thinkingBudget: 1000, + thinkingBudget: DEFAULT_THINKING_MODE, }, abortSignal: expect.any(AbortSignal), }, @@ -1023,6 +1031,45 @@ describe('GeminiChat', () => { 'prompt-id-thinking-level', ); }); + + it('should use thinkingBudget and remove thinkingLevel for non-gemini-3 models', async () => { + const response = (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'response' }], role: 'model' }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + response, + ); + + const stream = await chat.sendMessageStream( + { model: 'gemini-2.0-flash' }, + 'hello', + 'prompt-id-thinking-budget', + new AbortController().signal, + ); + for await (const _ of stream) { + // consume stream + } + + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gemini-2.0-flash', + config: expect.objectContaining({ + thinkingConfig: { + thinkingBudget: DEFAULT_THINKING_MODE, + thinkingLevel: undefined, + }, + }), + }), + 'prompt-id-thinking-budget', + ); + }); }); describe('addHistory', () => { @@ -1902,6 +1949,92 @@ describe('GeminiChat', () => { expect(modelTurn.parts![0]!.text).toBe('Success on retry'); }); + it('should switch to DEFAULT_GEMINI_FLASH_MODEL and use thinkingBudget when falling back from a gemini-3 model', async () => { + // ARRANGE + const authType = AuthType.LOGIN_WITH_GOOGLE; + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType, + }); + + // Initial state: Not in fallback mode + const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode'); + isInFallbackModeSpy.mockReturnValue(false); + + // Mock API calls: + // 1. Fails with 429 (simulating gemini-3 failure) + // 2. Succeeds (simulating fallback success) + vi.mocked(mockContentGenerator.generateContentStream) + .mockRejectedValueOnce(error429) + .mockResolvedValueOnce( + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Fallback success' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(), + ); + + // Mock handleFallback to enable fallback mode and signal retry + mockHandleFallback.mockImplementation(async () => { + isInFallbackModeSpy.mockReturnValue(true); // Next call will see fallback mode = true + return true; + }); + + // ACT + const stream = await chat.sendMessageStream( + { model: 'gemini-3-test-model' }, // Start with a gemini-3 model + 'test fallback thinking', + 'prompt-id-fb3', + new AbortController().signal, + ); + for await (const _ of stream) { + // consume stream + } + + // ASSERT + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 2, + ); + + // First call: gemini-3 model, thinkingLevel set + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + model: 'gemini-3-test-model', + config: expect.objectContaining({ + thinkingConfig: { + thinkingBudget: undefined, + thinkingLevel: ThinkingLevel.HIGH, + }, + }), + }), + 'prompt-id-fb3', + ); + + // Second call: DEFAULT_GEMINI_FLASH_MODEL (due to fallback), thinkingBudget set (due to fix) + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + model: DEFAULT_GEMINI_FLASH_MODEL, + config: expect.objectContaining({ + thinkingConfig: { + thinkingBudget: DEFAULT_THINKING_MODE, + thinkingLevel: undefined, + }, + }), + }), + 'prompt-id-fb3', + ); + }); + it('should stop retrying if handleFallback returns false (e.g., auth intent)', async () => { vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro'); vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 02ce3acf60b..c06285f4d4a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -22,6 +22,7 @@ import { retryWithBackoff } from '../utils/retry.js'; import type { Config } from '../config/config.js'; import { DEFAULT_GEMINI_MODEL, + DEFAULT_THINKING_MODE, PREVIEW_GEMINI_MODEL, getEffectiveModel, isGemini2Model, @@ -428,6 +429,14 @@ export class GeminiChat { thinkingLevel: ThinkingLevel.HIGH, }; delete config.thinkingConfig?.thinkingBudget; + } else { + // The `gemini-3` configs use thinkingLevel, so we have to invert the + // change above. + config.thinkingConfig = { + ...config.thinkingConfig, + thinkingBudget: DEFAULT_THINKING_MODE, + }; + delete config.thinkingConfig?.thinkingLevel; } return this.config.getContentGenerator().generateContentStream( From 6340f11875dfef46692a3c297f019c8c0687703e Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:54:17 -0800 Subject: [PATCH 20/32] Remove useModelRouter experimental flag (#13593) --- docs/cli/model-routing.md | 17 +-- docs/cli/settings.md | 9 +- docs/get-started/configuration.md | 6 - packages/cli/src/config/config.test.ts | 122 ------------------ packages/cli/src/config/config.ts | 7 +- .../cli/src/config/settingsSchema.test.ts | 15 --- packages/cli/src/config/settingsSchema.ts | 10 -- .../src/services/BuiltinCommandLoader.test.ts | 24 ---- .../cli/src/services/BuiltinCommandLoader.ts | 2 +- .../src/ui/components/ModelDialog.test.tsx | 1 - .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 - .../cli/src/ui/hooks/useToolScheduler.test.ts | 1 - packages/core/src/config/config.test.ts | 93 ------------- packages/core/src/config/config.ts | 20 --- packages/core/src/core/client.test.ts | 1 - .../core/src/core/coreToolScheduler.test.ts | 1 - .../core/nonInteractiveToolExecutor.test.ts | 1 - .../clearcut-logger/clearcut-logger.test.ts | 2 +- packages/core/src/telemetry/loggers.test.ts | 3 - packages/core/src/tools/smart-edit.test.ts | 1 - schemas/settings.schema.json | 7 - 21 files changed, 8 insertions(+), 336 deletions(-) diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md index 4d01434f28e..d98d8754211 100644 --- a/docs/cli/model-routing.md +++ b/docs/cli/model-routing.md @@ -25,18 +25,6 @@ Here's how it works: `packages/cli/src/zed-integration/zedIntegration.ts` which checks if `isInFallbackMode()` is true. -## Configuration - -Model routing is controlled by the `useModelRouter` setting in your -`settings.json` file. - -- **`"experimental.useModelRouter": true` (Default):** Enables the model - routing/fallback feature. - -- **`"experimental.useModelRouter": false`:** Disables the model - routing/fallback feature. If a model fails, the CLI will not attempt to switch - to a fallback model. - ### Model Selection Precedence The model used by Gemini CLI is determined by the following order of precedence: @@ -50,7 +38,4 @@ The model used by Gemini CLI is determined by the following order of precedence: model specified in the `model.name` property of your `settings.json` file will be used. 4. **Default Model:** If none of the above are set, the default model will be - used. The default model is determined by the `useModelRouter` setting: - - If `useModelRouter` is `true`, the default model is `"auto"`. - - If `useModelRouter` is `false`, the default model is the standard Gemini - model. + used. The default model is `auto` diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 9c5784108de..01ca1afdac0 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -106,8 +106,7 @@ they appear in the UI. ### Experimental -| UI Label | Setting | Description | Default | -| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------- | -| Use Model Router | `experimental.useModelRouter` | Enable model routing to route requests to the best model based on complexity. | `true` | -| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` | -| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` | +| UI Label | Setting | Description | Default | +| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- | +| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` | +| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 33e4926ec19..e0e94128652 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -563,12 +563,6 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes -- **`experimental.useModelRouter`** (boolean): - - **Description:** Enable model routing to route requests to the best model - based on complexity. - - **Default:** `true` - - **Requires restart:** Yes - - **`experimental.codebaseInvestigatorSettings.enabled`** (boolean): - **Description:** Enable the Codebase Investigator agent. - **Default:** `true` diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 020592c9038..a8d04936911 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -9,8 +9,6 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { DEFAULT_FILE_FILTERING_OPTIONS, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, OutputFormat, SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, @@ -1364,100 +1362,6 @@ describe('loadCliConfig model selection', () => { }); }); -describe('loadCliConfig model selection with model router', () => { - beforeEach(() => { - vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should use auto model when useModelRouter is true and no model is provided', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - { - experimental: { - useModelRouter: true, - }, - }, - 'test-session', - argv, - ); - - expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL_AUTO); - }); - - it('should use default model when useModelRouter is false and no model is provided', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - { - experimental: { - useModelRouter: false, - }, - }, - 'test-session', - argv, - ); - - expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL); - }); - - it('should prioritize argv over useModelRouter', async () => { - process.argv = ['node', 'script.js', '--model', 'gemini-from-argv']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - { - experimental: { - useModelRouter: true, - }, - }, - 'test-session', - argv, - ); - - expect(config.getModel()).toBe('gemini-from-argv'); - }); - - it('should prioritize settings over useModelRouter', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - { - experimental: { - useModelRouter: true, - }, - model: { - name: 'gemini-from-settings', - }, - }, - 'test-session', - argv, - ); - - expect(config.getModel()).toBe('gemini-from-settings'); - }); - - it('should prioritize environment variable over useModelRouter', async () => { - process.argv = ['node', 'script.js']; - vi.stubEnv('GEMINI_MODEL', 'gemini-from-env'); - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - { - experimental: { - useModelRouter: true, - }, - }, - 'test-session', - argv, - ); - - expect(config.getModel()).toBe('gemini-from-env'); - }); -}); - describe('loadCliConfig folderTrust', () => { beforeEach(() => { vi.resetAllMocks(); @@ -1633,32 +1537,6 @@ describe('loadCliConfig useRipgrep', () => { const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); - - describe('loadCliConfig useModelRouter', () => { - it('should be true by default when useModelRouter is not set 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.getUseModelRouter()).toBe(true); - }); - - it('should be true when useModelRouter is set to true in settings', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { experimental: { useModelRouter: true } }; - const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getUseModelRouter()).toBe(true); - }); - - it('should be false when useModelRouter is explicitly set to false in settings', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { experimental: { useModelRouter: false } }; - const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getUseModelRouter()).toBe(false); - }); - }); }); describe('screenReader configuration', () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f39c9811222..8bd63473ba0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -15,7 +15,6 @@ import { setGeminiMdFilename as setServerGeminiMdFilename, getCurrentGeminiMdFilename, ApprovalMode, - DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_FILE_FILTERING_OPTIONS, @@ -580,10 +579,7 @@ export async function loadCliConfig( extraExcludes.length > 0 ? extraExcludes : undefined, ); - const useModelRouter = settings.experimental?.useModelRouter ?? true; - const defaultModel = useModelRouter - ? DEFAULT_GEMINI_MODEL_AUTO - : DEFAULT_GEMINI_MODEL; + const defaultModel = DEFAULT_GEMINI_MODEL_AUTO; const resolvedModel: string = argv.model || process.env['GEMINI_MODEL'] || @@ -674,7 +670,6 @@ export async function loadCliConfig( output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, - useModelRouter, enableMessageBusIntegration, codebaseInvestigatorSettings: settings.experimental?.codebaseInvestigatorSettings, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 090d56bdd5b..c8350a94c1a 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -345,21 +345,6 @@ describe('SettingsSchema', () => { getSettingsSchema().general.properties.previewFeatures.description, ).toBe('Enable preview features (e.g., preview models).'); }); - - it('should have useModelRouter setting in schema', () => { - expect( - getSettingsSchema().experimental.properties.useModelRouter, - ).toBeDefined(); - expect( - getSettingsSchema().experimental.properties.useModelRouter.type, - ).toBe('boolean'); - expect( - getSettingsSchema().experimental.properties.useModelRouter.category, - ).toBe('Experimental'); - expect( - getSettingsSchema().experimental.properties.useModelRouter.default, - ).toBe(true); - }); }); it('has JSON schema definitions for every referenced ref', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8bde95604c7..2eb79584609 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1281,16 +1281,6 @@ const SETTINGS_SCHEMA = { 'Enables extension loading/unloading within the CLI session.', showInDialog: false, }, - useModelRouter: { - type: 'boolean', - label: 'Use Model Router', - category: 'Experimental', - requiresRestart: true, - default: true, - description: - 'Enable model routing to route requests to the best model based on complexity.', - showInDialog: true, - }, codebaseInvestigatorSettings: { type: 'object', label: 'Codebase Investigator Settings', diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 49792cb0815..09de4b4fa88 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -95,7 +95,6 @@ describe('BuiltinCommandLoader', () => { vi.clearAllMocks(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(true), - getUseModelRouter: () => false, getEnableMessageBusIntegration: () => false, getEnableExtensionReloading: () => false, } as unknown as Config; @@ -168,28 +167,6 @@ describe('BuiltinCommandLoader', () => { expect(permissionsCmd).toBeUndefined(); }); - it('should include modelCommand when getUseModelRouter is true', async () => { - const mockConfigWithModelRouter = { - ...mockConfig, - getUseModelRouter: () => true, - } as unknown as Config; - const loader = new BuiltinCommandLoader(mockConfigWithModelRouter); - const commands = await loader.loadCommands(new AbortController().signal); - const modelCmd = commands.find((c) => c.name === 'model'); - expect(modelCmd).toBeDefined(); - }); - - it('should not include modelCommand when getUseModelRouter is false', async () => { - const mockConfigWithoutModelRouter = { - ...mockConfig, - getUseModelRouter: () => false, - } as unknown as Config; - const loader = new BuiltinCommandLoader(mockConfigWithoutModelRouter); - const commands = await loader.loadCommands(new AbortController().signal); - const modelCmd = commands.find((c) => c.name === 'model'); - expect(modelCmd).toBeUndefined(); - }); - it('should include policies command when message bus integration is enabled', async () => { const mockConfigWithMessageBus = { ...mockConfig, @@ -220,7 +197,6 @@ describe('BuiltinCommandLoader profile', () => { vi.resetModules(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(false), - getUseModelRouter: () => false, getCheckpointingEnabled: () => false, getEnableMessageBusIntegration: () => false, getEnableExtensionReloading: () => false, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 6e6c0a407fb..3646b2d0361 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -73,7 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { initCommand, mcpCommand, memoryCommand, - ...(this.config?.getUseModelRouter() ? [modelCommand] : []), + modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), privacyCommand, ...(this.config?.getEnableMessageBusIntegration() diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 140ed9248e0..a98a3caa822 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -51,7 +51,6 @@ const renderComponent = ( getDebugMode: vi.fn(() => false), getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })), getUseSmartEdit: vi.fn(() => false), - getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), isInteractive: vi.fn(() => false), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 6a8c8643153..8411b4ba787 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -222,7 +222,6 @@ describe('useGeminiStream', () => { .fn() .mockReturnValue(contentGeneratorConfig), getUseSmartEdit: () => false, - getUseModelRouter: () => false, isInteractive: () => false, } as unknown as Config; mockOnDebugMessage = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 175bf9c6312..9c4bfd9b8b1 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -74,7 +74,6 @@ const mockConfig = { authType: 'oauth-personal', }), getUseSmartEdit: () => false, - getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), getEnableMessageBusIntegration: () => false, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 286103b85b2..a3dda9371db 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -750,99 +750,6 @@ describe('Server Config (config.ts)', () => { }); }); - describe('Model Router with Auth', () => { - it('should disable model router by default for oauth-personal', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getUseModelRouter()).toBe(true); - }); - - it('should enable model router by default for other auth types', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - }); - await config.refreshAuth(AuthType.USE_GEMINI); - expect(config.getUseModelRouter()).toBe(true); - }); - - it('should disable model router for specified auth type', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - disableModelRouterForAuth: [AuthType.USE_GEMINI], - }); - await config.refreshAuth(AuthType.USE_GEMINI); - expect(config.getUseModelRouter()).toBe(false); - }); - - it('should enable model router for other auth type', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - disableModelRouterForAuth: [], - }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getUseModelRouter()).toBe(true); - }); - - it('should keep model router disabled when useModelRouter is false', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: false, - disableModelRouterForAuth: [AuthType.USE_GEMINI], - }); - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - expect(config.getUseModelRouter()).toBe(false); - }); - - it('should keep the user-chosen model after refreshAuth, even when model router is disabled for the auth type', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - disableModelRouterForAuth: [AuthType.USE_GEMINI], - }); - const chosenModel = 'gemini-1.5-pro-latest'; - config.setModel(chosenModel); - - await config.refreshAuth(AuthType.USE_GEMINI); - - expect(config.getUseModelRouter()).toBe(false); - expect(config.getModel()).toBe(chosenModel); - }); - - it('should keep the user-chosen model after refreshAuth, when model router is enabled for the auth type', async () => { - const config = new Config({ - ...baseParams, - useModelRouter: true, - disableModelRouterForAuth: [AuthType.USE_GEMINI], - }); - const chosenModel = 'gemini-1.5-pro-latest'; - config.setModel(chosenModel); - - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - - 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', () => { it('should default continueOnFailedApiCall to false when not provided', () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6c2a962cfc7..2e47e4294bf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -48,7 +48,6 @@ import { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_THINKING_MODE, } from './models.js'; import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; @@ -288,7 +287,6 @@ export interface ConfigParameters { useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; output?: OutputSettings; - useModelRouter?: boolean; enableMessageBusIntegration?: boolean; disableModelRouterForAuth?: AuthType[]; codebaseInvestigatorSettings?: CodebaseInvestigatorSettings; @@ -402,9 +400,6 @@ export class Config { private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; private readonly outputSettings: OutputSettings; - private useModelRouter: boolean; - private readonly initialUseModelRouter: boolean; - private readonly disableModelRouterForAuth?: AuthType[]; private readonly enableMessageBusIntegration: boolean; private readonly codebaseInvestigatorSettings: CodebaseInvestigatorSettings; private readonly continueOnFailedApiCall: boolean; @@ -519,9 +514,6 @@ export class Config { this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.useSmartEdit = params.useSmartEdit ?? true; this.useWriteTodos = params.useWriteTodos ?? true; - this.initialUseModelRouter = params.useModelRouter ?? false; - this.useModelRouter = this.initialUseModelRouter; - this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? []; this.enableHooks = params.enableHooks ?? false; // Enable MessageBus integration if: @@ -643,14 +635,6 @@ export class Config { } async refreshAuth(authMethod: AuthType) { - this.useModelRouter = this.initialUseModelRouter; - if (this.disableModelRouterForAuth?.includes(authMethod)) { - this.useModelRouter = false; - if (this.model === DEFAULT_GEMINI_MODEL_AUTO) { - this.model = DEFAULT_GEMINI_MODEL; - } - } - // Vertex and Genai have incompatible encryption and sending history with // thoughtSignature from Genai to Vertex will fail, we need to strip them if ( @@ -1341,10 +1325,6 @@ export class Config { : OutputFormat.TEXT; } - getUseModelRouter(): boolean { - return this.useModelRouter; - } - async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir, this.storage); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e483ef1eb50..7284fe9e6f6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -216,7 +216,6 @@ describe('Gemini Client (client.ts)', () => { getChatCompression: vi.fn().mockReturnValue(undefined), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getUseSmartEdit: vi.fn().mockReturnValue(false), - getUseModelRouter: vi.fn().mockReturnValue(false), getShowModelInfoInChat: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn(), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index cbf8f5b66b1..2821f182dbb 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -248,7 +248,6 @@ function createMockConfig(overrides: Partial = {}): Config { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => defaultToolRegistry, getUseSmartEdit: () => false, - getUseModelRouter: () => false, getGeminiClient: () => null, getEnableMessageBusIntegration: () => false, getMessageBus: () => null, diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 5ee2b83f49c..d74165ee5ec 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -60,7 +60,6 @@ describe('executeToolCall', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getUseSmartEdit: () => false, - getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests getEnableMessageBusIntegration: () => false, getMessageBus: () => null, diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index fa14fcb0174..ee6083c7393 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -312,7 +312,7 @@ describe('ClearcutLogger', () => { it('logs all user settings', () => { const { logger } = setup({ - config: { useSmartEdit: true, useModelRouter: true }, + config: { useSmartEdit: true }, }); vi.stubEnv('TERM_PROGRAM', 'vscode'); diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 4be2ec6eeb9..282c266083c 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -1544,7 +1544,6 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, getUseSmartEdit: () => null, - getUseModelRouter: () => null, isInteractive: () => false, } as unknown as Config; @@ -1595,7 +1594,6 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, getUseSmartEdit: () => null, - getUseModelRouter: () => null, isInteractive: () => false, } as unknown as Config; @@ -1648,7 +1646,6 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, getUseSmartEdit: () => null, - getUseModelRouter: () => null, isInteractive: () => false, } as unknown as Config; diff --git a/packages/core/src/tools/smart-edit.test.ts b/packages/core/src/tools/smart-edit.test.ts index 1d3a8c822f9..293d586c67d 100644 --- a/packages/core/src/tools/smart-edit.test.ts +++ b/packages/core/src/tools/smart-edit.test.ts @@ -90,7 +90,6 @@ describe('SmartEditTool', () => { getSessionId: vi.fn(() => 'mock-session-id'), getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })), getUseSmartEdit: vi.fn(() => false), - getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), getGeminiClient: vi.fn().mockReturnValue(geminiClient), getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient), diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 3bc6a5f7b1b..ebfbe0dbf74 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1201,13 +1201,6 @@ "default": false, "type": "boolean" }, - "useModelRouter": { - "title": "Use Model Router", - "description": "Enable model routing to route requests to the best model based on complexity.", - "markdownDescription": "Enable model routing to route requests to the best model based on complexity.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, - "type": "boolean" - }, "codebaseInvestigatorSettings": { "title": "Codebase Investigator Settings", "description": "Configuration for Codebase Investigator.", From 074d5d48aeed166625054e8647d2d7997f29407b Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 21 Nov 2025 09:59:34 -0800 Subject: [PATCH 21/32] feat(docs): Ensure multiline JS objects are rendered properly. (#13535) --- docs/get-started/configuration.md | 183 +++++++++++++++++++++++++++++- schemas/settings.schema.json | 6 +- scripts/generate-settings-doc.ts | 18 ++- scripts/tests/autogen.test.ts | 56 +++++++++ scripts/utils/autogen.ts | 4 +- 5 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 scripts/tests/autogen.test.ts diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index e0e94128652..ecbedc5c37f 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -317,7 +317,182 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property. - **Default:** - `{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true},"temperature":1,"topP":0.95,"topK":64}}},"chat-base-2.5":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingBudget":8192}}}},"chat-base-3":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingLevel":"HIGH"}}}},"gemini-3-pro-preview":{"extends":"chat-base-3","modelConfig":{"model":"gemini-3-pro-preview"}},"gemini-2.5-pro":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection-double-check":{"extends":"base","modelConfig":{"model":"gemini-2.5-pro"}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}` + + ```json + { + "base": { + "modelConfig": { + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + } + }, + "chat-base": { + "extends": "base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "includeThoughts": true + }, + "temperature": 1, + "topP": 0.95, + "topK": 64 + } + } + }, + "chat-base-2.5": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 8192 + } + } + } + }, + "chat-base-3": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingLevel": "HIGH" + } + } + } + }, + "gemini-3-pro-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "gemini-2.5-pro": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "gemini-2.5-flash": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "gemini-2.5-flash-lite": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "gemini-2.5-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "classifier": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 1024, + "thinkingConfig": { + "thinkingBudget": 512 + } + } + } + }, + "prompt-completion": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "temperature": 0.3, + "maxOutputTokens": 16000, + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "edit-corrector": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "summarizer-default": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "summarizer-shell": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "web-search": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + } + ] + } + } + }, + "web-fetch": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "urlContext": {} + } + ] + } + } + }, + "web-fetch-fallback": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection-double-check": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "llm-edit-fixer": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "next-speaker-checker": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + } + } + ``` - **`modelConfigs.overrides`** (array): - **Description:** Apply specific configuration overrides based on matches, @@ -545,7 +720,11 @@ their corresponding top-level category object in your `settings.json` file. - **`advanced.excludedEnvVars`** (array): - **Description:** Environment variables to exclude from project context. - - **Default:** `["DEBUG","DEBUG_MODE"]` + - **Default:** + + ```json + ["DEBUG", "DEBUG_MODE"] + ``` - **`advanced.bugCommand`** (object): - **Description:** Configuration for the bug report command. diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index ebfbe0dbf74..f3301802b03 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -436,7 +436,7 @@ "modelConfigs": { "title": "Model Configs", "description": "Model configurations.", - "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\"aliases\":{\"base\":{\"modelConfig\":{\"generateContentConfig\":{\"temperature\":0,\"topP\":1}}},\"chat-base\":{\"extends\":\"base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"includeThoughts\":true},\"temperature\":1,\"topP\":0.95,\"topK\":64}}},\"chat-base-2.5\":{\"extends\":\"chat-base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}},\"chat-base-3\":{\"extends\":\"chat-base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"HIGH\"}}}},\"gemini-3-pro-preview\":{\"extends\":\"chat-base-3\",\"modelConfig\":{\"model\":\"gemini-3-pro-preview\"}},\"gemini-2.5-pro\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"gemini-2.5-flash\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"gemini-2.5-flash-lite\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\"}},\"gemini-2.5-flash-base\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"classifier\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":1024,\"thinkingConfig\":{\"thinkingBudget\":512}}}},\"prompt-completion\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"temperature\":0.3,\"maxOutputTokens\":16000,\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"edit-corrector\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"summarizer-default\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"summarizer-shell\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"web-search\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{\"generateContentConfig\":{\"tools\":[{\"googleSearch\":{}}]}}},\"web-fetch\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{\"generateContentConfig\":{\"tools\":[{\"urlContext\":{}}]}}},\"web-fetch-fallback\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"loop-detection\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"loop-detection-double-check\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"llm-edit-fixer\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"next-speaker-checker\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}}}}`", + "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n }\n }\n}`", "default": { "aliases": { "base": { @@ -617,7 +617,7 @@ "aliases": { "title": "Model Config Aliases", "description": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.", - "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\"base\":{\"modelConfig\":{\"generateContentConfig\":{\"temperature\":0,\"topP\":1}}},\"chat-base\":{\"extends\":\"base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"includeThoughts\":true},\"temperature\":1,\"topP\":0.95,\"topK\":64}}},\"chat-base-2.5\":{\"extends\":\"chat-base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":8192}}}},\"chat-base-3\":{\"extends\":\"chat-base\",\"modelConfig\":{\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingLevel\":\"HIGH\"}}}},\"gemini-3-pro-preview\":{\"extends\":\"chat-base-3\",\"modelConfig\":{\"model\":\"gemini-3-pro-preview\"}},\"gemini-2.5-pro\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"gemini-2.5-flash\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"gemini-2.5-flash-lite\":{\"extends\":\"chat-base-2.5\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\"}},\"gemini-2.5-flash-base\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash\"}},\"classifier\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":1024,\"thinkingConfig\":{\"thinkingBudget\":512}}}},\"prompt-completion\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"temperature\":0.3,\"maxOutputTokens\":16000,\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"edit-corrector\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"thinkingConfig\":{\"thinkingBudget\":0}}}},\"summarizer-default\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"summarizer-shell\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-flash-lite\",\"generateContentConfig\":{\"maxOutputTokens\":2000}}},\"web-search\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{\"generateContentConfig\":{\"tools\":[{\"googleSearch\":{}}]}}},\"web-fetch\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{\"generateContentConfig\":{\"tools\":[{\"urlContext\":{}}]}}},\"web-fetch-fallback\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"loop-detection\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"loop-detection-double-check\":{\"extends\":\"base\",\"modelConfig\":{\"model\":\"gemini-2.5-pro\"}},\"llm-edit-fixer\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}},\"next-speaker-checker\":{\"extends\":\"gemini-2.5-flash-base\",\"modelConfig\":{}}}`", + "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n }\n}`", "default": { "base": { "modelConfig": { @@ -1164,7 +1164,7 @@ "excludedEnvVars": { "title": "Excluded Project Environment Variables", "description": "Environment variables to exclude from project context.", - "markdownDescription": "Environment variables to exclude from project context.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[\"DEBUG\",\"DEBUG_MODE\"]`", + "markdownDescription": "Environment variables to exclude from project context.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[\n \"DEBUG\",\n \"DEBUG_MODE\"\n]`", "default": ["DEBUG", "DEBUG_MODE"], "type": "array", "items": { diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 0acaf781d9a..6cd5c6a2938 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -168,7 +168,23 @@ function renderSections(sections: Map) { for (const entry of entries) { lines.push(`- **\`${entry.path}\`** (${entry.type}):`); lines.push(` - **Description:** ${entry.description}`); - lines.push(` - **Default:** \`${escapeBackticks(entry.defaultValue)}\``); + + if (entry.defaultValue.includes('\n')) { + lines.push(' - **Default:**'); + lines.push(''); + lines.push(' ```json'); + lines.push( + entry.defaultValue + .split('\n') + .map((line) => ` ${line}`) + .join('\n'), + ); + lines.push(' ```'); + } else { + lines.push( + ` - **Default:** \`${escapeBackticks(entry.defaultValue)}\``, + ); + } if (entry.enumValues && entry.enumValues.length > 0) { const values = entry.enumValues diff --git a/scripts/tests/autogen.test.ts b/scripts/tests/autogen.test.ts new file mode 100644 index 00000000000..23cc4d8d715 --- /dev/null +++ b/scripts/tests/autogen.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { formatDefaultValue } from '../utils/autogen.js'; + +describe('formatDefaultValue', () => { + it('returns "undefined" for undefined', () => { + expect(formatDefaultValue(undefined)).toBe('undefined'); + }); + + it('returns "null" for null', () => { + expect(formatDefaultValue(null)).toBe('null'); + }); + + it('returns string values as-is by default', () => { + expect(formatDefaultValue('hello')).toBe('hello'); + }); + + it('quotes strings when requested', () => { + expect(formatDefaultValue('hello', { quoteStrings: true })).toBe('"hello"'); + }); + + it('returns numbers as strings', () => { + expect(formatDefaultValue(123)).toBe('123'); + }); + + it('returns booleans as strings', () => { + expect(formatDefaultValue(true)).toBe('true'); + }); + + it('pretty prints arrays', () => { + const input = ['a', 'b']; + const expected = JSON.stringify(input, null, 2); + expect(formatDefaultValue(input)).toBe(expected); + expect(formatDefaultValue(input)).toContain('\n'); + }); + + it('returns "[]" for empty arrays', () => { + expect(formatDefaultValue([])).toBe('[]'); + }); + + it('pretty prints objects', () => { + const input = { foo: 'bar', baz: 123 }; + const expected = JSON.stringify(input, null, 2); + expect(formatDefaultValue(input)).toBe(expected); + expect(formatDefaultValue(input)).toContain('\n'); + }); + + it('returns "{}" for empty objects', () => { + expect(formatDefaultValue({})).toBe('{}'); + }); +}); diff --git a/scripts/utils/autogen.ts b/scripts/utils/autogen.ts index e43823ab21d..501c5d3ef6e 100644 --- a/scripts/utils/autogen.ts +++ b/scripts/utils/autogen.ts @@ -57,7 +57,7 @@ export function formatDefaultValue( return '[]'; } try { - return JSON.stringify(value); + return JSON.stringify(value, null, 2); } catch { return String(value); } @@ -65,7 +65,7 @@ export function formatDefaultValue( if (typeof value === 'object') { try { - const json = JSON.stringify(value); + const json = JSON.stringify(value, null, 2); if (json === '{}') { return '{}'; } From ca778aac7fe1051b156b9f8a52bfb3c9b00263ab Mon Sep 17 00:00:00 2001 From: owenofbrien <86964623+owenofbrien@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:44:50 -0600 Subject: [PATCH 22/32] Fix exp id logging (#13430) --- .../src/ui/components/ModelDialog.test.tsx | 1 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + .../cli/src/ui/hooks/useToolScheduler.test.ts | 1 + packages/core/src/config/config.ts | 11 +++ packages/core/src/core/client.test.ts | 1 + .../core/src/core/coreToolScheduler.test.ts | 2 + .../core/nonInteractiveToolExecutor.test.ts | 1 + .../clearcut-logger/clearcut-logger.test.ts | 26 ++---- .../clearcut-logger/clearcut-logger.ts | 89 ++++++++++++++----- .../clearcut-logger/event-metadata-key.ts | 3 + packages/core/src/tools/smart-edit.test.ts | 1 + 11 files changed, 92 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index a98a3caa822..ebbf921348b 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -53,6 +53,7 @@ const renderComponent = ( getUseSmartEdit: vi.fn(() => false), getProxy: vi.fn(() => undefined), isInteractive: vi.fn(() => false), + getExperiments: () => {}, // --- Spread test-specific overrides --- ...contextValue, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 8411b4ba787..b5d7b57f8c5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -223,6 +223,7 @@ describe('useGeminiStream', () => { .mockReturnValue(contentGeneratorConfig), getUseSmartEdit: () => false, isInteractive: () => false, + getExperiments: () => {}, } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 9c4bfd9b8b1..8c75e86683c 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -80,6 +80,7 @@ const mockConfig = { getMessageBus: () => null, getPolicyEngine: () => null, isInteractive: () => false, + getExperiments: () => {}, } as unknown as Config; const mockTool = new MockTool({ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2e47e4294bf..ef906341343 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -689,6 +689,17 @@ export class Config { this.inFallbackMode = false; } + async getExperimentsAsync(): Promise { + if (this.experiments) { + return this.experiments; + } + const codeAssistServer = getCodeAssistServer(this); + if (codeAssistServer) { + return getExperiments(codeAssistServer); + } + return undefined; + } + getUserTier(): UserTierId | undefined { return this.contentGenerator?.userTier; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 7284fe9e6f6..10e954fd2ef 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -241,6 +241,7 @@ describe('Gemini Client (client.ts)', () => { }, }, isInteractive: vi.fn().mockReturnValue(false), + getExperiments: () => {}, } as unknown as Config; client = new GeminiClient(mockConfig); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 2821f182dbb..a5d4d398238 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -223,6 +223,7 @@ function createMockConfig(overrides: Partial = {}): Config { discoverTools: async () => {}, getAllTools: () => [], getToolsByServer: () => [], + getExperiments: () => {}, } as unknown as ToolRegistry; const baseConfig = { @@ -252,6 +253,7 @@ function createMockConfig(overrides: Partial = {}): Config { getEnableMessageBusIntegration: () => false, getMessageBus: () => null, getPolicyEngine: () => null, + getExperiments: () => {}, } as unknown as Config; return { ...baseConfig, ...overrides } as Config; diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index d74165ee5ec..9ec63584d18 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -65,6 +65,7 @@ describe('executeToolCall', () => { getMessageBus: () => null, getPolicyEngine: () => null, isInteractive: () => false, + getExperiments: () => {}, } as unknown as Config; abortController = new AbortController(); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index ee6083c7393..3b8747b6299 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -66,7 +66,6 @@ expect.extend({ received: LogEventEntry[], [key, value]: [EventMetadataKey, string], ) { - const { isNot } = this; const event = JSON.parse(received[0].source_extension_json) as LogEvent; const metadata = event['event_metadata'][0]; const data = metadata.find((m) => m.gemini_cli_key === key)?.value; @@ -75,8 +74,7 @@ expect.extend({ return { pass, - message: () => - `event ${received} does${isNot ? ' not' : ''} have ${value}}`, + message: () => `event ${received} should have: ${value}. Found: ${data}`, }; }, @@ -93,21 +91,6 @@ expect.extend({ `event ${received} ${isNot ? 'has' : 'does not have'} the metadata key ${key}`, }; }, - - toHaveGwsExperiments(received: LogEventEntry[], expected_exps: number[]) { - const { isNot } = this; - const exps = received[0].gws_experiment; - - const pass = - exps.length === expected_exps.length && - exps.every((value, index) => value === expected_exps[index]); - - return { - pass, - message: () => - `event ${received} ${isNot ? 'has' : 'does not have'} expected exp ids: ${expected_exps.join(',')}`, - }; - }, }); vi.mock('../../utils/userAccountManager.js'); @@ -618,7 +601,6 @@ describe('ClearcutLogger', () => { { event_time_ms: Date.now(), source_extension_json: JSON.stringify({ event_id: i }), - gws_experiment: [], }, ]); } @@ -652,7 +634,6 @@ describe('ClearcutLogger', () => { { event_time_ms: Date.now(), source_extension_json: JSON.stringify({ event_id: `failed_${i}` }), - gws_experiment: [], }, ]); } @@ -779,7 +760,10 @@ describe('ClearcutLogger', () => { const events = getEvents(logger!); expect(events.length).toBe(1); expect(events[0]).toHaveEventName(EventNames.AGENT_START); - expect(events[0]).toHaveGwsExperiments([123, 456, 789]); + expect(events[0]).toHaveMetadataValue([ + EventMetadataKey.GEMINI_CLI_EXPERIMENT_IDS, + '123,456,789', + ]); }); }); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 81a4e771b46..8c3ea14da87 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -103,7 +103,6 @@ export interface LogResponse { export interface LogEventEntry { event_time_ms: number; source_extension_json: string; - gws_experiment: number[]; } export interface EventValue { @@ -233,28 +232,55 @@ export class ClearcutLogger { ClearcutLogger.instance = undefined; } + enqueueHelper(event: LogEvent): void { + // Manually handle overflow for FixedDeque, which throws when full. + const wasAtCapacity = this.events.size >= MAX_EVENTS; + + if (wasAtCapacity) { + this.events.shift(); // Evict oldest element to make space. + } + + this.events.push([ + { + event_time_ms: Date.now(), + source_extension_json: safeJsonStringify(event), + }, + ]); + + if (wasAtCapacity && this.config?.getDebugMode()) { + debugLogger.debug( + `ClearcutLogger: Dropped old event to prevent memory leak (queue size: ${this.events.size})`, + ); + } + } + enqueueLogEvent(event: LogEvent): void { try { - // Manually handle overflow for FixedDeque, which throws when full. - const wasAtCapacity = this.events.size >= MAX_EVENTS; - - if (wasAtCapacity) { - this.events.shift(); // Evict oldest element to make space. + this.enqueueHelper(event); + } catch (error) { + if (this.config?.getDebugMode()) { + console.error('ClearcutLogger: Failed to enqueue log event.', error); } + } + } - this.events.push([ - { - event_time_ms: Date.now(), - source_extension_json: safeJsonStringify(event), - gws_experiment: this.config?.getExperiments()?.experimentIds ?? [], - }, - ]); + async enqueueLogEventAfterExperimentsLoadAsync( + event: LogEvent, + ): Promise { + try { + this.config?.getExperimentsAsync().then((experiments) => { + if (experiments) { + const exp_id_data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXPERIMENT_IDS, + value: experiments.experimentIds.toString() ?? 'NA', + }, + ]; + event.event_metadata = [[...event.event_metadata[0], ...exp_id_data]]; + } - if (wasAtCapacity && this.config?.getDebugMode()) { - debugLogger.debug( - `ClearcutLogger: Dropped old event to prevent memory leak (queue size: ${this.events.size})`, - ); - } + this.enqueueHelper(event); + }); } catch (error) { if (this.config?.getDebugMode()) { console.error('ClearcutLogger: Failed to enqueue log event.', error); @@ -507,10 +533,13 @@ export class ClearcutLogger { ]; this.sessionData = data; - // Flush start event immediately - this.enqueueLogEvent(this.createLogEvent(EventNames.START_SESSION, data)); - this.flushToClearcut().catch((error) => { - debugLogger.debug('Error flushing to Clearcut:', error); + // Flush after experiments finish loading from CCPA server + this.enqueueLogEventAfterExperimentsLoadAsync( + this.createLogEvent(EventNames.START_SESSION, data), + ).then(() => { + this.flushToClearcut().catch((error) => { + debugLogger.debug('Error flushing to Clearcut:', error); + }); }); } @@ -847,8 +876,14 @@ export class ClearcutLogger { }, ]; - this.enqueueLogEvent(this.createLogEvent(EventNames.IDE_CONNECTION, data)); - this.flushIfNeeded(); + // Flush after experiments finish loading from CCPA server + this.enqueueLogEventAfterExperimentsLoadAsync( + this.createLogEvent(EventNames.START_SESSION, data), + ).then(() => { + this.flushToClearcut().catch((error) => { + debugLogger.debug('Error flushing to Clearcut:', error); + }); + }); } logConversationFinishedEvent(event: ConversationFinishedEvent): void { @@ -1357,6 +1392,12 @@ export class ClearcutLogger { value: this.config?.isInteractive().toString() ?? 'false', }, ]; + if (this.config?.getExperiments()) { + defaultLogMetadata.push({ + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXPERIMENT_IDS, + value: this.config?.getExperiments()?.experimentIds.toString() ?? 'NA', + }); + } return [...data, ...defaultLogMetadata]; } diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index fc64f8a714b..333bbaf6fae 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -194,6 +194,9 @@ export enum EventMetadataKey { // Logs the name of the GitHub Action workflow that triggered the session. GEMINI_CLI_GH_WORKFLOW_NAME = 130, + // Logs the active experiment IDs for the session. + GEMINI_CLI_EXPERIMENT_IDS = 131, + // ========================================================================== // Loop Detected Event Keys // =========================================================================== diff --git a/packages/core/src/tools/smart-edit.test.ts b/packages/core/src/tools/smart-edit.test.ts index 293d586c67d..2a003f0b8a9 100644 --- a/packages/core/src/tools/smart-edit.test.ts +++ b/packages/core/src/tools/smart-edit.test.ts @@ -116,6 +116,7 @@ describe('SmartEditTool', () => { setGeminiMdFileCount: vi.fn(), getToolRegistry: () => ({}) as any, isInteractive: () => false, + getExperiments: () => {}, } as unknown as Config; (mockConfig.getApprovalMode as Mock).mockClear(); From c5492ae407ba376a8bb5277da394e1000db28a2f Mon Sep 17 00:00:00 2001 From: owenofbrien <86964623+owenofbrien@users.noreply.github.com> Date: Fri, 21 Nov 2025 15:20:15 -0600 Subject: [PATCH 23/32] Moved client id logging into createBasicLogEvent (#13607) --- .../clearcut-logger/clearcut-logger.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 8c3ea14da87..d6468816d93 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -292,8 +292,10 @@ export class ClearcutLogger { eventName: EventNames, data: EventValue[] = [], ): LogEvent { + const email = this.userAccountManager.getCachedGoogleAccount(); const surface = determineSurface(); const ghWorkflowName = determineGHWorkflowName(); + const baseMetadata: EventValue[] = [ ...data, { @@ -321,17 +323,24 @@ export class ClearcutLogger { }); } - return { + const logEvent: LogEvent = { console_type: 'GEMINI_CLI', application: 102, // GEMINI_CLI event_name: eventName as string, event_metadata: [baseMetadata], }; + + // Should log either email or install ID, not both. See go/cloudmill-1p-oss-instrumentation#define-sessionable-id + if (email) { + logEvent.client_email = email; + } else { + logEvent.client_install_id = this.installationManager.getInstallationId(); + } + + return logEvent; } createLogEvent(eventName: EventNames, data: EventValue[] = []): LogEvent { - const email = this.userAccountManager.getCachedGoogleAccount(); - if (eventName !== EventNames.START_SESSION) { data.push(...this.sessionData); } @@ -339,16 +348,7 @@ export class ClearcutLogger { data = this.addDefaultFields(data, totalAccounts); - const logEvent = this.createBasicLogEvent(eventName, data); - - // Should log either email or install ID, not both. See go/cloudmill-1p-oss-instrumentation#define-sessionable-id - if (email) { - logEvent.client_email = email; - } else { - logEvent.client_install_id = this.installationManager.getInstallationId(); - } - - return logEvent; + return this.createBasicLogEvent(eventName, data); } flushIfNeeded(): void { From 6d007c0ed8f9c73dd7025646e1565ecc8c2e88af Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 21 Nov 2025 13:20:37 -0800 Subject: [PATCH 24/32] Restore bracketed paste mode after external editor exit (#13606) --- packages/cli/src/gemini.test.tsx | 4 ++++ packages/cli/src/ui/AppContainer.tsx | 2 ++ packages/cli/src/ui/hooks/useBracketedPaste.ts | 12 ++++++------ packages/cli/src/ui/utils/bracketedPaste.ts | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/ui/utils/bracketedPaste.ts diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5b257ed6f5f..ef4efd8965a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -62,6 +62,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { write: vi.fn(), }, })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + enterAlternateScreen: vi.fn(), + disableLineWrapping: vi.fn(), }; }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2baf93f4d5c..2435976f9dd 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -116,6 +116,7 @@ import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js'; +import { enableBracketedPaste } from './utils/bracketedPaste.js'; const WARNING_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -387,6 +388,7 @@ export const AppContainer = (props: AppContainerProps) => { disableLineWrapping(); app.rerender(); } + enableBracketedPaste(); enableSupportedProtocol(); refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); diff --git a/packages/cli/src/ui/hooks/useBracketedPaste.ts b/packages/cli/src/ui/hooks/useBracketedPaste.ts index b8a763af42d..1e9cbbebcf3 100644 --- a/packages/cli/src/ui/hooks/useBracketedPaste.ts +++ b/packages/cli/src/ui/hooks/useBracketedPaste.ts @@ -5,10 +5,10 @@ */ import { useEffect } from 'react'; -import { writeToStdout } from '@google/gemini-cli-core'; - -const ENABLE_BRACKETED_PASTE = '\x1b[?2004h'; -const DISABLE_BRACKETED_PASTE = '\x1b[?2004l'; +import { + disableBracketedPaste, + enableBracketedPaste, +} from '../utils/bracketedPaste.js'; /** * Enables and disables bracketed paste mode in the terminal. @@ -18,11 +18,11 @@ const DISABLE_BRACKETED_PASTE = '\x1b[?2004l'; */ export const useBracketedPaste = () => { const cleanup = () => { - writeToStdout(DISABLE_BRACKETED_PASTE); + disableBracketedPaste(); }; useEffect(() => { - writeToStdout(ENABLE_BRACKETED_PASTE); + enableBracketedPaste(); process.on('exit', cleanup); process.on('SIGINT', cleanup); diff --git a/packages/cli/src/ui/utils/bracketedPaste.ts b/packages/cli/src/ui/utils/bracketedPaste.ts new file mode 100644 index 00000000000..26bb0e08fa7 --- /dev/null +++ b/packages/cli/src/ui/utils/bracketedPaste.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { writeToStdout } from '@google/gemini-cli-core'; + +const ENABLE_BRACKETED_PASTE = '\x1b[?2004h'; +const DISABLE_BRACKETED_PASTE = '\x1b[?2004l'; + +export const enableBracketedPaste = () => { + writeToStdout(ENABLE_BRACKETED_PASTE); +}; + +export const disableBracketedPaste = () => { + writeToStdout(DISABLE_BRACKETED_PASTE); +}; From 02f09db88c78729d766dbd5e8ba6b940aef3e050 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 21 Nov 2025 16:13:10 -0800 Subject: [PATCH 25/32] feat(core): Add support for custom aliases for model configs. (#13546) --- docs/get-started/configuration.md | 5 + packages/cli/src/config/settingsSchema.ts | 10 ++ .../src/services/modelConfigService.test.ts | 121 ++++++++++++++++++ .../core/src/services/modelConfigService.ts | 9 +- schemas/settings.schema.json | 8 ++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index ecbedc5c37f..101c0033589 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -494,6 +494,11 @@ their corresponding top-level category object in your `settings.json` file. } ``` +- **`modelConfigs.customAliases`** (object): + - **Description:** Custom named presets for model configs. These are merged + with (and override) the built-in aliases. + - **Default:** `{}` + - **`modelConfigs.overrides`** (array): - **Description:** Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2eb79584609..5222ed32f4b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -729,6 +729,16 @@ const SETTINGS_SCHEMA = { 'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.', showInDialog: false, }, + customAliases: { + type: 'object', + label: 'Custom Model Config Aliases', + category: 'Model', + requiresRestart: false, + default: {}, + description: + 'Custom named presets for model configs. These are merged with (and override) the built-in aliases.', + showInDialog: false, + }, overrides: { type: 'array', label: 'Model Config Overrides', diff --git a/packages/core/src/services/modelConfigService.test.ts b/packages/core/src/services/modelConfigService.test.ts index ba5773b9060..0ec08c45352 100644 --- a/packages/core/src/services/modelConfigService.test.ts +++ b/packages/core/src/services/modelConfigService.test.ts @@ -576,4 +576,125 @@ describe('ModelConfigService', () => { }); }); }); + + describe('custom aliases', () => { + it('should resolve a custom alias', () => { + const config: ModelConfigServiceConfig = { + aliases: {}, + customAliases: { + 'my-custom-alias': { + modelConfig: { + model: 'gemini-custom', + generateContentConfig: { + temperature: 0.9, + }, + }, + }, + }, + overrides: [], + }; + const service = new ModelConfigService(config); + const resolved = service.getResolvedConfig({ model: 'my-custom-alias' }); + + expect(resolved.model).toBe('gemini-custom'); + expect(resolved.generateContentConfig).toEqual({ + temperature: 0.9, + }); + }); + + it('should allow custom aliases to override built-in aliases', () => { + const config: ModelConfigServiceConfig = { + aliases: { + 'standard-alias': { + modelConfig: { + model: 'gemini-standard', + generateContentConfig: { + temperature: 0.5, + }, + }, + }, + }, + customAliases: { + 'standard-alias': { + modelConfig: { + model: 'gemini-custom-override', + generateContentConfig: { + temperature: 0.1, + }, + }, + }, + }, + overrides: [], + }; + const service = new ModelConfigService(config); + const resolved = service.getResolvedConfig({ model: 'standard-alias' }); + + expect(resolved.model).toBe('gemini-custom-override'); + expect(resolved.generateContentConfig).toEqual({ + temperature: 0.1, + }); + }); + }); + + describe('unrecognized models', () => { + it('should apply overrides to unrecognized model names', () => { + const unregisteredModelName = 'my-unregistered-model-v1'; + const config: ModelConfigServiceConfig = { + aliases: {}, // No aliases defined + overrides: [ + { + match: { model: unregisteredModelName }, + modelConfig: { + generateContentConfig: { + temperature: 0.01, + }, + }, + }, + ], + }; + const service = new ModelConfigService(config); + + // Request the unregistered model directly + const resolved = service.getResolvedConfig({ + model: unregisteredModelName, + }); + + // It should preserve the model name and apply the override + expect(resolved.model).toBe(unregisteredModelName); + expect(resolved.generateContentConfig).toEqual({ + temperature: 0.01, + }); + }); + + it('should apply scoped overrides to unrecognized model names', () => { + const unregisteredModelName = 'my-unregistered-model-v1'; + const config: ModelConfigServiceConfig = { + aliases: {}, + overrides: [ + { + match: { + model: unregisteredModelName, + overrideScope: 'special-agent', + }, + modelConfig: { + generateContentConfig: { + temperature: 0.99, + }, + }, + }, + ], + }; + const service = new ModelConfigService(config); + + const resolved = service.getResolvedConfig({ + model: unregisteredModelName, + overrideScope: 'special-agent', + }); + + expect(resolved.model).toBe(unregisteredModelName); + expect(resolved.generateContentConfig).toEqual({ + temperature: 0.99, + }); + }); + }); }); diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index eb9d8750a1a..9c7a5f5a020 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -43,6 +43,7 @@ export interface ModelConfigAlias { export interface ModelConfigServiceConfig { aliases?: Record; + customAliases?: Record; overrides?: ModelConfigOverride[]; } @@ -104,8 +105,12 @@ export class ModelConfigService { generateContentConfig: GenerateContentConfig; } { const config = this.config || {}; - const { aliases = {}, overrides = [] } = config; - const allAliases = { ...aliases, ...this.runtimeAliases }; + const { aliases = {}, customAliases = {}, overrides = [] } = config; + const allAliases = { + ...aliases, + ...customAliases, + ...this.runtimeAliases, + }; let baseModel: string | undefined = context.model; let resolvedConfig: GenerateContentConfig = {}; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f3301802b03..a8748521208 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -794,6 +794,14 @@ "type": "object", "additionalProperties": true }, + "customAliases": { + "title": "Custom Model Config Aliases", + "description": "Custom named presets for model configs. These are merged with (and override) the built-in aliases.", + "markdownDescription": "Custom named presets for model configs. These are merged with (and override) the built-in aliases.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": true + }, "overrides": { "title": "Model Config Overrides", "description": "Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.", From 41329a179e210d1d7f351e8e4e843e32abe1b590 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 21 Nov 2025 17:27:57 -0800 Subject: [PATCH 26/32] feat(core): Add `BaseLlmClient.generateContent`. (#13591) --- packages/core/src/core/baseLlmClient.test.ts | 141 +++++++++++- packages/core/src/core/baseLlmClient.ts | 213 +++++++++++++------ 2 files changed, 279 insertions(+), 75 deletions(-) diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index d924fabacbb..d1596451c16 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -12,6 +12,7 @@ import { beforeEach, afterEach, type Mocked, + type Mock, } from 'vitest'; import type { GenerateContentResponse } from '@google/genai'; @@ -299,6 +300,45 @@ describe('BaseLlmClient', () => { expect(result).toEqual({ color: 'orange' }); expect(logMalformedJsonResponse).not.toHaveBeenCalled(); }); + + it('should use the resolved model name when logging malformed JSON telemetry', async () => { + const aliasModel = 'fast-alias'; + const resolvedModel = 'gemini-1.5-flash'; + + // Override the mock for this specific test to simulate resolution + ( + mockConfig.modelConfigService.getResolvedConfig as unknown as Mock + ).mockReturnValue({ + model: resolvedModel, + generateContentConfig: { + temperature: 0, + topP: 1, + }, + }); + + const malformedResponse = '```json\n{"color": "red"}\n```'; + mockGenerateContent.mockResolvedValue( + createMockResponse(malformedResponse), + ); + + const options = { + ...defaultOptions, + modelConfigKey: { model: aliasModel }, + }; + + const result = await client.generateJson(options); + + expect(result).toEqual({ color: 'red' }); + + expect(logMalformedJsonResponse).toHaveBeenCalled(); + const calls = vi.mocked(logMalformedJsonResponse).mock.calls; + const lastCall = calls[calls.length - 1]; + const event = lastCall[1] as MalformedJsonResponseEvent; + + // This is the key assertion: it should be the resolved model, not the alias + expect(event.model).toBe(resolvedModel); + expect(event.model).not.toBe(aliasModel); + }); }); describe('generateJson - Error Handling', () => { @@ -306,14 +346,14 @@ describe('BaseLlmClient', () => { mockGenerateContent.mockResolvedValue(createMockResponse('')); await expect(client.generateJson(defaultOptions)).rejects.toThrow( - 'Failed to generate JSON content: Retry attempts exhausted for invalid content', + 'Failed to generate content: Retry attempts exhausted for invalid content', ); // Verify error reporting details expect(reportError).toHaveBeenCalledTimes(1); expect(reportError).toHaveBeenCalledWith( expect.any(Error), - 'API returned invalid content (empty or unparsable JSON) after all retries.', + 'API returned invalid content after all retries.', defaultOptions.contents, 'generateJson-invalid-content', ); @@ -324,13 +364,13 @@ describe('BaseLlmClient', () => { mockGenerateContent.mockResolvedValue(createMockResponse(invalidJson)); await expect(client.generateJson(defaultOptions)).rejects.toThrow( - 'Failed to generate JSON content: Retry attempts exhausted for invalid content', + 'Failed to generate content: Retry attempts exhausted for invalid content', ); expect(reportError).toHaveBeenCalledTimes(1); expect(reportError).toHaveBeenCalledWith( expect.any(Error), - 'API returned invalid content (empty or unparsable JSON) after all retries.', + 'API returned invalid content after all retries.', defaultOptions.contents, 'generateJson-invalid-content', ); @@ -342,14 +382,14 @@ describe('BaseLlmClient', () => { mockGenerateContent.mockRejectedValue(apiError); await expect(client.generateJson(defaultOptions)).rejects.toThrow( - 'Failed to generate JSON content: Service Unavailable (503)', + 'Failed to generate content: Service Unavailable (503)', ); // Verify generic error reporting expect(reportError).toHaveBeenCalledTimes(1); expect(reportError).toHaveBeenCalledWith( apiError, - 'Error generating JSON content via API.', + 'Error generating content via API.', defaultOptions.contents, 'generateJson-api', ); @@ -464,4 +504,93 @@ describe('BaseLlmClient', () => { ); }); }); + + describe('generateContent', () => { + it('should call generateContent with correct parameters and utilize retry mechanism', async () => { + const mockResponse = createMockResponse('This is the content.'); + mockGenerateContent.mockResolvedValue(mockResponse); + + const options = { + modelConfigKey: { model: 'test-model' }, + contents: [{ role: 'user', parts: [{ text: 'Give me content.' }] }], + abortSignal: abortController.signal, + promptId: 'content-prompt-id', + }; + + const result = await client.generateContent(options); + + expect(result).toBe(mockResponse); + + // Ensure the retry mechanism was engaged + expect(retryWithBackoff).toHaveBeenCalledTimes(1); + expect(retryWithBackoff).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + shouldRetryOnContent: expect.any(Function), + }), + ); + + // Validate the parameters passed to the underlying generator + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + expect(mockGenerateContent).toHaveBeenCalledWith( + { + model: 'test-model', + contents: options.contents, + config: { + abortSignal: options.abortSignal, + temperature: 0, + topP: 1, + }, + }, + 'content-prompt-id', + ); + }); + + it('should validate content using shouldRetryOnContent function', async () => { + const mockResponse = createMockResponse('Some valid content.'); + mockGenerateContent.mockResolvedValue(mockResponse); + + const options = { + modelConfigKey: { model: 'test-model' }, + contents: [{ role: 'user', parts: [{ text: 'Give me content.' }] }], + abortSignal: abortController.signal, + promptId: 'content-prompt-id', + }; + + await client.generateContent(options); + + const retryCall = vi.mocked(retryWithBackoff).mock.calls[0]; + const shouldRetryOnContent = retryCall[1]?.shouldRetryOnContent; + + // Valid content should not trigger retry + expect(shouldRetryOnContent!(mockResponse)).toBe(false); + + // Empty response should trigger retry + expect(shouldRetryOnContent!(createMockResponse(''))).toBe(true); + expect(shouldRetryOnContent!(createMockResponse(' '))).toBe(true); + }); + + it('should throw and report error for empty response after retry exhaustion', async () => { + mockGenerateContent.mockResolvedValue(createMockResponse('')); + const options = { + modelConfigKey: { model: 'test-model' }, + contents: [{ role: 'user', parts: [{ text: 'Give me content.' }] }], + abortSignal: abortController.signal, + promptId: 'content-prompt-id', + }; + + await expect(client.generateContent(options)).rejects.toThrow( + 'Failed to generate content: Retry attempts exhausted for invalid content', + ); + + // Verify error reporting details + expect(reportError).toHaveBeenCalledTimes(1); + expect(reportError).toHaveBeenCalledWith( + expect.any(Error), + 'API returned invalid content after all retries.', + options.contents, + 'generateContent-invalid-content', + ); + }); + }); }); diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index adaa3e3c39c..166579b1662 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -6,10 +6,10 @@ import type { Content, - GenerateContentConfig, Part, EmbedContentParameters, GenerateContentResponse, + GenerateContentParameters, } from '@google/genai'; import type { Config } from '../config/config.js'; import type { ContentGenerator } from './contentGenerator.js'; @@ -50,6 +50,31 @@ export interface GenerateJsonOptions { maxAttempts?: number; } +/** + * Options for the generateContent utility function. + */ +export interface GenerateContentOptions { + /** The desired model config. */ + modelConfigKey: ModelConfigKey; + /** The input prompt or history. */ + contents: Content[]; + /** + * Task-specific system instructions. + * If omitted, no system instruction is sent. + */ + systemInstruction?: string | Part | Part[] | Content; + /** Signal for cancellation. */ + abortSignal: AbortSignal; + /** + * A unique ID for the prompt, used for logging/telemetry correlation. + */ + promptId: string; + /** + * The maximum number of attempts for the request. + */ + maxAttempts?: number; +} + /** * A client dedicated to stateless, utility-focused LLM calls. */ @@ -63,87 +88,54 @@ export class BaseLlmClient { options: GenerateJsonOptions, ): Promise> { const { + schema, modelConfigKey, contents, - schema, - abortSignal, systemInstruction, + abortSignal, promptId, maxAttempts, } = options; const { model, generateContentConfig } = this.config.modelConfigService.getResolvedConfig(modelConfigKey); - const requestConfig: GenerateContentConfig = { - abortSignal, - ...generateContentConfig, - ...(systemInstruction && { systemInstruction }), - responseJsonSchema: schema, - responseMimeType: 'application/json', - }; - try { - const apiCall = () => - this.contentGenerator.generateContent( - { - model, - config: requestConfig, - contents, - }, - promptId, - ); - - const shouldRetryOnContent = (response: GenerateContentResponse) => { - const text = getResponseText(response)?.trim(); - if (!text) { - return true; // Retry on empty response - } - try { - JSON.parse(this.cleanJsonResponse(text, model)); - return false; - } catch (_e) { - return true; - } - }; - - const result = await retryWithBackoff(apiCall, { - shouldRetryOnContent, - maxAttempts: maxAttempts ?? DEFAULT_MAX_ATTEMPTS, - }); - - // If we are here, the content is valid (not empty and parsable). - return JSON.parse( - this.cleanJsonResponse(getResponseText(result)!.trim(), model), - ); - } catch (error) { - if (abortSignal.aborted) { - throw error; + const shouldRetryOnContent = (response: GenerateContentResponse) => { + const text = getResponseText(response)?.trim(); + if (!text) { + return true; // Retry on empty response } - - // Check if the error is from exhausting retries, and report accordingly. - if ( - error instanceof Error && - error.message.includes('Retry attempts exhausted') - ) { - await reportError( - error, - 'API returned invalid content (empty or unparsable JSON) after all retries.', - contents, - 'generateJson-invalid-content', - ); - } else { - await reportError( - error, - 'Error generating JSON content via API.', - contents, - 'generateJson-api', - ); + try { + // We don't use the result, just check if it's valid JSON + JSON.parse(this.cleanJsonResponse(text, model)); + return false; // It's valid, don't retry + } catch (_e) { + return true; // It's not valid, retry } + }; - throw new Error( - `Failed to generate JSON content: ${getErrorMessage(error)}`, - ); - } + const result = await this._generateWithRetry( + { + model, + contents, + config: { + ...generateContentConfig, + ...(systemInstruction && { systemInstruction }), + responseJsonSchema: schema, + responseMimeType: 'application/json', + abortSignal, + }, + }, + promptId, + maxAttempts, + shouldRetryOnContent, + 'generateJson', + ); + + // If we are here, the content is valid (not empty and parsable). + return JSON.parse( + this.cleanJsonResponse(getResponseText(result)!.trim(), model), + ); } async generateEmbedding(texts: string[]): Promise { @@ -193,4 +185,87 @@ export class BaseLlmClient { } return text; } + + async generateContent( + options: GenerateContentOptions, + ): Promise { + const { + modelConfigKey, + contents, + systemInstruction, + abortSignal, + promptId, + maxAttempts, + } = options; + + const { model, generateContentConfig } = + this.config.modelConfigService.getResolvedConfig(modelConfigKey); + + const shouldRetryOnContent = (response: GenerateContentResponse) => { + const text = getResponseText(response)?.trim(); + return !text; // Retry on empty response + }; + + return this._generateWithRetry( + { + model, + contents, + config: { + ...generateContentConfig, + ...(systemInstruction && { systemInstruction }), + abortSignal, + }, + }, + promptId, + maxAttempts, + shouldRetryOnContent, + 'generateContent', + ); + } + + private async _generateWithRetry( + requestParams: GenerateContentParameters, + promptId: string, + maxAttempts: number | undefined, + shouldRetryOnContent: (response: GenerateContentResponse) => boolean, + errorContext: 'generateJson' | 'generateContent', + ): Promise { + const abortSignal = requestParams.config?.abortSignal; + + try { + const apiCall = () => + this.contentGenerator.generateContent(requestParams, promptId); + + return await retryWithBackoff(apiCall, { + shouldRetryOnContent, + maxAttempts: maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + }); + } catch (error) { + if (abortSignal?.aborted) { + throw error; + } + + // Check if the error is from exhausting retries, and report accordingly. + if ( + error instanceof Error && + error.message.includes('Retry attempts exhausted') + ) { + await reportError( + error, + `API returned invalid content after all retries.`, + requestParams.contents as Content[], + `${errorContext}-invalid-content`, + ); + } else { + await reportError( + error, + `Error generating content via API.`, + requestParams.contents as Content[], + `${errorContext}-api`, + ); + } + + throw new Error(`Failed to generate content: ${getErrorMessage(error)}`); + } + } } From 1cd836355db63256f8ec16f684cf1e3bded6237c Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 21 Nov 2025 17:30:38 -0800 Subject: [PATCH 27/32] Turn off alternate buffer mode by default. (#13623) --- docs/get-started/configuration.md | 2 +- integration-tests/test-helper.ts | 3 +++ packages/cli/src/config/settingsSchema.ts | 2 +- packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/test-utils/render.tsx | 2 +- packages/cli/src/ui/hooks/useAlternateBuffer.ts | 2 +- schemas/settings.schema.json | 4 ++-- 7 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 101c0033589..a7a7213df23 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -240,7 +240,7 @@ their corresponding top-level category object in your `settings.json` file. - **`ui.useAlternateBuffer`** (boolean): - **Description:** Use an alternate screen buffer for the UI, preserving shell history. - - **Default:** `true` + - **Default:** `false` - **Requires restart:** Yes - **`ui.incrementalRendering`** (boolean): diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index e83939420cc..94d3e697b8c 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -321,6 +321,9 @@ export class TestRig { selectedType: 'gemini-api-key', }, }, + ui: { + useAlternateBuffer: true, + }, model: DEFAULT_GEMINI_MODEL, sandbox: env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5222ed32f4b..0fcae7874a5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -516,7 +516,7 @@ const SETTINGS_SCHEMA = { label: 'Use Alternate Screen Buffer', category: 'UI', requiresRestart: true, - default: true, + default: false, description: 'Use an alternate screen buffer for the UI, preserving shell history.', showInDialog: true, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index ef4efd8965a..8caca0c5c47 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -509,6 +509,7 @@ describe('startInteractiveUI', () => { merged: { ui: { hideWindowTitle: false, + useAlternateBuffer: true, }, }, } as LoadedSettings; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 0cc3ab12511..74335963b1a 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -176,7 +176,7 @@ export const renderWithProviders = ( width, mouseEventsEnabled = false, config = configProxy as unknown as Config, - useAlternateBuffer, + useAlternateBuffer = true, uiActions, }: { shellFocus?: boolean; diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index efa8fbf3cf8..ba5dd460ee5 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -8,7 +8,7 @@ import { useSettings } from '../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../config/settings.js'; export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean => - settings.merged.ui?.useAlternateBuffer !== false; + settings.merged.ui?.useAlternateBuffer === true; export const useAlternateBuffer = (): boolean => { const settings = useSettings(); diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a8748521208..8f2f74f7f51 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -292,8 +292,8 @@ "useAlternateBuffer": { "title": "Use Alternate Screen Buffer", "description": "Use an alternate screen buffer for the UI, preserving shell history.", - "markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, + "markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, "type": "boolean" }, "incrementalRendering": { From 6fd1b4cbb4becb92b2a5c71ff68e5cd1cac3ea79 Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 21 Nov 2025 21:08:06 -0500 Subject: [PATCH 28/32] fix(cli): Prevent stdout/stderr patching for extension commands (#13600) Co-authored-by: jacob314 --- packages/cli/src/commands/extensions.tsx | 2 ++ .../cli/src/commands/extensions/disable.test.ts | 3 +++ packages/cli/src/commands/extensions/disable.ts | 2 ++ packages/cli/src/commands/extensions/enable.test.ts | 3 +++ packages/cli/src/commands/extensions/enable.ts | 2 ++ .../cli/src/commands/extensions/install.test.ts | 4 ++++ packages/cli/src/commands/extensions/install.ts | 2 ++ packages/cli/src/commands/extensions/link.test.ts | 3 +++ packages/cli/src/commands/extensions/link.ts | 2 ++ packages/cli/src/commands/extensions/list.test.ts | 3 +++ packages/cli/src/commands/extensions/list.ts | 2 ++ packages/cli/src/commands/extensions/new.test.ts | 3 +++ packages/cli/src/commands/extensions/new.ts | 2 ++ .../cli/src/commands/extensions/uninstall.test.ts | 3 +++ packages/cli/src/commands/extensions/uninstall.ts | 2 ++ packages/cli/src/commands/extensions/update.test.ts | 3 +++ packages/cli/src/commands/extensions/update.ts | 2 ++ .../cli/src/commands/extensions/validate.test.ts | 4 ++++ packages/cli/src/commands/extensions/validate.ts | 2 ++ packages/cli/src/commands/mcp.test.ts | 1 + packages/cli/src/commands/mcp.ts | 2 ++ packages/cli/src/commands/mcp/add.test.ts | 4 ++++ packages/cli/src/commands/mcp/add.ts | 2 ++ packages/cli/src/commands/mcp/list.test.ts | 4 ++++ packages/cli/src/commands/mcp/list.ts | 2 ++ packages/cli/src/commands/mcp/remove.test.ts | 4 ++++ packages/cli/src/commands/mcp/remove.ts | 2 ++ packages/cli/src/commands/utils.ts | 12 ++++++++++++ packages/cli/src/config/config.ts | 13 ------------- packages/cli/src/gemini.tsx | 2 +- 30 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/commands/utils.ts diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 42516dcea32..fe4c48059cf 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { validateCommand } from './extensions/validate.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -21,6 +22,7 @@ export const extensionsCommand: CommandModule = { describe: 'Manage Gemini CLI extensions.', builder: (yargs) => yargs + .middleware(() => initializeOutputListenersAndFlush()) .command(installCommand) .command(uninstallCommand) .command(listCommand) diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts index ce022e826f3..128a313452d 100644 --- a/packages/cli/src/commands/extensions/disable.test.ts +++ b/packages/cli/src/commands/extensions/disable.test.ts @@ -56,6 +56,9 @@ vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions disable command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 623e80f06b6..c36d32856c7 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -11,6 +11,7 @@ import { debugLogger } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface DisableArgs { name: string; @@ -81,5 +82,6 @@ export const disableCommand: CommandModule = { name: argv['name'] as string, scope: argv['scope'] as string, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts index 4f58e67eebc..45fc38b5fef 100644 --- a/packages/cli/src/commands/extensions/enable.test.ts +++ b/packages/cli/src/commands/extensions/enable.test.ts @@ -58,6 +58,9 @@ vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js'); vi.mock('../../config/extensions/extensionSettings.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions enable command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index a2105739631..8e29389f898 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -14,6 +14,7 @@ import { getErrorMessage, } from '@google/gemini-cli-core'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface EnableArgs { name: string; @@ -86,5 +87,6 @@ export const enableCommand: CommandModule = { name: argv['name'] as string, scope: argv['scope'] as string, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 7b2f4466fc0..8f861246e64 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -48,6 +48,10 @@ vi.mock('node:fs/promises', () => ({ }, })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + describe('extensions install command', () => { it('should fail if no source is provided', () => { const validationParser = yargs([]).command(installCommand).fail(false); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 0eae10341ab..0420bd14f3e 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -18,6 +18,7 @@ import { import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface InstallArgs { source: string; @@ -130,5 +131,6 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, consent: argv['consent'] as boolean | undefined, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts index c694a7d526e..252d704d533 100644 --- a/packages/cli/src/commands/extensions/link.test.ts +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -52,6 +52,9 @@ vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions link command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 70b6b3e0020..845475e551b 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -15,6 +15,7 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface InstallArgs { path: string; @@ -60,5 +61,6 @@ export const linkCommand: CommandModule = { await handleLink({ path: argv['path'] as string, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts index 1f8b0b2ec22..5ef259277c1 100644 --- a/packages/cli/src/commands/extensions/list.test.ts +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -44,6 +44,9 @@ vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions list command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 4596f95cd95..6faa795bd70 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -11,6 +11,7 @@ import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; export async function handleList() { try { @@ -45,5 +46,6 @@ export const listCommand: CommandModule = { builder: (yargs) => yargs, handler: async () => { await handleList(); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/new.test.ts b/packages/cli/src/commands/extensions/new.test.ts index 62c9edcece3..2400ac61a78 100644 --- a/packages/cli/src/commands/extensions/new.test.ts +++ b/packages/cli/src/commands/extensions/new.test.ts @@ -11,6 +11,9 @@ import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; vi.mock('node:fs/promises'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); const mockedFs = vi.mocked(fsPromises); diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts index 5bd6a12d6d7..75cfff7370e 100644 --- a/packages/cli/src/commands/extensions/new.ts +++ b/packages/cli/src/commands/extensions/new.ts @@ -9,6 +9,7 @@ import { join, dirname, basename } from 'node:path'; import type { CommandModule } from 'yargs'; import { fileURLToPath } from 'node:url'; import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; interface NewArgs { path: string; @@ -100,5 +101,6 @@ export const newCommand: CommandModule = { path: args['path'] as string, template: args['template'] as string | undefined, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index c2c2584a787..8842da960b8 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -75,6 +75,9 @@ vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions uninstall command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 52f9ad37e02..5e94cd598b9 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -11,6 +11,7 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface UninstallArgs { names: string[]; // can be extension names or source URLs. @@ -72,5 +73,6 @@ export const uninstallCommand: CommandModule = { await handleUninstall({ names: argv['names'] as string[], }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts index 0979551aaa2..a88b06b429c 100644 --- a/packages/cli/src/commands/extensions/update.test.ts +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -56,6 +56,9 @@ vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); describe('extensions update command', () => { const mockLoadSettings = vi.mocked(loadSettings); diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 5488bacde77..ba7c8658990 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -19,6 +19,7 @@ import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; interface UpdateArgs { name?: string; @@ -144,5 +145,6 @@ export const updateCommand: CommandModule = { name: argv['name'] as string | undefined, all: argv['all'] as boolean | undefined, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/extensions/validate.test.ts b/packages/cli/src/commands/extensions/validate.test.ts index 758295213d1..3d5aed0df50 100644 --- a/packages/cli/src/commands/extensions/validate.test.ts +++ b/packages/cli/src/commands/extensions/validate.test.ts @@ -13,6 +13,10 @@ import path from 'node:path'; import * as os from 'node:os'; import { debugLogger } from '@google/gemini-cli-core'; +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + describe('extensions validate command', () => { it('should fail if no path is provided', () => { const validationParser = yargs([]).command(validateCommand).fail(false); diff --git a/packages/cli/src/commands/extensions/validate.ts b/packages/cli/src/commands/extensions/validate.ts index c8cacc8b4ff..b516d767554 100644 --- a/packages/cli/src/commands/extensions/validate.ts +++ b/packages/cli/src/commands/extensions/validate.ts @@ -15,6 +15,7 @@ import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { loadSettings } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; interface ValidateArgs { path: string; @@ -101,5 +102,6 @@ export const validateCommand: CommandModule = { await handleValidate({ path: args['path'] as string, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index f94da0febfb..4e476ddad68 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -56,6 +56,7 @@ describe('mcp command', () => { command: vi.fn().mockReturnThis(), demandCommand: vi.fn().mockReturnThis(), version: vi.fn().mockReturnThis(), + middleware: vi.fn().mockReturnThis(), }; (mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 5e55286c1db..f09680dbdd3 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -9,12 +9,14 @@ import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; export const mcpCommand: CommandModule = { command: 'mcp', describe: 'Manage MCP servers', builder: (yargs: Argv) => yargs + .middleware(() => initializeOutputListenersAndFlush()) .command(addCommand) .command(removeCommand) .command(listCommand) diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts index 4b17ec75f24..12fba04b15f 100644 --- a/packages/cli/src/commands/mcp/add.test.ts +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -10,6 +10,10 @@ import { addCommand } from './add.js'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + vi.mock('fs/promises', () => ({ readFile: vi.fn(), writeFile: vi.fn(), diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index a08dcb6526e..b960736e4ce 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -8,6 +8,7 @@ import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; async function addMcpServer( name: string, @@ -230,5 +231,6 @@ export const addCommand: CommandModule = { excludeTools: argv['excludeTools'] as string[] | undefined, }, ); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index d2e50d8f3e8..7d78d482331 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -44,6 +44,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); vi.mock('@modelcontextprotocol/sdk/client/index.js'); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + const mockedGetUserExtensionsDir = ExtensionStorage.getUserExtensionsDir as Mock; const mockedLoadSettings = loadSettings as Mock; diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 9b5571d1348..793a017eab3 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -17,6 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { exitCli } from '../utils.js'; const COLOR_GREEN = '\u001b[32m'; const COLOR_YELLOW = '\u001b[33m'; @@ -145,5 +146,6 @@ export const listCommand: CommandModule = { describe: 'List all configured MCP servers', handler: async () => { await listMcpServers(); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts index 3a58f9afc35..021b9c12d60 100644 --- a/packages/cli/src/commands/mcp/remove.test.ts +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -26,6 +26,10 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), })); +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + describe('mcp remove command', () => { describe('unit tests with mocks', () => { let parser: Argv; diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts index dda8e2d5a1c..f0f6b1fba62 100644 --- a/packages/cli/src/commands/mcp/remove.ts +++ b/packages/cli/src/commands/mcp/remove.ts @@ -8,6 +8,7 @@ import type { CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; async function removeMcpServer( name: string, @@ -57,5 +58,6 @@ export const removeCommand: CommandModule = { await removeMcpServer(argv['name'] as string, { scope: argv['scope'] as string, }); + await exitCli(); }, }; diff --git a/packages/cli/src/commands/utils.ts b/packages/cli/src/commands/utils.ts new file mode 100644 index 00000000000..33c5c142a98 --- /dev/null +++ b/packages/cli/src/commands/utils.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { runExitCleanup } from '../utils/cleanup.js'; + +export async function exitCli(exitCode = 0) { + await runExitCleanup(); + process.exit(exitCode); +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8bd63473ba0..c4c56424a58 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -312,19 +312,6 @@ export async function parseArguments(settings: Settings): Promise { process.exit(0); } - // If yargs handled --help/--version it will have exited; nothing to do here. - - // Handle case where MCP subcommands are executed - they should exit the process - // and not return to main CLI logic - if ( - result._.length > 0 && - (result._[0] === 'mcp' || result._[0] === 'extensions') - ) { - // MCP commands handle their own execution and process exit - await runExitCleanup(); - process.exit(0); - } - // Normalize query args: handle both quoted "@path file" and unquoted @path file const queryArg = (result as { query?: string | string[] | undefined }).query; const q: string | undefined = Array.isArray(queryArg) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b4983e94018..d5acb02f896 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -634,7 +634,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) { } } -function initializeOutputListenersAndFlush() { +export function initializeOutputListenersAndFlush() { // If there are no listeners for output, make sure we flush so output is not // lost. if (coreEvents.listenerCount(CoreEvent.Output) === 0) { From b2c4954de82710eba1cddf1c9d586e4b622ea9ff Mon Sep 17 00:00:00 2001 From: Megha Bansal Date: Sat, 22 Nov 2025 08:17:29 +0530 Subject: [PATCH 29/32] Improve test coverage for cli/src/ui/components (#13598) --- .../cli/src/ui/components/AboutBox.test.tsx | 57 +++ .../components/AutoAcceptIndicator.test.tsx | 39 +++ .../cli/src/ui/components/Banner.test.tsx | 29 ++ .../ui/components/ConfigInitDisplay.test.tsx | 117 +++++++ .../components/ConsoleSummaryDisplay.test.tsx | 27 ++ .../components/ContextUsageDisplay.test.tsx | 61 ++++ .../ui/components/CopyModeWarning.test.tsx | 37 ++ .../src/ui/components/DebugProfiler.test.tsx | 53 +++ .../DetailedMessagesDisplay.test.tsx | 92 +++++ .../src/ui/components/DialogManager.test.tsx | 180 ++++++++++ .../components/EditorSettingsDialog.test.tsx | 167 +++++++++ .../src/ui/components/ExitWarning.test.tsx | 60 ++++ .../GeminiRespondingSpinner.test.tsx | 72 ++++ .../src/ui/components/MainContent.test.tsx | 103 ++++++ .../ui/components/MemoryUsageDisplay.test.tsx | 55 +++ .../src/ui/components/Notifications.test.tsx | 183 ++++++++++ .../cli/src/ui/components/Notifications.tsx | 9 +- .../ui/components/QuittingDisplay.test.tsx | 54 +++ .../components/RawMarkdownIndicator.test.tsx | 37 ++ .../src/ui/components/SessionBrowser.test.tsx | 29 +- .../ui/components/ShellInputPrompt.test.tsx | 105 ++++++ .../ui/components/ShellModeIndicator.test.tsx | 17 + .../src/ui/components/ShowMoreLines.test.tsx | 54 +++ .../ui/components/SuggestionsDisplay.test.tsx | 124 +++++++ .../src/ui/components/ThemedGradient.test.tsx | 32 ++ packages/cli/src/ui/components/Tips.test.tsx | 25 ++ .../ui/components/UpdateNotification.test.tsx | 18 + .../__snapshots__/Banner.test.tsx.snap | 20 ++ .../ConfigInitDisplay.test.tsx.snap | 16 + .../EditorSettingsDialog.test.tsx.snap | 18 + .../__snapshots__/Notifications.test.tsx.snap | 22 ++ .../SessionBrowser.test.tsx.snap | 32 ++ .../SuggestionsDisplay.test.tsx.snap | 31 ++ .../components/messages/ErrorMessage.test.tsx | 26 ++ .../components/messages/InfoMessage.test.tsx | 35 ++ .../messages/ToolConfirmationMessage.test.tsx | 13 +- .../components/messages/ToolMessage.test.tsx | 34 +- .../messages/ToolResultDisplay.test.tsx | 191 ++++++++++ .../components/messages/ToolResultDisplay.tsx | 2 +- .../components/messages/UserMessage.test.tsx | 40 +++ .../messages/WarningMessage.test.tsx | 26 ++ .../__snapshots__/ErrorMessage.test.tsx.snap | 12 + .../__snapshots__/InfoMessage.test.tsx.snap | 17 + .../ToolConfirmationMessage.test.tsx.snap | 123 +++++++ .../__snapshots__/ToolMessage.test.tsx.snap | 96 ++++++ .../ToolResultDisplay.test.tsx.snap | 326 ++++++++++++++++++ .../__snapshots__/UserMessage.test.tsx.snap | 20 ++ .../WarningMessage.test.tsx.snap | 12 + 48 files changed, 2897 insertions(+), 51 deletions(-) create mode 100644 packages/cli/src/ui/components/AboutBox.test.tsx create mode 100644 packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx create mode 100644 packages/cli/src/ui/components/Banner.test.tsx create mode 100644 packages/cli/src/ui/components/ConfigInitDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/ContextUsageDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/CopyModeWarning.test.tsx create mode 100644 packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/DialogManager.test.tsx create mode 100644 packages/cli/src/ui/components/EditorSettingsDialog.test.tsx create mode 100644 packages/cli/src/ui/components/ExitWarning.test.tsx create mode 100644 packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx create mode 100644 packages/cli/src/ui/components/MainContent.test.tsx create mode 100644 packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/Notifications.test.tsx create mode 100644 packages/cli/src/ui/components/QuittingDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx create mode 100644 packages/cli/src/ui/components/ShellInputPrompt.test.tsx create mode 100644 packages/cli/src/ui/components/ShellModeIndicator.test.tsx create mode 100644 packages/cli/src/ui/components/ShowMoreLines.test.tsx create mode 100644 packages/cli/src/ui/components/SuggestionsDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/ThemedGradient.test.tsx create mode 100644 packages/cli/src/ui/components/Tips.test.tsx create mode 100644 packages/cli/src/ui/components/UpdateNotification.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/ErrorMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/InfoMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/UserMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/WarningMessage.test.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx new file mode 100644 index 00000000000..b6e5968e533 --- /dev/null +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { AboutBox } from './AboutBox.js'; +import { describe, it, expect, vi } from 'vitest'; + +// Mock GIT_COMMIT_INFO +vi.mock('../../generated/git-commit.js', () => ({ + GIT_COMMIT_INFO: 'mock-commit-hash', +})); + +describe('AboutBox', () => { + const defaultProps = { + cliVersion: '1.0.0', + osVersion: 'macOS', + sandboxEnv: 'default', + modelVersion: 'gemini-pro', + selectedAuthType: 'oauth', + gcpProject: '', + ideClient: '', + }; + + it('renders with required props', () => { + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain('About Gemini CLI'); + expect(output).toContain('1.0.0'); + expect(output).toContain('mock-commit-hash'); + expect(output).toContain('gemini-pro'); + expect(output).toContain('default'); + expect(output).toContain('macOS'); + expect(output).toContain('OAuth'); + }); + + it.each([ + ['userEmail', 'test@example.com', 'User Email'], + ['gcpProject', 'my-project', 'GCP Project'], + ['ideClient', 'vscode', 'IDE Client'], + ])('renders optional prop %s', (prop, value, label) => { + const props = { ...defaultProps, [prop]: value }; + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain(label); + expect(output).toContain(value); + }); + + it('renders Auth Method correctly when not oauth', () => { + const props = { ...defaultProps, selectedAuthType: 'api-key' }; + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain('api-key'); + }); +}); diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx new file mode 100644 index 00000000000..d71d49d2f1d --- /dev/null +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { describe, it, expect } from 'vitest'; +import { ApprovalMode } from '@google/gemini-cli-core'; + +describe('AutoAcceptIndicator', () => { + it('renders correctly for AUTO_EDIT mode', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('accepting edits'); + expect(output).toContain('(shift + tab to toggle)'); + }); + + it('renders correctly for YOLO mode', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('YOLO mode'); + expect(output).toContain('(ctrl + y to toggle)'); + }); + + it('renders nothing for DEFAULT mode', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('accepting edits'); + expect(output).not.toContain('YOLO mode'); + }); +}); diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx new file mode 100644 index 00000000000..ec50b25510e --- /dev/null +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { Banner } from './Banner.js'; +import { describe, it, expect } from 'vitest'; + +describe('Banner', () => { + it.each([ + ['warning mode', true, 'Warning Message'], + ['info mode', false, 'Info Message'], + ])('renders in %s', (_, isWarning, text) => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('handles newlines in text', () => { + const text = 'Line 1\\nLine 2'; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx new file mode 100644 index 00000000000..ed4a60a1de4 --- /dev/null +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from '../../test-utils/render.js'; +import { ConfigInitDisplay } from './ConfigInitDisplay.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppEvent } from '../../utils/events.js'; +import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core'; +import { Text } from 'ink'; + +// Mock GeminiSpinner +vi.mock('./GeminiRespondingSpinner.js', () => ({ + GeminiSpinner: () => Spinner, +})); + +// Mock appEvents +const { mockOn, mockOff, mockEmit } = vi.hoisted(() => ({ + mockOn: vi.fn(), + mockOff: vi.fn(), + mockEmit: vi.fn(), +})); + +vi.mock('../../utils/events.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appEvents: { + on: mockOn, + off: mockOff, + emit: mockEmit, + }, + }; +}); + +describe('ConfigInitDisplay', () => { + beforeEach(() => { + mockOn.mockClear(); + mockOff.mockClear(); + mockEmit.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders initial state', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('updates message on McpClientUpdate event', async () => { + let listener: ((clients?: Map) => void) | undefined; + mockOn.mockImplementation((event, fn) => { + if (event === AppEvent.McpClientUpdate) { + listener = fn; + } + }); + + const { lastFrame } = render(); + + // Wait for listener to be registered + await vi.waitFor(() => { + if (!listener) throw new Error('Listener not registered yet'); + }); + + const mockClient1 = { + getStatus: () => MCPServerStatus.CONNECTED, + } as McpClient; + const mockClient2 = { + getStatus: () => MCPServerStatus.CONNECTING, + } as McpClient; + const clients = new Map([ + ['server1', mockClient1], + ['server2', mockClient2], + ]); + + // Trigger the listener manually since we mocked the event emitter + act(() => { + listener!(clients); + }); + + // Wait for the UI to update + await vi.waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('handles empty clients map', async () => { + let listener: ((clients?: Map) => void) | undefined; + mockOn.mockImplementation((event, fn) => { + if (event === AppEvent.McpClientUpdate) { + listener = fn; + } + }); + + const { lastFrame } = render(); + + await vi.waitFor(() => { + if (!listener) throw new Error('Listener not registered yet'); + }); + + if (listener) { + const safeListener = listener; + act(() => { + safeListener(new Map()); + }); + } + + await vi.waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx new file mode 100644 index 00000000000..aacb90e1e39 --- /dev/null +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; +import { describe, it, expect } from 'vitest'; + +describe('ConsoleSummaryDisplay', () => { + it('renders nothing when errorCount is 0', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it.each([ + [1, '1 error'], + [5, '5 errors'], + ])('renders correct message for %i errors', (count, expectedText) => { + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain(expectedText); + expect(output).toContain('✖'); + expect(output).toContain('(F12 for details)'); + }); +}); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx new file mode 100644 index 00000000000..caa81ee9685 --- /dev/null +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ContextUsageDisplay } from './ContextUsageDisplay.js'; +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@google/gemini-cli-core', () => ({ + tokenLimit: () => 10000, +})); + +vi.mock('../../config/settings.js', () => ({ + DEFAULT_MODEL_CONFIGS: {}, + LoadedSettings: class { + constructor() { + // this.merged = {}; + } + }, +})); + +describe('ContextUsageDisplay', () => { + it('renders correct percentage left', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('50% context left'); + }); + + it('renders short label when terminal width is small', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('80%'); + expect(output).not.toContain('context left'); + }); + + it('renders 0% when full', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('0% context left'); + }); +}); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx new file mode 100644 index 00000000000..63ca84369f8 --- /dev/null +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { CopyModeWarning } from './CopyModeWarning.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useUIState, type UIState } from '../contexts/UIStateContext.js'; + +vi.mock('../contexts/UIStateContext.js'); + +describe('CopyModeWarning', () => { + const mockUseUIState = vi.mocked(useUIState); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when copy mode is disabled', () => { + mockUseUIState.mockReturnValue({ + copyModeEnabled: false, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('renders warning when copy mode is enabled', () => { + mockUseUIState.mockReturnValue({ + copyModeEnabled: true, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('In Copy Mode'); + expect(lastFrame()).toContain('Press any key to exit'); + }); +}); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index 164b90962af..b60419be8a8 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -8,12 +8,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { appEvents, AppEvent } from '../../utils/events.js'; import { profiler, + DebugProfiler, ACTION_TIMESTAMP_CAPACITY, FRAME_TIMESTAMP_CAPACITY, } from './DebugProfiler.js'; +import { render } from '../../test-utils/render.js'; +import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { FixedDeque } from 'mnemonist'; import { debugState } from '../debug.js'; +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: vi.fn(), +})); + describe('DebugProfiler', () => { beforeEach(() => { vi.useFakeTimers(); @@ -214,3 +221,49 @@ describe('DebugProfiler', () => { expect(profiler.totalIdleFrames).toBe(0); }); }); + +describe('DebugProfiler Component', () => { + beforeEach(() => { + // Reset the mock implementation before each test + vi.mocked(useUIState).mockReturnValue({ + showDebugProfiler: false, + constrainHeight: false, + } as unknown as UIState); + + // Mock process.stdin and stdout + // We need to be careful not to break the test runner's own output + // So we might want to skip mocking them if they are not strictly needed for the simple render test + // or mock them safely. + // For now, let's assume the component uses them in useEffect. + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null when showDebugProfiler is false', () => { + vi.mocked(useUIState).mockReturnValue({ + showDebugProfiler: false, + constrainHeight: false, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('should render stats when showDebugProfiler is true', () => { + vi.mocked(useUIState).mockReturnValue({ + showDebugProfiler: true, + constrainHeight: false, + } as unknown as UIState); + profiler.numFrames = 10; + profiler.totalIdleFrames = 5; + profiler.totalFlickerFrames = 2; + + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain('Renders: 10 (total)'); + expect(output).toContain('5 (idle)'); + expect(output).toContain('2 (flicker)'); + }); +}); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx new file mode 100644 index 00000000000..203b2364c78 --- /dev/null +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; +import { describe, it, expect, vi } from 'vitest'; +import type { ConsoleMessageItem } from '../types.js'; +import { Box } from 'ink'; +import type React from 'react'; + +vi.mock('./shared/ScrollableList.js', () => ({ + ScrollableList: ({ + data, + renderItem, + }: { + data: unknown[]; + renderItem: (props: { item: unknown }) => React.ReactNode; + }) => ( + + {data.map((item: unknown, index: number) => ( + {renderItem({ item })} + ))} + + ), +})); + +describe('DetailedMessagesDisplay', () => { + it('renders nothing when messages are empty', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders messages correctly', () => { + const messages: ConsoleMessageItem[] = [ + { type: 'log', content: 'Log message', count: 1 }, + { type: 'warn', content: 'Warning message', count: 1 }, + { type: 'error', content: 'Error message', count: 1 }, + { type: 'debug', content: 'Debug message', count: 1 }, + ]; + + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Debug Console'); + expect(output).toContain('Log message'); + expect(output).toContain('Warning message'); + expect(output).toContain('Error message'); + expect(output).toContain('Debug message'); + + // Check for icons + expect(output).toContain('ℹ'); + expect(output).toContain('⚠'); + expect(output).toContain('✖'); + expect(output).toContain('🔍'); + }); + + it('renders message counts', () => { + const messages: ConsoleMessageItem[] = [ + { type: 'log', content: 'Repeated message', count: 5 }, + ]; + + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Repeated message'); + expect(output).toContain('(x5)'); + }); +}); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx new file mode 100644 index 00000000000..7477cc3cf41 --- /dev/null +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { DialogManager } from './DialogManager.js'; +import { describe, it, expect, vi } from 'vitest'; +import { Text } from 'ink'; +import { type UIState } from '../contexts/UIStateContext.js'; +import { type RestartReason } from '../hooks/useIdeTrustListener.js'; +import { type IdeInfo } from '@google/gemini-cli-core'; +import { type ShellConfirmationRequest } from '../types.js'; + +// Mock child components +vi.mock('../IdeIntegrationNudge.js', () => ({ + IdeIntegrationNudge: () => IdeIntegrationNudge, +})); +vi.mock('./LoopDetectionConfirmation.js', () => ({ + LoopDetectionConfirmation: () => LoopDetectionConfirmation, +})); +vi.mock('./FolderTrustDialog.js', () => ({ + FolderTrustDialog: () => FolderTrustDialog, +})); +vi.mock('./ShellConfirmationDialog.js', () => ({ + ShellConfirmationDialog: () => ShellConfirmationDialog, +})); +vi.mock('./ConsentPrompt.js', () => ({ + ConsentPrompt: () => ConsentPrompt, +})); +vi.mock('./ThemeDialog.js', () => ({ + ThemeDialog: () => ThemeDialog, +})); +vi.mock('./SettingsDialog.js', () => ({ + SettingsDialog: () => SettingsDialog, +})); +vi.mock('../auth/AuthInProgress.js', () => ({ + AuthInProgress: () => AuthInProgress, +})); +vi.mock('../auth/AuthDialog.js', () => ({ + AuthDialog: () => AuthDialog, +})); +vi.mock('../auth/ApiAuthDialog.js', () => ({ + ApiAuthDialog: () => ApiAuthDialog, +})); +vi.mock('./EditorSettingsDialog.js', () => ({ + EditorSettingsDialog: () => EditorSettingsDialog, +})); +vi.mock('../privacy/PrivacyNotice.js', () => ({ + PrivacyNotice: () => PrivacyNotice, +})); +vi.mock('./ProQuotaDialog.js', () => ({ + ProQuotaDialog: () => ProQuotaDialog, +})); +vi.mock('./PermissionsModifyTrustDialog.js', () => ({ + PermissionsModifyTrustDialog: () => PermissionsModifyTrustDialog, +})); +vi.mock('./ModelDialog.js', () => ({ + ModelDialog: () => ModelDialog, +})); +vi.mock('./IdeTrustChangeDialog.js', () => ({ + IdeTrustChangeDialog: () => IdeTrustChangeDialog, +})); + +describe('DialogManager', () => { + const defaultProps = { + addItem: vi.fn(), + terminalWidth: 100, + }; + + const baseUiState = { + constrainHeight: false, + terminalHeight: 24, + staticExtraHeight: 0, + mainAreaWidth: 80, + confirmUpdateExtensionRequests: [], + showIdeRestartPrompt: false, + proQuotaRequest: null, + shouldShowIdePrompt: false, + isFolderTrustDialogOpen: false, + shellConfirmationRequest: null, + loopDetectionConfirmationRequest: null, + confirmationRequest: null, + isThemeDialogOpen: false, + isSettingsDialogOpen: false, + isModelDialogOpen: false, + isAuthenticating: false, + isAwaitingApiKeyInput: false, + isAuthDialogOpen: false, + isEditorDialogOpen: false, + showPrivacyNotice: false, + isPermissionsDialogOpen: false, + }; + + it('renders nothing by default', () => { + const { lastFrame } = renderWithProviders( + , + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { uiState: baseUiState as any }, + ); + expect(lastFrame()).toBe(''); + }); + + const testCases: Array<[Partial, string]> = [ + [ + { + showIdeRestartPrompt: true, + ideTrustRestartReason: 'update' as RestartReason, + }, + 'IdeTrustChangeDialog', + ], + [ + { + proQuotaRequest: { + failedModel: 'a', + fallbackModel: 'b', + message: 'c', + isTerminalQuotaError: false, + resolve: vi.fn(), + }, + }, + 'ProQuotaDialog', + ], + [ + { + shouldShowIdePrompt: true, + currentIDE: { name: 'vscode', version: '1.0' } as unknown as IdeInfo, + }, + 'IdeIntegrationNudge', + ], + [{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'], + [ + { + shellConfirmationRequest: { + commands: [], + onConfirm: vi.fn(), + } as unknown as ShellConfirmationRequest, + }, + 'ShellConfirmationDialog', + ], + [ + { loopDetectionConfirmationRequest: { onComplete: vi.fn() } }, + 'LoopDetectionConfirmation', + ], + [ + { confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } }, + 'ConsentPrompt', + ], + [ + { + confirmUpdateExtensionRequests: [{ prompt: 'foo', onConfirm: vi.fn() }], + }, + 'ConsentPrompt', + ], + [{ isThemeDialogOpen: true }, 'ThemeDialog'], + [{ isSettingsDialogOpen: true }, 'SettingsDialog'], + [{ isModelDialogOpen: true }, 'ModelDialog'], + [{ isAuthenticating: true }, 'AuthInProgress'], + [{ isAwaitingApiKeyInput: true }, 'ApiAuthDialog'], + [{ isAuthDialogOpen: true }, 'AuthDialog'], + [{ isEditorDialogOpen: true }, 'EditorSettingsDialog'], + [{ showPrivacyNotice: true }, 'PrivacyNotice'], + [{ isPermissionsDialogOpen: true }, 'PermissionsModifyTrustDialog'], + ]; + + it.each(testCases)( + 'renders %s when state is %o', + (uiStateOverride, expectedComponent) => { + const { lastFrame } = renderWithProviders( + , + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + uiState: { ...baseUiState, ...uiStateOverride } as any, + }, + ); + expect(lastFrame()).toContain(expectedComponent); + }, + ); +}); diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx new file mode 100644 index 00000000000..56638a17bae --- /dev/null +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { EditorSettingsDialog } from './EditorSettingsDialog.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SettingScope } from '../../config/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { act } from 'react'; +import { waitFor } from '../../test-utils/async.js'; + +// Mock editorSettingsManager +vi.mock('../editors/editorSettingsManager.js', () => ({ + editorSettingsManager: { + getAvailableEditorDisplays: () => [ + { name: 'VS Code', type: 'vscode', disabled: false }, + { name: 'Vim', type: 'vim', disabled: false }, + ], + }, +})); + +describe('EditorSettingsDialog', () => { + const mockSettings = { + forScope: (scope: string) => ({ + settings: { + general: { + preferredEditor: scope === SettingScope.User ? 'vscode' : undefined, + }, + }, + }), + merged: { + general: { + preferredEditor: 'vscode', + }, + }, + } as unknown as LoadedSettings; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderWithProvider = (ui: React.ReactNode) => + render({ui}); + + it('renders correctly', () => { + const { lastFrame } = renderWithProvider( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('calls onSelect when an editor is selected', () => { + const onSelect = vi.fn(); + const { lastFrame } = renderWithProvider( + , + ); + + expect(lastFrame()).toContain('VS Code'); + }); + + it('switches focus between editor and scope sections on Tab', async () => { + const { lastFrame, stdin } = renderWithProvider( + , + ); + + // Initial focus on editor + expect(lastFrame()).toContain('> Select Editor'); + expect(lastFrame()).not.toContain('> Apply To'); + + // Press Tab + await act(async () => { + stdin.write('\t'); + }); + + // Focus should be on scope + await waitFor(() => { + const frame = lastFrame() || ''; + if (!frame.includes('> Apply To')) { + console.log( + 'Waiting for scope focus. Current frame:', + JSON.stringify(frame), + ); + } + expect(frame).toContain('> Apply To'); + }); + expect(lastFrame()).toContain(' Select Editor'); + + // Press Tab again + await act(async () => { + stdin.write('\t'); + }); + + // Focus should be back on editor + await waitFor(() => { + expect(lastFrame()).toContain('> Select Editor'); + }); + }); + + it('calls onExit when Escape is pressed', async () => { + const onExit = vi.fn(); + const { stdin } = renderWithProvider( + , + ); + + await act(async () => { + stdin.write('\u001B'); // Escape + }); + + await waitFor(() => { + expect(onExit).toHaveBeenCalled(); + }); + }); + + it('shows modified message when setting exists in other scope', () => { + const settingsWithOtherScope = { + forScope: (_scope: string) => ({ + settings: { + general: { + preferredEditor: 'vscode', // Both scopes have it set + }, + }, + }), + merged: { + general: { + preferredEditor: 'vscode', + }, + }, + } as unknown as LoadedSettings; + + const { lastFrame } = renderWithProvider( + , + ); + + const frame = lastFrame() || ''; + if (!frame.includes('(Also modified')) { + console.log( + 'Modified message test failure. Frame:', + JSON.stringify(frame), + ); + } + expect(frame).toContain('(Also modified'); + }); +}); diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx new file mode 100644 index 00000000000..df91560cf0e --- /dev/null +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ExitWarning } from './ExitWarning.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useUIState, type UIState } from '../contexts/UIStateContext.js'; + +vi.mock('../contexts/UIStateContext.js'); + +describe('ExitWarning', () => { + const mockUseUIState = vi.mocked(useUIState); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing by default', () => { + mockUseUIState.mockReturnValue({ + dialogsVisible: false, + ctrlCPressedOnce: false, + ctrlDPressedOnce: false, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('renders Ctrl+C warning when pressed once and dialogs visible', () => { + mockUseUIState.mockReturnValue({ + dialogsVisible: true, + ctrlCPressedOnce: true, + ctrlDPressedOnce: false, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('Press Ctrl+C again to exit'); + }); + + it('renders Ctrl+D warning when pressed once and dialogs visible', () => { + mockUseUIState.mockReturnValue({ + dialogsVisible: true, + ctrlCPressedOnce: false, + ctrlDPressedOnce: true, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('Press Ctrl+D again to exit'); + }); + + it('renders nothing if dialogs are not visible', () => { + mockUseUIState.mockReturnValue({ + dialogsVisible: false, + ctrlCPressedOnce: true, + ctrlDPressedOnce: true, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx new file mode 100644 index 00000000000..88f6c3fd13d --- /dev/null +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useStreamingContext } from '../contexts/StreamingContext.js'; +import { useIsScreenReaderEnabled } from 'ink'; +import { StreamingState } from '../types.js'; +import { + SCREEN_READER_LOADING, + SCREEN_READER_RESPONDING, +} from '../textConstants.js'; + +vi.mock('../contexts/StreamingContext.js'); +vi.mock('ink', async () => { + const actual = await vi.importActual('ink'); + return { + ...actual, + useIsScreenReaderEnabled: vi.fn(), + }; +}); + +describe('GeminiRespondingSpinner', () => { + const mockUseStreamingContext = vi.mocked(useStreamingContext); + const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseIsScreenReaderEnabled.mockReturnValue(false); + }); + + it('renders spinner when responding', () => { + mockUseStreamingContext.mockReturnValue(StreamingState.Responding); + const { lastFrame } = render(); + // Spinner output varies, but it shouldn't be empty + expect(lastFrame()).not.toBe(''); + }); + + it('renders screen reader text when responding and screen reader enabled', () => { + mockUseStreamingContext.mockReturnValue(StreamingState.Responding); + mockUseIsScreenReaderEnabled.mockReturnValue(true); + const { lastFrame } = render(); + expect(lastFrame()).toContain(SCREEN_READER_RESPONDING); + }); + + it('renders nothing when not responding and no non-responding display', () => { + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('renders non-responding display when provided', () => { + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('Waiting...'); + }); + + it('renders screen reader loading text when non-responding display provided and screen reader enabled', () => { + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + mockUseIsScreenReaderEnabled.mockReturnValue(true); + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain(SCREEN_READER_LOADING); + }); +}); diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx new file mode 100644 index 00000000000..4bd823503cc --- /dev/null +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { MainContent } from './MainContent.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Box, Text } from 'ink'; +import type React from 'react'; + +// Mock dependencies +vi.mock('../contexts/AppContext.js', () => ({ + useAppContext: () => ({ + version: '1.0.0', + }), +})); + +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + history: [ + { id: 1, role: 'user', content: 'Hello' }, + { id: 2, role: 'model', content: 'Hi there' }, + ], + pendingHistoryItems: [], + mainAreaWidth: 80, + staticAreaMaxItemHeight: 20, + availableTerminalHeight: 24, + slashCommands: [], + constrainHeight: false, + isEditorDialogOpen: false, + activePtyId: undefined, + embeddedShellFocused: false, + historyRemountKey: 0, + }), +})); + +vi.mock('../hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: vi.fn(), +})); + +vi.mock('./HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: ({ item }: { item: { content: string } }) => ( + + HistoryItem: {item.content} + + ), +})); + +vi.mock('./AppHeader.js', () => ({ + AppHeader: () => AppHeader, +})); + +vi.mock('./ShowMoreLines.js', () => ({ + ShowMoreLines: () => ShowMoreLines, +})); + +vi.mock('./shared/ScrollableList.js', () => ({ + ScrollableList: ({ + data, + renderItem, + }: { + data: unknown[]; + renderItem: (props: { item: unknown }) => React.JSX.Element; + }) => ( + + ScrollableList + {data.map((item: unknown, index: number) => ( + {renderItem({ item })} + ))} + + ), + SCROLL_TO_ITEM_END: 0, +})); + +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; + +describe('MainContent', () => { + beforeEach(() => { + vi.mocked(useAlternateBuffer).mockReturnValue(false); + }); + + it('renders in normal buffer mode', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain('AppHeader'); + expect(output).toContain('HistoryItem: Hello'); + expect(output).toContain('HistoryItem: Hi there'); + }); + + it('renders in alternate buffer mode', () => { + vi.mocked(useAlternateBuffer).mockReturnValue(true); + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain('ScrollableList'); + expect(output).toContain('AppHeader'); + expect(output).toContain('HistoryItem: Hello'); + expect(output).toContain('HistoryItem: Hi there'); + }); +}); diff --git a/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx b/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx new file mode 100644 index 00000000000..de61fe22faf --- /dev/null +++ b/packages/cli/src/ui/components/MemoryUsageDisplay.test.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import process from 'node:process'; +import { act } from 'react'; + +describe('MemoryUsageDisplay', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + // Mock process.memoryUsage + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 1024 * 1024 * 50, // 50MB + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders memory usage', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('50.0 MB'); + }); + + it('updates memory usage over time', async () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('50.0 MB'); + + vi.mocked(process.memoryUsage).mockReturnValue({ + rss: 1024 * 1024 * 100, // 100MB + heapTotal: 0, + heapUsed: 0, + external: 0, + arrayBuffers: 0, + }); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(lastFrame()).toContain('100.0 MB'); + }); +}); diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx new file mode 100644 index 00000000000..6e0c178e86b --- /dev/null +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { Notifications } from './Notifications.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useAppContext, type AppState } from '../contexts/AppContext.js'; +import { useUIState, type UIState } from '../contexts/UIStateContext.js'; +import { useIsScreenReaderEnabled } from 'ink'; +import * as fs from 'node:fs/promises'; +import { act } from 'react'; + +// Mock dependencies +vi.mock('../contexts/AppContext.js'); +vi.mock('../contexts/UIStateContext.js'); +vi.mock('ink', async () => { + const actual = await vi.importActual('ink'); + return { + ...actual, + useIsScreenReaderEnabled: vi.fn(), + }; +}); +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + access: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock('node:os', () => ({ + default: { + homedir: () => '/mock/home', + }, +})); + +vi.mock('node:path', async () => { + const actual = await vi.importActual('node:path'); + return { + ...actual, + default: actual.posix, + }; +}); + +vi.mock('@google/gemini-cli-core', () => ({ + GEMINI_DIR: '.gemini', + Storage: { + getGlobalTempDir: () => '/mock/temp', + }, +})); + +vi.mock('../../config/settings.js', () => ({ + DEFAULT_MODEL_CONFIGS: {}, + LoadedSettings: class { + constructor() { + // this.merged = {}; + } + }, +})); + +describe('Notifications', () => { + const mockUseAppContext = vi.mocked(useAppContext); + const mockUseUIState = vi.mocked(useUIState); + const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled); + const mockFsAccess = vi.mocked(fs.access); + const mockFsWriteFile = vi.mocked(fs.writeFile); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAppContext.mockReturnValue({ + startupWarnings: [], + version: '1.0.0', + } as AppState); + mockUseUIState.mockReturnValue({ + initError: null, + streamingState: 'idle', + updateInfo: null, + } as unknown as UIState); + mockUseIsScreenReaderEnabled.mockReturnValue(false); + }); + + it('renders nothing when no notifications', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])( + 'renders startup warnings: %s', + (warnings) => { + mockUseAppContext.mockReturnValue({ + startupWarnings: warnings, + version: '1.0.0', + } as AppState); + const { lastFrame } = render(); + const output = lastFrame(); + warnings.forEach((warning) => { + expect(output).toContain(warning); + }); + }, + ); + + it('renders init error', () => { + mockUseUIState.mockReturnValue({ + initError: 'Something went wrong', + streamingState: 'idle', + updateInfo: null, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('does not render init error when streaming', () => { + mockUseUIState.mockReturnValue({ + initError: 'Something went wrong', + streamingState: 'responding', + updateInfo: null, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('renders update notification', () => { + mockUseUIState.mockReturnValue({ + initError: null, + streamingState: 'idle', + updateInfo: { message: 'Update available' }, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders screen reader nudge when enabled and not seen', async () => { + mockUseIsScreenReaderEnabled.mockReturnValue(true); + + let rejectAccess: (err: Error) => void; + mockFsAccess.mockImplementation( + () => + new Promise((_, reject) => { + rejectAccess = reject; + }), + ); + + const { lastFrame } = render(); + + // Trigger rejection inside act + await act(async () => { + rejectAccess(new Error('File not found')); + }); + + // Wait for effect to propagate + await vi.waitFor(() => { + expect(mockFsWriteFile).toHaveBeenCalled(); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('does not render screen reader nudge when already seen', async () => { + mockUseIsScreenReaderEnabled.mockReturnValue(true); + + let resolveAccess: (val: undefined) => void; + mockFsAccess.mockImplementation( + () => + new Promise((resolve) => { + resolveAccess = resolve; + }), + ); + + const { lastFrame } = render(); + + // Trigger resolution inside act + await act(async () => { + resolveAccess(undefined); + }); + + expect(lastFrame()).toBe(''); + expect(mockFsWriteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 05e00ed479d..d956afac0df 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -39,7 +39,7 @@ export const Notifications = () => { >(undefined); useEffect(() => { - const checkScreenReaderNudge = async () => { + const checkScreenReader = async () => { try { await fs.access(screenReaderNudgeFilePath); setHasSeenScreenReaderNudge(true); @@ -47,8 +47,11 @@ export const Notifications = () => { setHasSeenScreenReaderNudge(false); } }; - checkScreenReaderNudge(); - }, []); + + if (isScreenReaderEnabled) { + checkScreenReader(); + } + }, [isScreenReaderEnabled]); const showScreenReaderNudge = isScreenReaderEnabled && hasSeenScreenReaderNudge === false; diff --git a/packages/cli/src/ui/components/QuittingDisplay.test.tsx b/packages/cli/src/ui/components/QuittingDisplay.test.tsx new file mode 100644 index 00000000000..177d469d8e7 --- /dev/null +++ b/packages/cli/src/ui/components/QuittingDisplay.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { QuittingDisplay } from './QuittingDisplay.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { useUIState, type UIState } from '../contexts/UIStateContext.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; + +vi.mock('../contexts/UIStateContext.js'); +vi.mock('../hooks/useTerminalSize.js'); +vi.mock('./HistoryItemDisplay.js', async () => { + const { Text } = await vi.importActual('ink'); + return { + HistoryItemDisplay: ({ item }: { item: { content: string } }) => + React.createElement(Text as unknown as React.FC, null, item.content), + }; +}); + +describe('QuittingDisplay', () => { + const mockUseUIState = vi.mocked(useUIState); + const mockUseTerminalSize = vi.mocked(useTerminalSize); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseTerminalSize.mockReturnValue({ rows: 20, columns: 80 }); + }); + + it('renders nothing when no quitting messages', () => { + mockUseUIState.mockReturnValue({ + quittingMessages: null, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('renders quitting messages', () => { + const mockMessages = [ + { id: '1', type: 'user', content: 'Goodbye' }, + { id: '2', type: 'model', content: 'See you later' }, + ]; + mockUseUIState.mockReturnValue({ + quittingMessages: mockMessages, + constrainHeight: false, + } as unknown as UIState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('Goodbye'); + expect(lastFrame()).toContain('See you later'); + }); +}); diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx new file mode 100644 index 00000000000..30eaf90c258 --- /dev/null +++ b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; +import { describe, it, expect, afterEach } from 'vitest'; + +describe('RawMarkdownIndicator', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + + it('renders correct key binding for darwin', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const { lastFrame } = render(); + expect(lastFrame()).toContain('raw markdown mode'); + expect(lastFrame()).toContain('option+m to toggle'); + }); + + it('renders correct key binding for other platforms', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + const { lastFrame } = render(); + expect(lastFrame()).toContain('raw markdown mode'); + expect(lastFrame()).toContain('alt+m to toggle'); + }); +}); diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx index e063af40aaf..2a2f239d11e 100644 --- a/packages/cli/src/ui/components/SessionBrowser.test.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -157,8 +157,7 @@ describe('SessionBrowser component', () => { />, ); - expect(lastFrame()).toContain('No auto-saved conversations found.'); - expect(lastFrame()).toContain('Press q to exit'); + expect(lastFrame()).toMatchSnapshot(); }); it('renders a list of sessions and marks current session as disabled', () => { @@ -193,11 +192,7 @@ describe('SessionBrowser component', () => { />, ); - const output = lastFrame(); - expect(output).toContain('Chat Sessions (2 total'); - expect(output).toContain('First conversation about cats'); - expect(output).toContain('Second conversation about dogs'); - expect(output).toContain('(current)'); + expect(lastFrame()).toMatchSnapshot(); }); it('enters search mode, filters sessions, and renders match snippets', async () => { @@ -214,6 +209,7 @@ describe('SessionBrowser component', () => { }, ], index: 0, + lastUpdated: '2025-01-01T12:00:00Z', }); const otherSession = createSession({ @@ -229,6 +225,7 @@ describe('SessionBrowser component', () => { }, ], index: 1, + lastUpdated: '2025-01-01T10:00:00Z', }); const config = createMockConfig(); @@ -259,15 +256,9 @@ describe('SessionBrowser component', () => { } await waitFor(() => { - const output = lastFrame(); - expect(output).toContain('Chat Sessions (1 total, filtered'); - expect(output).toContain('Query is here'); - expect(output).not.toContain('Nothing interesting here.'); - - expect(output).toContain('You:'); - expect(output).toContain('query'); - expect(output).toContain('(+1 more)'); + expect(lastFrame()).toContain('Chat Sessions (1 total, filtered'); }); + expect(lastFrame()).toMatchSnapshot(); }); it('handles keyboard navigation and resumes the selected session', () => { @@ -276,12 +267,14 @@ describe('SessionBrowser component', () => { file: 'one', displayName: 'First session', index: 0, + lastUpdated: '2025-01-02T12:00:00Z', }); const session2 = createSession({ id: 'two', file: 'two', displayName: 'Second session', index: 1, + lastUpdated: '2025-01-01T12:00:00Z', }); const config = createMockConfig(); @@ -317,6 +310,7 @@ describe('SessionBrowser component', () => { displayName: 'Current session', isCurrentSession: true, index: 0, + lastUpdated: '2025-01-02T12:00:00Z', }); const otherSession = createSession({ id: 'other', @@ -324,6 +318,7 @@ describe('SessionBrowser component', () => { displayName: 'Other session', isCurrentSession: false, index: 1, + lastUpdated: '2025-01-01T12:00:00Z', }); const config = createMockConfig(); @@ -364,8 +359,6 @@ describe('SessionBrowser component', () => { />, ); - const output = lastFrame(); - expect(output).toContain('Error: storage failure'); - expect(output).toContain('Press q to exit'); + expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx new file mode 100644 index 00000000000..815cfcadf77 --- /dev/null +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ShellInputPrompt } from './ShellInputPrompt.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ShellExecutionService } from '@google/gemini-cli-core'; + +// Mock useKeypress +const mockUseKeypress = vi.fn(); +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: (handler: (input: unknown) => void, options?: unknown) => + mockUseKeypress(handler, options), +})); + +// Mock ShellExecutionService +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + ShellExecutionService: { + writeToPty: vi.fn(), + scrollPty: vi.fn(), + }, + }; +}); + +describe('ShellInputPrompt', () => { + const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty); + const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it.each([ + ['a', 'a'], + ['b', 'b'], + ])('handles keypress input: %s', (name, sequence) => { + render(); + + // Get the registered handler + const handler = mockUseKeypress.mock.calls[0][0]; + + // Simulate keypress + handler({ name, sequence, ctrl: false, shift: false, meta: false }); + + expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence); + }); + + it.each([ + ['up', -1], + ['down', 1], + ])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ name: key, ctrl: true, shift: true, meta: false }); + + expect(mockScrollPty).toHaveBeenCalledWith(1, direction); + }); + + it('does not handle input when not focused', () => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ + name: 'a', + sequence: 'a', + ctrl: false, + shift: false, + meta: false, + }); + + expect(mockWriteToPty).not.toHaveBeenCalled(); + }); + + it('does not handle input when no active shell', () => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ + name: 'a', + sequence: 'a', + ctrl: false, + shift: false, + meta: false, + }); + + expect(mockWriteToPty).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/ShellModeIndicator.test.tsx b/packages/cli/src/ui/components/ShellModeIndicator.test.tsx new file mode 100644 index 00000000000..73c2b5d03a9 --- /dev/null +++ b/packages/cli/src/ui/components/ShellModeIndicator.test.tsx @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ShellModeIndicator } from './ShellModeIndicator.js'; +import { describe, it, expect } from 'vitest'; + +describe('ShellModeIndicator', () => { + it('renders correctly', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('shell mode enabled'); + expect(lastFrame()).toContain('esc to disable'); + }); +}); diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx new file mode 100644 index 00000000000..beec038bbee --- /dev/null +++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useOverflowState } from '../contexts/OverflowContext.js'; +import { useStreamingContext } from '../contexts/StreamingContext.js'; +import { StreamingState } from '../types.js'; + +vi.mock('../contexts/OverflowContext.js'); +vi.mock('../contexts/StreamingContext.js'); + +describe('ShowMoreLines', () => { + const mockUseOverflowState = vi.mocked(useOverflowState); + const mockUseStreamingContext = vi.mocked(useStreamingContext); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + [new Set(), StreamingState.Idle, true], // No overflow + [new Set(['1']), StreamingState.Idle, false], // Not constraining height + [new Set(['1']), StreamingState.Responding, true], // Streaming + ])( + 'renders nothing when: overflow=%s, streaming=%s, constrain=%s', + (overflowingIds, streamingState, constrainHeight) => { + mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable< + ReturnType + >); + mockUseStreamingContext.mockReturnValue(streamingState); + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }, + ); + + it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])( + 'renders message when overflowing and state is %s', + (streamingState) => { + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(streamingState); + const { lastFrame } = render(); + expect(lastFrame()).toContain('Press ctrl-s to show more lines'); + }, + ); +}); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx new file mode 100644 index 00000000000..6931268a379 --- /dev/null +++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { SuggestionsDisplay } from './SuggestionsDisplay.js'; +import { describe, it, expect } from 'vitest'; +import { CommandKind } from '../commands/types.js'; + +describe('SuggestionsDisplay', () => { + const mockSuggestions = [ + { label: 'Command 1', value: 'command1', description: 'Description 1' }, + { label: 'Command 2', value: 'command2', description: 'Description 2' }, + { label: 'Command 3', value: 'command3', description: 'Description 3' }, + ]; + + it('renders loading state', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders nothing when empty and not loading', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders suggestions list', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('highlights active item', () => { + // This test relies on visual inspection or implementation details (colors) + // For now, we just ensure it renders without error and contains the item + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('handles scrolling', () => { + const manySuggestions = Array.from({ length: 20 }, (_, i) => ({ + label: `Cmd ${i}`, + value: `Cmd ${i}`, + description: `Description ${i}`, + })); + + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders MCP tag for MCP prompts', () => { + const mcpSuggestions = [ + { + label: 'MCP Tool', + value: 'mcp-tool', + commandKind: CommandKind.MCP_PROMPT, + }, + ]; + + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ThemedGradient.test.tsx b/packages/cli/src/ui/components/ThemedGradient.test.tsx new file mode 100644 index 00000000000..9ea194c9f99 --- /dev/null +++ b/packages/cli/src/ui/components/ThemedGradient.test.tsx @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { describe, it, expect, vi } from 'vitest'; + +// Mock theme to control gradient +vi.mock('../semantic-colors.js', () => ({ + theme: { + ui: { + gradient: ['red', 'blue'], + }, + text: { + accent: 'cyan', + }, + }, +})); + +describe('ThemedGradient', () => { + it('renders children', () => { + const { lastFrame } = render(Hello); + expect(lastFrame()).toContain('Hello'); + }); + + // Note: Testing actual gradient application is hard with ink-testing-library + // as it often renders as plain text or ANSI codes. + // We mainly ensure it doesn't crash and renders content. +}); diff --git a/packages/cli/src/ui/components/Tips.test.tsx b/packages/cli/src/ui/components/Tips.test.tsx new file mode 100644 index 00000000000..adbedb53261 --- /dev/null +++ b/packages/cli/src/ui/components/Tips.test.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { Tips } from './Tips.js'; +import { describe, it, expect, vi } from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; + +describe('Tips', () => { + it.each([ + [0, '3. Create GEMINI.md files'], + [5, '3. /help for more information'], + ])('renders correct tips when file count is %i', (count, expectedText) => { + const config = { + getGeminiMdFileCount: vi.fn().mockReturnValue(count), + } as unknown as Config; + + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toContain(expectedText); + }); +}); diff --git a/packages/cli/src/ui/components/UpdateNotification.test.tsx b/packages/cli/src/ui/components/UpdateNotification.test.tsx new file mode 100644 index 00000000000..fa1632d50cf --- /dev/null +++ b/packages/cli/src/ui/components/UpdateNotification.test.tsx @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { UpdateNotification } from './UpdateNotification.js'; +import { describe, it, expect } from 'vitest'; + +describe('UpdateNotification', () => { + it('renders message', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('Update available!'); + }); +}); diff --git a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap new file mode 100644 index 00000000000..07b9ecf5b82 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Banner > handles newlines in text 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Line 1 │ +│ Line 2 │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in info mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Info Message │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in warning mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Warning Message │ +╰──────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap new file mode 100644 index 00000000000..9c7b7cc0d71 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ConfigInitDisplay > handles empty clients map 1`] = ` +" +Spinner Initializing..." +`; + +exports[`ConfigInitDisplay > renders initial state 1`] = ` +" +Spinner Initializing..." +`; + +exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` +" +Spinner Connecting to MCP servers... (1/2)" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap new file mode 100644 index 00000000000..dd0719a90e5 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EditorSettingsDialog > renders correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Select Editor Editor Preference │ +│ ● 1. VS Code │ +│ 2. Vim These editors are currently supported. Please note │ +│ that some editors cannot be used in sandbox mode. │ +│ Apply To │ +│ ● 1. User Settings Your preferred editor is: None. │ +│ 2. Workspace Settings │ +│ │ +│ (Use Enter to select, Tab to change │ +│ focus, Esc to close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap new file mode 100644 index 00000000000..32704a9313c --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Notifications.test.tsx.snap @@ -0,0 +1,22 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Notifications > renders init error 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Initialization Error: Something went wrong Please check API key and configuration. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`Notifications > renders screen reader nudge when enabled and not seen 1`] = ` +"You are currently in screen reader-friendly view. To switch out, open +/mock/home/.gemini/settings.json and remove the entry for "screenReader". This will disappear on +next run." +`; + +exports[`Notifications > renders update notification 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Update available │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap new file mode 100644 index 00000000000..efffa48b4ec --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap @@ -0,0 +1,32 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionBrowser component > enters search mode, filters sessions, and renders match snippets 1`] = ` +" Chat Sessions (1 total, filtered) sorted by date desc + + Search: query (Esc to cancel) + + Index │ Msgs │ Age │ Match + ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more) + ▼" +`; + +exports[`SessionBrowser component > renders a list of sessions and marks current session as disabled 1`] = ` +" Chat Sessions (2 total) sorted by date desc + Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q + Sort: s Reverse: r First/Last: g/G + + Index │ Msgs │ Age │ Name + ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current) + #2 │ 2 │ 10mo │ First conversation about cats + ▼" +`; + +exports[`SessionBrowser component > shows an error state when loading sessions fails 1`] = ` +" Error: storage failure + Press q to exit" +`; + +exports[`SessionBrowser component > shows empty state when no sessions exist 1`] = ` +" No auto-saved conversations found. + Press q to exit" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap new file mode 100644 index 00000000000..ce1640ce257 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SuggestionsDisplay > handles scrolling 1`] = ` +" ▲ + Cmd 5 Description 5 + Cmd 6 Description 6 + Cmd 7 Description 7 + Cmd 8 Description 8 + Cmd 9 Description 9 + Cmd 10 Description 10 + Cmd 11 Description 11 + Cmd 12 Description 12 + ▼ + (11/20)" +`; + +exports[`SuggestionsDisplay > highlights active item 1`] = ` +" command1 Description 1 + command2 Description 2 + command3 Description 3" +`; + +exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `" mcp-tool [MCP]"`; + +exports[`SuggestionsDisplay > renders loading state 1`] = `" Loading suggestions..."`; + +exports[`SuggestionsDisplay > renders suggestions list 1`] = ` +" command1 Description 1 + command2 Description 2 + command3 Description 3" +`; diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx new file mode 100644 index 00000000000..1c605e01289 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ErrorMessage.test.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { ErrorMessage } from './ErrorMessage.js'; +import { describe, it, expect } from 'vitest'; + +describe('ErrorMessage', () => { + it('renders with the correct prefix and text', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders multiline error messages', () => { + const message = 'Error line 1\nError line 2'; + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/InfoMessage.test.tsx b/packages/cli/src/ui/components/messages/InfoMessage.test.tsx new file mode 100644 index 00000000000..d5c3c78de3e --- /dev/null +++ b/packages/cli/src/ui/components/messages/InfoMessage.test.tsx @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { InfoMessage } from './InfoMessage.js'; +import { describe, it, expect } from 'vitest'; + +describe('InfoMessage', () => { + it('renders with the correct default prefix and text', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders with a custom icon', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders multiline info messages', () => { + const message = 'Info line 1\nInfo line 2'; + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 967a301febd..ef41d5590c7 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -36,7 +36,7 @@ describe('ToolConfirmationMessage', () => { />, ); - expect(lastFrame()).not.toContain('URLs to fetch:'); + expect(lastFrame()).toMatchSnapshot(); }); it('should display urls if prompt and url are different', () => { @@ -60,10 +60,7 @@ describe('ToolConfirmationMessage', () => { />, ); - expect(lastFrame()).toContain('URLs to fetch:'); - expect(lastFrame()).toContain( - '- https://raw.githubusercontent.com/google/gemini-react/main/README.md', - ); + expect(lastFrame()).toMatchSnapshot(); }); describe('with folder trust', () => { @@ -124,7 +121,7 @@ describe('ToolConfirmationMessage', () => { details: mcpConfirmationDetails, alwaysAllowText: 'always allow', }, - ])('$description', ({ details, alwaysAllowText }) => { + ])('$description', ({ details }) => { it('should show "allow always" when folder is trusted', () => { const mockConfig = { isTrustedFolder: () => true, @@ -140,7 +137,7 @@ describe('ToolConfirmationMessage', () => { />, ); - expect(lastFrame()).toContain(alwaysAllowText); + expect(lastFrame()).toMatchSnapshot(); }); it('should NOT show "allow always" when folder is untrusted', () => { @@ -158,7 +155,7 @@ describe('ToolConfirmationMessage', () => { />, ); - expect(lastFrame()).not.toContain(alwaysAllowText); + expect(lastFrame()).toMatchSnapshot(); }); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 3d1b6413893..983bca8669e 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -107,10 +107,7 @@ describe('', () => { StreamingState.Idle, ); const output = lastFrame(); - expect(output).toContain('✓'); // Success indicator - expect(output).toContain('test-tool'); - expect(output).toContain('A tool for testing'); - expect(output).toContain('MockMarkdown:Test result'); + expect(output).toMatchSnapshot(); }); describe('ToolStatusIndicator rendering', () => { @@ -119,7 +116,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('✓'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows o for Pending status', () => { @@ -127,7 +124,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('o'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows ? for Confirming status', () => { @@ -135,7 +132,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('?'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows - for Canceled status', () => { @@ -143,7 +140,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('-'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows x for Error status', () => { @@ -151,7 +148,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('x'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows paused spinner for Executing status when streamingState is Idle', () => { @@ -159,9 +156,7 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('⊷'); - expect(lastFrame()).not.toContain('MockRespondingSpinner'); - expect(lastFrame()).not.toContain('✓'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => { @@ -169,9 +164,7 @@ describe('', () => { , StreamingState.WaitingForConfirmation, ); - expect(lastFrame()).toContain('⊷'); - expect(lastFrame()).not.toContain('MockRespondingSpinner'); - expect(lastFrame()).not.toContain('✓'); + expect(lastFrame()).toMatchSnapshot(); }); it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => { @@ -179,8 +172,7 @@ describe('', () => { , StreamingState.Responding, // Simulate app still responding ); - expect(lastFrame()).toContain('MockRespondingSpinner'); - expect(lastFrame()).not.toContain('✓'); + expect(lastFrame()).toMatchSnapshot(); }); }); @@ -196,7 +188,7 @@ describe('', () => { StreamingState.Idle, ); // Check that the output contains the MockDiff content as part of the whole message - expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/); + expect(lastFrame()).toMatchSnapshot(); }); it('renders emphasis correctly', () => { @@ -205,7 +197,7 @@ describe('', () => { StreamingState.Idle, ); // Check for trailing indicator or specific color if applicable (Colors are not easily testable here) - expect(highEmphasisFrame()).toContain('←'); // Trailing indicator for high emphasis + expect(highEmphasisFrame()).toMatchSnapshot(); const { lastFrame: lowEmphasisFrame } = renderWithContext( , @@ -214,7 +206,7 @@ describe('', () => { // For low emphasis, the name and description might be dimmed (check for dimColor if possible) // This is harder to assert directly in text output without color checks. // We can at least ensure it doesn't have the high emphasis indicator. - expect(lowEmphasisFrame()).not.toContain('←'); + expect(lowEmphasisFrame()).toMatchSnapshot(); }); it('renders AnsiOutputText for AnsiOutput results', () => { @@ -236,6 +228,6 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('MockAnsiOutput:hello'); + expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx new file mode 100644 index 00000000000..98b0af9f40a --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { ToolResultDisplay } from './ToolResultDisplay.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Box, Text } from 'ink'; +import type { AnsiOutput } from '@google/gemini-cli-core'; + +// Mock child components to simplify testing +vi.mock('./DiffRenderer.js', () => ({ + DiffRenderer: ({ + diffContent, + filename, + }: { + diffContent: string; + filename: string; + }) => ( + + + DiffRenderer: {filename} - {diffContent} + + + ), +})); + +vi.mock('../../utils/MarkdownDisplay.js', () => ({ + MarkdownDisplay: ({ text }: { text: string }) => ( + + MarkdownDisplay: {text} + + ), +})); + +vi.mock('../AnsiOutput.js', () => ({ + AnsiOutputText: ({ data }: { data: unknown }) => ( + + AnsiOutputText: {JSON.stringify(data)} + + ), +})); + +vi.mock('../shared/MaxSizedBox.js', () => ({ + MaxSizedBox: ({ children }: { children: React.ReactNode }) => ( + + MaxSizedBox: + {children} + + ), +})); + +// Mock UIStateContext +const mockUseUIState = vi.fn(); +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => mockUseUIState(), +})); + +// Mock useAlternateBuffer +const mockUseAlternateBuffer = vi.fn(); +vi.mock('../../hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: () => mockUseAlternateBuffer(), +})); + +describe('ToolResultDisplay', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseUIState.mockReturnValue({ renderMarkdown: true }); + mockUseAlternateBuffer.mockReturnValue(false); + }); + + it('renders string result as markdown by default', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders string result as plain text when renderOutputAsMarkdown is false', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('truncates very long string results', { timeout: 20000 }, () => { + const longString = 'a'.repeat(1000005); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders file diff result', () => { + const diffResult = { + fileDiff: 'diff content', + fileName: 'test.ts', + }; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders ANSI output result', () => { + const ansiResult = { + text: 'ansi content', + }; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders nothing for todos result', () => { + const todoResult = { + todos: [], + }; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('falls back to plain text if availableHeight is set and not in alternate buffer', () => { + mockUseAlternateBuffer.mockReturnValue(false); + // availableHeight calculation: 20 - 1 - 5 = 14 > 3 + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + // Should force renderOutputAsMarkdown to false + expect(output).toMatchSnapshot(); + }); + + it('keeps markdown if in alternate buffer even with availableHeight', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index b02f616eb08..49002d50e92 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -21,7 +21,7 @@ const MIN_LINES_SHOWN = 2; // show at least this many lines // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. -const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; +const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000; export interface ToolResultDisplayProps { resultDisplay: string | object | undefined; diff --git a/packages/cli/src/ui/components/messages/UserMessage.test.tsx b/packages/cli/src/ui/components/messages/UserMessage.test.tsx new file mode 100644 index 00000000000..2f130c54694 --- /dev/null +++ b/packages/cli/src/ui/components/messages/UserMessage.test.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { UserMessage } from './UserMessage.js'; +import { describe, it, expect, vi } from 'vitest'; + +// Mock the commandUtils to control isSlashCommand behavior +vi.mock('../../utils/commandUtils.js', () => ({ + isSlashCommand: vi.fn((text: string) => text.startsWith('/')), +})); + +describe('UserMessage', () => { + it('renders normal user message with correct prefix', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders slash command message', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders multiline user message', () => { + const message = 'Line 1\nLine 2'; + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/WarningMessage.test.tsx b/packages/cli/src/ui/components/messages/WarningMessage.test.tsx new file mode 100644 index 00000000000..fcb635d6247 --- /dev/null +++ b/packages/cli/src/ui/components/messages/WarningMessage.test.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { WarningMessage } from './WarningMessage.js'; +import { describe, it, expect } from 'vitest'; + +describe('WarningMessage', () => { + it('renders with the correct prefix and text', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it('renders multiline warning messages', () => { + const message = 'Warning line 1\nWarning line 2'; + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap new file mode 100644 index 00000000000..0f5c270ae47 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ErrorMessage.test.tsx.snap @@ -0,0 +1,12 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ErrorMessage > renders multiline error messages 1`] = ` +"✕ Error line 1 + Error line 2 +" +`; + +exports[`ErrorMessage > renders with the correct prefix and text 1`] = ` +"✕ Something went wrong +" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap new file mode 100644 index 00000000000..47b63b76817 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/InfoMessage.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InfoMessage > renders multiline info messages 1`] = ` +" +ℹ Info line 1 + Info line 2" +`; + +exports[`InfoMessage > renders with a custom icon 1`] = ` +" +★Custom icon test" +`; + +exports[`InfoMessage > renders with the correct default prefix and text 1`] = ` +" +ℹ Just so you know" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap new file mode 100644 index 00000000000..95aa1fca13b --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -0,0 +1,123 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ToolConfirmationMessage > should display urls if prompt and url are different 1`] = ` +"fetch https://github.com/google/gemini-react/blob/main/README.md + +URLs to fetch: + - https://raw.githubusercontent.com/google/gemini-react/main/README.md + +Do you want to proceed? + +● 1. Yes, allow once + 2. Yes, allow always + 3. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > should not display urls if prompt and url are the same 1`] = ` +"https://example.com + +Do you want to proceed? + +● 1. Yes, allow once + 2. Yes, allow always + 3. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` +"╭──────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────╯ + +Apply this change? + +● 1. Yes, allow once + 2. Modify with external editor + 3. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should show "allow always" when folder is trusted 1`] = ` +"╭──────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────╯ + +Apply this change? + +● 1. Yes, allow once + 2. Yes, allow always + 3. Modify with external editor + 4. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` +"echo "hello" + +Allow execution of: 'echo'? + +● 1. Yes, allow once + 2. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = ` +"echo "hello" + +Allow execution of: 'echo'? + +● 1. Yes, allow once + 2. Yes, allow always ... + 3. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` +"https://example.com + +Do you want to proceed? + +● 1. Yes, allow once + 2. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should show "allow always" when folder is trusted 1`] = ` +"https://example.com + +Do you want to proceed? + +● 1. Yes, allow once + 2. Yes, allow always + 3. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` +"MCP Server: test-server +Tool: test-tool + +Allow execution of MCP tool "test-tool" from server "test-server"? + +● 1. Yes, allow once + 2. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should show "allow always" when folder is trusted 1`] = ` +"MCP Server: test-server +Tool: test-tool + +Allow execution of MCP tool "test-tool" from server "test-server"? + +● 1. Yes, allow once + 2. Yes, always allow tool "test-tool" from server "test-server" + 3. Yes, always allow all tools from server "test-server" + 4. No, suggest changes (esc) +" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap new file mode 100644 index 00000000000..fd161ce9a20 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap @@ -0,0 +1,96 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > ToolStatusIndicator rendering > shows ? for Confirming status 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ? test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows - for Canceled status 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ - test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ MockRespondingSpinnertest-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows o for Pending status 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ o test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows x for Error status 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ x test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > ToolStatusIndicator rendering > shows ✓ for Success status 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > renders AnsiOutputText for AnsiOutput results 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ MockAnsiOutput:hello │" +`; + +exports[` > renders DiffRenderer for diff results 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ MockDiff:--- a/file.txt │ +│ +++ b/file.txt │ +│ @@ -1 +1 @@ │ +│ -old │ +│ +new │" +`; + +exports[` > renders basic tool information 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > renders emphasis correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing ← │ +│ │ +│ MockMarkdown:Test result │" +`; + +exports[` > renders emphasis correctly 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ MockMarkdown:Test result │" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap new file mode 100644 index 00000000000..2919124771f --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -0,0 +1,326 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ToolResultDisplay > falls back to plain text if availableHeight is set and not in alternate buffer 1`] = `"MaxSizedBox:Some result"`; + +exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `"MarkdownDisplay: Some result"`; + +exports[`ToolResultDisplay > renders ANSI output result 1`] = `"AnsiOutputText: {"text":"ansi content"}"`; + +exports[`ToolResultDisplay > renders file diff result 1`] = `"DiffRenderer: test.ts - diff content"`; + +exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`; + +exports[`ToolResultDisplay > renders string result as markdown by default 1`] = `"MarkdownDisplay: Some result"`; + +exports[`ToolResultDisplay > renders string result as plain text when renderOutputAsMarkdown is false 1`] = `"MaxSizedBox:Some result"`; + +exports[`ToolResultDisplay > truncates very long string results 1`] = ` +"MaxSizedBo...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap new file mode 100644 index 00000000000..1d1e950bb34 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UserMessage > renders multiline user message 1`] = ` +" +> Line 1 + Line 2 +" +`; + +exports[`UserMessage > renders normal user message with correct prefix 1`] = ` +" +> Hello Gemini +" +`; + +exports[`UserMessage > renders slash command message 1`] = ` +" +> /help +" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap new file mode 100644 index 00000000000..4ebfdcf4e0c --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/WarningMessage.test.tsx.snap @@ -0,0 +1,12 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WarningMessage > renders multiline warning messages 1`] = ` +" +⚠ Warning line 1 + Warning line 2" +`; + +exports[`WarningMessage > renders with the correct prefix and text 1`] = ` +" +⚠ Watch out!" +`; From bd58beb1e7692d694b5ff34d71855b081b11c8d4 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 21 Nov 2025 19:20:28 -0800 Subject: [PATCH 30/32] Update ink version to 6.4.6 (#13631) --- package-lock.json | 51 ++++++++++++++++++++++++--------------- package.json | 4 +-- packages/cli/package.json | 2 +- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ca4d8c31cb..2e382cb447d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.4.5", + "ink": "npm:@jrichman/ink@6.4.6", "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, @@ -2403,6 +2403,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2583,6 +2584,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2616,6 +2618,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2984,6 +2987,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3017,6 +3021,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3069,6 +3074,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4279,6 +4285,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4566,6 +4573,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5489,6 +5497,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5924,8 +5933,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7189,7 +7197,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8205,6 +8212,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8794,7 +8802,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8804,7 +8811,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8814,7 +8820,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9068,7 +9073,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9087,7 +9091,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9096,15 +9099,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10311,10 +10312,11 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.5.tgz", - "integrity": "sha512-mIDkZqtJbedL9XDOoqoJt3S8aGQVqEJYnCnSeLlYzkpUWCsSWC0hW40yJ0DLH86lcl8k5R5lv/9C2i/3746nWw==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz", + "integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13422,8 +13424,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -13958,6 +13959,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13968,6 +13970,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16186,6 +16189,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16350,7 +16354,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16358,6 +16363,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16542,6 +16548,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16704,7 +16711,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16760,6 +16766,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16876,6 +16883,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16889,6 +16897,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17595,6 +17604,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17917,7 +17927,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.5", + "ink": "npm:@jrichman/ink@6.4.6", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -18136,6 +18146,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 07448d5e9ef..36cb244cdf9 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.4.5", + "ink": "npm:@jrichman/ink@6.4.6", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -119,7 +119,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.4.5", + "ink": "npm:@jrichman/ink@6.4.6", "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f33667ec83..3d7e5ff1ed6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,7 +43,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.5", + "ink": "npm:@jrichman/ink@6.4.6", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", From ec51de5f45f00410ce26fdf99dbe6ae793ac5b17 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Fri, 21 Nov 2025 23:46:20 -0800 Subject: [PATCH 31/32] chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 (#13637) --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/a2a-server/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e382cb447d..15777295292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "workspaces": [ "packages/*" ], @@ -17620,7 +17620,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -17910,7 +17910,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", @@ -18011,7 +18011,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "dependencies": { "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", @@ -18156,7 +18156,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -18167,7 +18167,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", diff --git a/package.json b/package.json index 36cb244cdf9..2d660b14c6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "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.19.0-nightly.20251120.8e531dc02" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-nightly.20251122.42c2e1b21" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index d086084f686..6cee946e711 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d7e5ff1ed6..b30ac561e04 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-nightly.20251120.8e531dc02" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-nightly.20251122.42c2e1b21" }, "dependencies": { "@google/gemini-cli-core": "file:../core", diff --git a/packages/core/package.json b/packages/core/package.json index d14e512f9db..cd116b0d51f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-core", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "description": "Gemini CLI Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f5d3ece5e7c..3e3d61dba23 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-test-utils", - "version": "0.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 73521c200ad..910846e04d4 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -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.19.0-nightly.20251120.8e531dc02", + "version": "0.19.0-nightly.20251122.42c2e1b21", "publisher": "google", "icon": "assets/icon.png", "repository": { From 48206a82b6a5c8bdae1e51353d322705ce9214eb Mon Sep 17 00:00:00 2001 From: prajwalr308 Date: Fri, 21 Nov 2025 12:49:13 +0000 Subject: [PATCH 32/32] Change clipboard image display to clean [image #N] format Users now see clean labels like [image #1], [image #2] instead of full paths. The system automatically transforms these to @.gemini-clipboard/image-N.png when submitting. Display: [image #1] [image #2] Internal: @.gemini-clipboard/image-1.png @.gemini-clipboard/image-2.png --- .../cli/src/ui/components/InputPrompt.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6a893b33b4e..57f0879046d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -234,10 +234,18 @@ export const InputPrompt: React.FC = ({ if (shellModeActive) { shellHistory.addCommandToHistory(submittedValue); } + + // Transform [image #N] to @.gemini-clipboard/image-N.png before submission + // Most clipboard images are PNG format + const transformedValue = submittedValue.replace( + /\[image #(\d+)\]/g, + '@.gemini-clipboard/image-$1.png', + ); + // Clear the buffer *before* calling onSubmit to prevent potential re-submission // if onSubmit triggers a re-render while the buffer still holds the old value. buffer.setText(''); - onSubmit(submittedValue); + onSubmit(transformedValue); resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -340,11 +348,14 @@ export const InputPrompt: React.FC = ({ // Ignore cleanup errors }); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); + // Extract image number from filename (e.g., "image-1.png" -> 1) + const filename = path.basename(imagePath); + const imageNumberMatch = filename.match(/image-(\d+)\./); + const imageNumber = imageNumberMatch ? imageNumberMatch[1] : '?'; - // Insert @path reference - const insertText = `@${relativePath}`; + // Insert friendly label only: [image #1] + // The actual path will be resolved at submit time + const insertText = `[image #${imageNumber}]`; const currentText = buffer.text; const offset = buffer.getOffset();