From 1311e8c4806a7029149c614a26fb4ecd1488964a Mon Sep 17 00:00:00 2001 From: jhhornn Date: Wed, 18 Mar 2026 15:32:57 +0100 Subject: [PATCH 01/49] fix: updates Docker image reference for GitHub MCP server (#22938) --- docs/cli/tutorials/mcp-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 76c2806f9d..1f3edf716a 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`. "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/modelcontextprotocol/servers/github:latest" + "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" From 81a97e78f1f371fbf4ea63f480aeaa12a74e3068 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:42:15 -0400 Subject: [PATCH 02/49] refactor(cli): group subagent trajectory deletion and use native filesystem testing (#22890) --- .../utils/sessionCleanup.integration.test.ts | 150 ++ packages/cli/src/utils/sessionCleanup.test.ts | 2269 ++++++----------- packages/cli/src/utils/sessionCleanup.ts | 214 +- 3 files changed, 1081 insertions(+), 1552 deletions(-) diff --git a/packages/cli/src/utils/sessionCleanup.integration.test.ts b/packages/cli/src/utils/sessionCleanup.integration.test.ts index eec9a12592..871e30f669 100644 --- a/packages/cli/src/utils/sessionCleanup.integration.test.ts +++ b/packages/cli/src/utils/sessionCleanup.integration.test.ts @@ -252,4 +252,154 @@ describe('Session Cleanup Integration', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('should delete subagent files and their artifacts when parent expires', async () => { + // Create a temporary directory with test sessions + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + const logsDir = path.join(tempDir, 'logs'); + const toolOutputsDir = path.join(tempDir, 'tool-outputs'); + + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + await fs.mkdir(toolOutputsDir, { recursive: true }); + + const now = new Date(); + const oldDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + + // The shortId that ties them together + const sharedShortId = 'abcdef12'; + + const parentSessionId = 'parent-uuid-123'; + const parentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-${sharedShortId}.json`, + ); + await fs.writeFile( + parentFile, + JSON.stringify({ + sessionId: parentSessionId, + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const subagentSessionId = 'subagent-uuid-456'; + const subagentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-05-00-${sharedShortId}.json`, + ); + await fs.writeFile( + subagentFile, + JSON.stringify({ + sessionId: subagentSessionId, + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const parentLogFile = path.join( + logsDir, + `session-${parentSessionId}.jsonl`, + ); + await fs.writeFile(parentLogFile, '{"log": "parent"}'); + + const parentToolOutputsDir = path.join( + toolOutputsDir, + `session-${parentSessionId}`, + ); + await fs.mkdir(parentToolOutputsDir, { recursive: true }); + await fs.writeFile( + path.join(parentToolOutputsDir, 'some-output.txt'), + 'data', + ); + + const subagentLogFile = path.join( + logsDir, + `session-${subagentSessionId}.jsonl`, + ); + await fs.writeFile(subagentLogFile, '{"log": "subagent"}'); + + const subagentToolOutputsDir = path.join( + toolOutputsDir, + `session-${subagentSessionId}`, + ); + await fs.mkdir(subagentToolOutputsDir, { recursive: true }); + await fs.writeFile( + path.join(subagentToolOutputsDir, 'some-output.txt'), + 'data', + ); + + const currentShortId = 'current1'; + const currentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`, + ); + await fs.writeFile( + currentFile, + JSON.stringify({ + sessionId: 'current-session', + messages: [ + { + type: 'user', + content: [{ type: 'text', text: 'hello' }], + timestamp: now.toISOString(), + }, + ], + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + }), + ); + + // Configure test + const config: Config = { + storage: { + getProjectTempDir: () => tempDir, + }, + getSessionId: () => 'current-session', // Mock CLI instance ID + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', // Expire things older than 1 day + }, + }, + }; + + try { + const result = await cleanupExpiredSessions(config, settings); + + // Verify the cleanup result object + // It scanned 3 files. It should delete 2 (parent + subagent), and keep 1 (current) + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(1); + + // Verify on-disk file states + const chats = await fs.readdir(chatsDir); + expect(chats).toHaveLength(1); + expect(chats).toContain( + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`, + ); // Only current is left + + const logs = await fs.readdir(logsDir); + expect(logs).toHaveLength(0); // Both parent and subagent logs were deleted + + const tools = await fs.readdir(toolOutputsDir); + expect(tools).toHaveLength(0); // Both parent and subagent tool output dirs were deleted + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index bcd55953e8..b014159e08 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -6,138 +6,145 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; +import { existsSync, unlinkSync } from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { - SESSION_FILE_PREFIX, type Config, debugLogger, + TOOL_OUTPUTS_DIR, + Storage, } from '@google/gemini-cli-core'; import type { Settings } from '../config/settings.js'; -import { cleanupExpiredSessions } from './sessionCleanup.js'; -import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js'; - -// Mock the fs module -vi.mock('node:fs/promises'); -vi.mock('./sessionUtils.js', () => ({ - getAllSessionFiles: vi.fn(), -})); +import { + cleanupExpiredSessions, + cleanupToolOutputFiles, +} from './sessionCleanup.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - Storage: class MockStorage { - getProjectTempDir() { - return '/tmp/test-project'; - } + debugLogger: { + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), }, }; }); -const mockFs = vi.mocked(fs); -const mockGetAllSessionFiles = vi.mocked(getAllSessionFiles); +describe('Session Cleanup (Refactored)', () => { + let testTempDir: string; + let chatsDir: string; + let logsDir: string; + let toolOutputsDir: string; -// Create mock config -function createMockConfig(overrides: Partial = {}): Config { - return { - storage: { - getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), - }, - getSessionId: vi.fn().mockReturnValue('current123'), - getDebugMode: vi.fn().mockReturnValue(false), - initialize: vi.fn().mockResolvedValue(undefined), - ...overrides, - } as unknown as Config; -} - -// Create test session data -function createTestSessions(): SessionInfo[] { - const now = new Date(); - const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); - const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - - return [ - { - id: 'current123', - file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`, - 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, - }, - { - id: 'recent456', - file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`, - 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, - }, - { - id: 'old789abc', - file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`, - 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, - }, - { - id: 'ancient12', - file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`, - 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, - }, - ]; -} - -describe('Session Cleanup', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); - vi.spyOn(debugLogger, 'error').mockImplementation(() => {}); - vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); - // By default, return all test sessions as valid - const sessions = createTestSessions(); - mockGetAllSessionFiles.mockResolvedValue( - sessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), + testTempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-cli-cleanup-test-'), ); + chatsDir = path.join(testTempDir, 'chats'); + logsDir = path.join(testTempDir, 'logs'); + toolOutputsDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + await fs.mkdir(toolOutputsDir, { recursive: true }); }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + if (testTempDir && existsSync(testTempDir)) { + await fs.rm(testTempDir, { recursive: true, force: true }); + } }); - describe('cleanupExpiredSessions', () => { + function createMockConfig(overrides: Partial = {}): Config { + return { + storage: { + getProjectTempDir: () => testTempDir, + }, + getSessionId: () => 'current123', + getDebugMode: () => false, + initialize: async () => {}, + ...overrides, + } as unknown as Config; + } + + async function writeSessionFile(session: { + id: string; + fileName: string; + lastUpdated: string; + }) { + const filePath = path.join(chatsDir, session.fileName); + await fs.writeFile( + filePath, + JSON.stringify({ + sessionId: session.id, + lastUpdated: session.lastUpdated, + startTime: session.lastUpdated, + messages: [{ type: 'user', content: 'hello' }], + }), + ); + } + + async function writeArtifacts(sessionId: string) { + // Log file + await fs.writeFile( + path.join(logsDir, `session-${sessionId}.jsonl`), + 'log content', + ); + // Tool output directory + const sessionOutputDir = path.join(toolOutputsDir, `session-${sessionId}`); + await fs.mkdir(sessionOutputDir, { recursive: true }); + await fs.writeFile( + path.join(sessionOutputDir, 'output.txt'), + 'tool output', + ); + // Session directory + await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true }); + } + + async function seedSessions() { + const now = new Date(); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const sessions = [ + { + id: 'current123', + fileName: 'session-20250101-current1.json', + lastUpdated: now.toISOString(), + }, + { + id: 'old789abc', + fileName: 'session-20250110-old789ab.json', + lastUpdated: twoWeeksAgo.toISOString(), + }, + { + id: 'ancient12', + fileName: 'session-20241225-ancient1.json', + lastUpdated: oneMonthAgo.toISOString(), + }, + ]; + + for (const session of sessions) { + await writeSessionFile(session); + await writeArtifacts(session.id); + } + return sessions; + } + + describe('Configuration boundaries & early exits', () => { it('should return early when cleanup is disabled', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: false } }, }; - const result = await cleanupExpiredSessions(config, settings); - expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); @@ -147,246 +154,99 @@ describe('Session Cleanup', () => { it('should return early when sessionRetention is not configured', async () => { const config = createMockConfig(); - const settings: Settings = {}; - + const settings: Settings = { general: {} }; const result = await cleanupExpiredSessions(config, settings); - expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); expect(result.deleted).toBe(0); - }); - - it('should handle invalid maxAge configuration', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: 'invalid-format', - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'Session cleanup disabled: Error: Invalid retention period format', - ), - ); - }); - - it('should delete sessions older than maxAge', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '10d', // 10 days - }, - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(2); // Should delete the 2-week-old and 1-month-old sessions - expect(result.skipped).toBe(2); // Current session + recent session should be skipped - expect(result.failed).toBe(0); - }); - - it('should never delete current session', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '1d', // Very short retention - }, - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should delete all sessions except the current one - expect(result.disabled).toBe(false); - expect(result.deleted).toBe(3); - - // Verify that unlink was never called with the current session file - const unlinkCalls = mockFs.unlink.mock.calls; - const currentSessionPath = path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, - ); - expect( - unlinkCalls.find((call) => call[0] === currentSessionPath), - ).toBeUndefined(); - }); - - it('should handle count-based retention', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 2, // Keep only 2 most recent sessions - }, - }, - }; - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(2); // Should delete 2 oldest sessions (after skipping the current one) - expect(result.skipped).toBe(2); // Current session + 1 recent session should be kept - }); - - it('should handle file system errors gracefully', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '1d', - }, - }, - }; - - // Mock file operations to succeed for access and readFile but fail for unlink - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockRejectedValue(new Error('Permission denied')); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); - expect(result.deleted).toBe(0); - expect(result.failed).toBeGreaterThan(0); - }); - - it('should handle empty sessions directory', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); }); - it('should handle global errors gracefully', async () => { + it('should require either maxAge or maxCount', async () => { const config = createMockConfig(); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }, + general: { sessionRetention: { enabled: true } }, }; - - // Mock getSessionFiles to throw an error - mockGetAllSessionFiles.mockRejectedValue( - new Error('Directory access failed'), - ); - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); - expect(debugLogger.warn).toHaveBeenCalledWith( - 'Session cleanup failed: Directory access failed', - ); - }); - - it('should respect minRetention configuration', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '12h', // Less than 1 day minimum - minRetention: '1d', - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - // Should disable cleanup due to minRetention violation expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(result.deleted).toBe(0); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); }); + it.each([0, -1, -5])( + 'should validate maxCount range (rejecting %i)', + async (invalidCount) => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxCount: invalidCount }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxCount must be at least 1'), + ); + }, + ); + + it('should reject if both maxAge and maxCount are invalid', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 0 }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + }); + + it('should reject if maxAge is invalid even when maxCount is valid', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 5 }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + }); + }); + + describe('Logging and Debug Mode', () => { it('should log debug information when enabled', async () => { + await seedSessions(); const config = createMockConfig({ getDebugMode: vi.fn().mockReturnValue(true), }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1 } }, + }; + + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + await cleanupExpiredSessions(config, settings); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Session cleanup: deleted'), + ); + debugSpy.mockRestore(); + }); + }); + + describe('Basic retention rules', () => { + it('should delete sessions older than maxAge', async () => { + const sessions = await seedSessions(); + const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { @@ -396,1304 +256,723 @@ describe('Session Cleanup', () => { }, }; - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const debugSpy = vi - .spyOn(debugLogger, 'debug') - .mockImplementation(() => {}); - - await cleanupExpiredSessions(config, settings); - - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining('Session cleanup: deleted'), - ); - expect(debugSpy).toHaveBeenCalledWith( - expect.stringContaining('Deleted expired session:'), - ); - - debugSpy.mockRestore(); - }); - }); - - describe('Specific cleanup scenarios', () => { - it('should delete sessions that exceed the cutoff date', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '7d', // Keep sessions for 7 days - }, - }, - }; - - // Create sessions with specific dates - const now = new Date(); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000); - const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); - - const testSessions: SessionInfo[] = [ - { - id: 'current', - file: `${SESSION_FILE_PREFIX}current`, - fileName: `${SESSION_FILE_PREFIX}current.json`, - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 1, - displayName: 'Current', - firstUserMessage: 'Current', - isCurrentSession: true, - index: 1, - }, - { - id: 'session5d', - file: `${SESSION_FILE_PREFIX}5d`, - 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, - }, - { - id: 'session8d', - file: `${SESSION_FILE_PREFIX}8d`, - 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, - }, - { - id: 'session15d', - file: `${SESSION_FILE_PREFIX}15d`, - 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, - }, - ]; - - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - const result = await cleanupExpiredSessions(config, settings); - // Should delete sessions older than 7 days (8d and 15d sessions) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); + expect(result.scanned).toBe(3); expect(result.deleted).toBe(2); - expect(result.skipped).toBe(2); // Current + 5d session + expect(result.skipped).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); - // Verify which files were deleted - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}8d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}15d.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); + // Verify artifacts for an old session are gone + expect( + existsSync(path.join(logsDir, `session-${sessions[1].id}.jsonl`)), + ).toBe(false); + expect( + existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)), + ).toBe(false); + expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted }); it('should NOT delete sessions within the cutoff date', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] const config = createMockConfig(); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '14d', // Keep sessions for 14 days - }, - }, + general: { sessionRetention: { enabled: true, maxAge: '60d' } }, }; - // Create sessions all within the retention period - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const thirteenDaysAgo = new Date( - now.getTime() - 13 * 24 * 60 * 60 * 1000, - ); - - const testSessions: SessionInfo[] = [ - { - id: 'current', - file: `${SESSION_FILE_PREFIX}current`, - fileName: `${SESSION_FILE_PREFIX}current.json`, - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 1, - displayName: 'Current', - firstUserMessage: 'Current', - isCurrentSession: true, - index: 1, - }, - { - id: 'session1d', - file: `${SESSION_FILE_PREFIX}1d`, - 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, - }, - { - id: 'session7d', - file: `${SESSION_FILE_PREFIX}7d`, - 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, - }, - { - id: 'session13d', - file: `${SESSION_FILE_PREFIX}13d`, - 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, - }, - ]; - - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - + // 60d cutoff should keep everything that was seeded const result = await cleanupExpiredSessions(config, settings); - // Should NOT delete any sessions as all are within 14 days - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(4); expect(result.deleted).toBe(0); - expect(result.skipped).toBe(4); - expect(result.failed).toBe(0); - - // Verify no files were deleted - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('should keep N most recent deletable sessions', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 3, // Keep only 3 most recent sessions - }, - }, - }; - - // Create 6 sessions with different timestamps - const now = new Date(); - const sessions: SessionInfo[] = [ - { - id: 'current', - file: `${SESSION_FILE_PREFIX}current`, - fileName: `${SESSION_FILE_PREFIX}current.json`, - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 1, - displayName: 'Current (newest)', - firstUserMessage: 'Current', - isCurrentSession: true, - index: 1, - }, - ]; - - // Add 5 more sessions with decreasing timestamps - for (let i = 1; i <= 5; i++) { - const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); - sessions.push({ - id: `session${i}`, - file: `${SESSION_FILE_PREFIX}${i}d`, - 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, - }); - } - - mockGetAllSessionFiles.mockResolvedValue( - sessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should keep current + 2 most recent (1d and 2d), delete 3d, 4d, 5d - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(6); - expect(result.deleted).toBe(3); expect(result.skipped).toBe(3); - - // Verify which files were deleted (should be the 3 oldest) - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}3d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}4d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); - - // Verify which files were NOT deleted - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}current.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}1d.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}2d.json`, - ), - ); + for (const session of sessions) { + expect(existsSync(path.join(chatsDir, session.fileName))).toBe(true); + } }); - it('should handle combined maxAge and maxCount retention (most restrictive wins)', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '10d', // Keep sessions for 10 days - maxCount: 2, // But also keep only 2 most recent - }, - }, - }; + it('should handle count-based retention (keeping N most recent)', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] - // Create sessions where maxCount is more restrictive + // Seed two additional granular files to prove sorting works const now = new Date(); const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000); - const testSessions: SessionInfo[] = [ - { - id: 'current', - file: `${SESSION_FILE_PREFIX}current`, - fileName: `${SESSION_FILE_PREFIX}current.json`, - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 1, - displayName: 'Current', - firstUserMessage: 'Current', - isCurrentSession: true, - index: 1, - }, - { - id: 'session3d', - file: `${SESSION_FILE_PREFIX}3d`, - 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, - }, - { - id: 'session5d', - file: `${SESSION_FILE_PREFIX}5d`, - 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, - }, - { - id: 'session7d', - file: `${SESSION_FILE_PREFIX}7d`, - 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, - }, - { - id: 'session12d', - file: `${SESSION_FILE_PREFIX}12d`, - 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, - }, - ]; + await writeSessionFile({ + id: 'recent3', + fileName: 'session-20250117-recent3.json', + lastUpdated: threeDaysAgo.toISOString(), + }); + await writeArtifacts('recent3'); + await writeSessionFile({ + id: 'recent5', + fileName: 'session-20250115-recent5.json', + lastUpdated: fiveDaysAgo.toISOString(), + }); + await writeArtifacts('recent5'); - mockGetAllSessionFiles.mockResolvedValue( - testSessions.map((session) => ({ - fileName: session.fileName, - sessionInfo: session, - })), - ); - - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - sessionId: 'test', - messages: [], - startTime: '2025-01-01T00:00:00Z', - lastUpdated: '2025-01-01T00:00:00Z', - }), - ); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - // Should delete: - // - session12d (exceeds maxAge of 10d) - // - session7d and session5d (exceed maxCount of 2, keeping current + 3d) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(5); - expect(result.deleted).toBe(3); - expect(result.skipped).toBe(2); // Current + 3d session - - // Verify which files were deleted - const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}5d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}7d.json`, - ), - ); - expect(unlinkCalls).toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}12d.json`, - ), - ); - - // Verify which files were NOT deleted - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}current.json`, - ), - ); - expect(unlinkCalls).not.toContain( - path.join( - '/tmp/test-project', - 'chats', - `${SESSION_FILE_PREFIX}3d.json`, - ), - ); - }); - - it('should delete the session-specific directory', async () => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, - maxAge: '1d', // Very short retention to trigger deletion of all but current + maxCount: 3, // Keep current + 2 most recent (which should be 3d and 5d) }, }, }; - // Mock successful file operations - mockFs.access.mockResolvedValue(undefined); - mockFs.unlink.mockResolvedValue(undefined); - mockFs.rm.mockResolvedValue(undefined); + const result = await cleanupExpiredSessions(config, settings); - await cleanupExpiredSessions(config, settings); + expect(result.scanned).toBe(5); + expect(result.deleted).toBe(2); // Should only delete the 14d and 30d old sessions + expect(result.skipped).toBe(3); + expect(result.failed).toBe(0); - // Verify that fs.rm was called with the session directory for the deleted session that has sessionInfo - // recent456 should be deleted and its directory removed - expect(mockFs.rm).toHaveBeenCalledWith( - path.join('/tmp/test-project', 'recent456'), - expect.objectContaining({ recursive: true, force: true }), + // Verify specifically WHICH files survived + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current + expect( + existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), + ).toBe(true); // 3d + expect( + existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), + ).toBe(true); // 5d + + // Verify the older ones were deleted + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d + }); + + it('should delete subagent files sharing the same shortId', async () => { + const now = new Date(); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + // Parent session (expired) + await writeSessionFile({ + id: 'parent-uuid', + fileName: 'session-20250110-abc12345.json', + lastUpdated: twoWeeksAgo.toISOString(), + }); + await writeArtifacts('parent-uuid'); + + // Subagent session (different UUID, same shortId) + await writeSessionFile({ + id: 'sub-uuid', + fileName: 'session-20250110-subagent-abc12345.json', + lastUpdated: twoWeeksAgo.toISOString(), + }); + await writeArtifacts('sub-uuid'); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(2); // Both files should be deleted + expect( + existsSync(path.join(chatsDir, 'session-20250110-abc12345.json')), + ).toBe(false); + expect( + existsSync( + path.join(chatsDir, 'session-20250110-subagent-abc12345.json'), + ), + ).toBe(false); + + // Artifacts for both should be gone + expect(existsSync(path.join(logsDir, 'session-parent-uuid.jsonl'))).toBe( + false, ); + expect(existsSync(path.join(logsDir, 'session-sub-uuid.jsonl'))).toBe( + false, + ); + }); + + it('should delete corrupted session files', async () => { + // Write a corrupted file (invalid JSON) + const corruptPath = path.join(chatsDir, 'session-corrupt.json'); + await fs.writeFile(corruptPath, 'invalid json'); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(existsSync(corruptPath)).toBe(false); + }); + + it('should safely delete 8-character sessions containing invalid JSON', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const badJsonPath = path.join(chatsDir, 'session-20241225-badjson1.json'); + await fs.writeFile(badJsonPath, 'This is raw text, not JSON'); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(badJsonPath)).toBe(false); + }); + + it('should safely delete legacy non-8-character sessions', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const legacyPath = path.join(chatsDir, 'session-20241225-legacy.json'); + // Create valid JSON so the parser succeeds, but shortId derivation fails + await fs.writeFile( + legacyPath, + JSON.stringify({ + sessionId: 'legacy-session-id', + lastUpdated: '2024-12-25T00:00:00.000Z', + messages: [], + }), + ); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(legacyPath)).toBe(false); + }); + + it('should silently ignore ENOENT if file is already deleted before unlink', async () => { + await seedSessions(); // Seeds older 2024 and 2025 sessions + const targetFile = path.join(chatsDir, 'session-20241225-ancient1.json'); + let getSessionIdCalls = 0; + + const config = createMockConfig({ + getSessionId: () => { + getSessionIdCalls++; + // First call is for `getAllSessionFiles`. + // Subsequent calls are right before `fs.unlink`! + if (getSessionIdCalls > 1) { + try { + unlinkSync(targetFile); + } catch { + /* ignore */ + } + } + return 'mock-session-id'; + }, + }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // `failed` should not increment because ENOENT is silently swallowed + expect(result.failed).toBe(0); + }); + + it('should respect minRetention configuration', async () => { + await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '12h', // Less than 1 day minRetention + minRetention: '1d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // Should return early and not delete anything + expect(result.disabled).toBe(true); + expect(result.deleted).toBe(0); + }); + + it('should handle combined maxAge and maxCount (most restrictive wins)', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] + + // Seed 3d and 5d to mirror the granular sorting test + const now = new Date(); + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + + await writeSessionFile({ + id: 'recent3', + fileName: 'session-20250117-recent3.json', + lastUpdated: threeDaysAgo.toISOString(), + }); + await writeArtifacts('recent3'); + await writeSessionFile({ + id: 'recent5', + fileName: 'session-20250115-recent5.json', + lastUpdated: fiveDaysAgo.toISOString(), + }); + await writeArtifacts('recent5'); + + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + // 20d deletes 30d. + // maxCount: 2 keeps current and 3d. + // Restrictive wins: 30d deleted by maxAge. 14d, 5d deleted by maxCount. + maxAge: '20d', + maxCount: 2, + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.scanned).toBe(5); + expect(result.deleted).toBe(3); // deletes 5d, 14d, 30d + expect(result.skipped).toBe(2); // keeps current, 3d + expect(result.failed).toBe(0); + + // Assert kept + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current + expect( + existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), + ).toBe(true); // 3d + + // Assert deleted + expect( + existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), + ).toBe(false); // 5d + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d + }); + + it('should handle empty sessions directory', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '30d' } }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); }); }); - describe('parseRetentionPeriod format validation', () => { - // Test all supported formats + describe('Error handling & resilience', () => { + it.skipIf(process.platform === 'win32')( + 'should handle file system errors gracefully (e.g., EACCES)', + async () => { + const sessions = await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + // Make one of the files read-only and its parent directory read-only to simulate EACCES during unlink + const targetFile = path.join(chatsDir, sessions[1].fileName); + await fs.chmod(targetFile, 0o444); + // Wait we want unlink to fail, so we make the directory read-only temporarily + await fs.chmod(chatsDir, 0o555); + + try { + const result = await cleanupExpiredSessions(config, settings); + + // It shouldn't crash + expect(result.disabled).toBe(false); + // It should have tried and failed to delete the old session + expect(result.failed).toBeGreaterThan(0); + } finally { + // Restore permissions so cleanup can proceed in afterEach + await fs.chmod(chatsDir, 0o777); + await fs.chmod(targetFile, 0o666); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'should handle global read errors gracefully', + async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + // Make the chats directory unreadable + await fs.chmod(chatsDir, 0o000); + + try { + const result = await cleanupExpiredSessions(config, settings); + + // It shouldn't crash, but it should fail + expect(result.disabled).toBe(false); + expect(result.failed).toBe(1); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Session cleanup failed'), + ); + } finally { + await fs.chmod(chatsDir, 0o777); + } + }, + ); + + it('should NOT delete tempDir if safeSessionId is empty', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const sessions = await seedSessions(); + const targetFile = path.join(chatsDir, sessions[1].fileName); + + // Write a session ID that sanitizeFilenamePart will turn into an empty string "" + await fs.writeFile(targetFile, JSON.stringify({ sessionId: '../../..' })); + + const tempDir = config.storage.getProjectTempDir(); + expect(existsSync(tempDir)).toBe(true); + + await cleanupExpiredSessions(config, settings); + + // It must NOT delete the tempDir root + expect(existsSync(tempDir)).toBe(true); + }); + + it('should handle unexpected errors without throwing (e.g. string errors)', async () => { + await seedSessions(); + const config = createMockConfig({ + getSessionId: () => { + const stringError = 'String error' as unknown as Error; + throw stringError; // Throw a non-Error string without triggering no-restricted-syntax + }, + }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1 } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.failed).toBeGreaterThan(0); + }); + + it('should never run on the current session', async () => { + await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 1, // Keep only 1 session (which will be the current one) + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(1); // The current session + const currentSessionFile = (await fs.readdir(chatsDir)).find((f) => + f.includes('current1'), + ); + expect(currentSessionFile).toBeDefined(); + }); + }); + + describe('Format parsing & validation', () => { + // Valid formats it.each([ - ['1h', 60 * 60 * 1000], - ['24h', 24 * 60 * 60 * 1000], - ['168h', 168 * 60 * 60 * 1000], - ['1d', 24 * 60 * 60 * 1000], - ['7d', 7 * 24 * 60 * 60 * 1000], - ['30d', 30 * 24 * 60 * 60 * 1000], - ['365d', 365 * 24 * 60 * 60 * 1000], - ['1w', 7 * 24 * 60 * 60 * 1000], - ['2w', 14 * 24 * 60 * 60 * 1000], - ['4w', 28 * 24 * 60 * 60 * 1000], - ['52w', 364 * 24 * 60 * 60 * 1000], - ['1m', 30 * 24 * 60 * 60 * 1000], - ['3m', 90 * 24 * 60 * 60 * 1000], - ['6m', 180 * 24 * 60 * 60 * 1000], - ['12m', 360 * 24 * 60 * 60 * 1000], - ])('should correctly parse valid format %s', async (input) => { + ['1h'], + ['24h'], + ['168h'], + ['1d'], + ['7d'], + ['30d'], + ['365d'], + ['1w'], + ['2w'], + ['4w'], + ['52w'], + ['1m'], + ['3m'], + ['12m'], + ['9999d'], + ])('should accept valid maxAge format %s', async (input) => { const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: input, - // Set minRetention to 1h to allow testing of hour-based maxAge values minRetention: '1h', }, }, }; - mockGetAllSessionFiles.mockResolvedValue([]); - - // If it parses correctly, cleanup should proceed without error const result = await cleanupExpiredSessions(config, settings); expect(result.disabled).toBe(false); expect(result.failed).toBe(0); }); - // Test invalid formats - it.each([ - '30', // Missing unit - '30x', // Invalid unit - 'd', // No number - '1.5d', // Decimal not supported - '-5d', // Negative number - '1 d', // Space in format - '1dd', // Double unit - 'abc', // Non-numeric - '30s', // Unsupported unit (seconds) - '30y', // Unsupported unit (years) - '0d', // Zero value (technically valid regex but semantically invalid) - ])('should reject invalid format %s', async (input) => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); + it('should accept maxAge equal to minRetention', async () => { + const config = createMockConfig(); const settings: Settings = { general: { - sessionRetention: { - enabled: true, - maxAge: input, - }, + sessionRetention: { enabled: true, maxAge: '1d', minRetention: '1d' }, }, }; - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - input === '0d' - ? 'Invalid retention period: 0d. Value must be greater than 0' - : `Invalid retention period format: ${input}`, - ), - ); + expect(result.disabled).toBe(false); }); - // Test special case - empty string - it('should reject empty string', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); + it('should accept maxCount = 1000 (maximum valid)', async () => { + const config = createMockConfig(); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '', - }, - }, + general: { sessionRetention: { enabled: true, maxCount: 1000 } }, }; - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - // Empty string means no valid retention method specified - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Either maxAge or maxCount must be specified'), - ); + expect(result.disabled).toBe(false); }); - // Test edge cases - it('should handle very large numbers', async () => { + it('should reject maxAge less than default minRetention (1d)', async () => { + await seedSessions(); const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, - maxAge: '9999d', // Very large number + maxAge: '12h', + // Note: No minRetention provided here, should default to 1d }, }, }; - mockGetAllSessionFiles.mockResolvedValue([]); + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxAge cannot be less than minRetention'), + ); + }); + + it('should reject maxAge less than custom minRetention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '2d', + minRetention: '3d', // maxAge < minRetention + }, + }, + }; const result = await cleanupExpiredSessions(config, settings); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(0); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxAge cannot be less than minRetention (3d)'), + ); + }); + + it('should reject zero value with a specific error message', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '0d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Value must be greater than 0'), + ); + }); + + // Invalid formats + it.each([ + ['30'], + ['30x'], + ['d'], + ['1.5d'], + ['-5d'], + ['1 d'], + ['1dd'], + ['abc'], + ['30s'], + ['30y'], + ])('should reject invalid maxAge format %s', async (input) => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: input } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining(`Invalid retention period format: ${input}`), + ); + }); + + it('should reject empty string for maxAge', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); }); it('should validate minRetention format', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); + const config = createMockConfig(); const settings: Settings = { general: { sessionRetention: { enabled: true, maxAge: '5d', - minRetention: 'invalid-format', // Invalid minRetention + minRetention: 'invalid-format', }, }, }; - mockGetAllSessionFiles.mockResolvedValue([]); - // Should fall back to default minRetention and proceed const result = await cleanupExpiredSessions(config, settings); - - // Since maxAge (5d) > default minRetention (1d), this should succeed expect(result.disabled).toBe(false); - expect(result.failed).toBe(0); }); }); - describe('Configuration validation', () => { - it('should require either maxAge or maxCount', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - // Neither maxAge nor maxCount specified - }, - }, - }; + describe('Tool Output Cleanup', () => { + let toolOutputDir: string; - const result = await cleanupExpiredSessions(config, settings); + beforeEach(async () => { + toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + }); + + async function seedToolOutputs() { + const now = new Date(); + const oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago + + const file1 = path.join(toolOutputDir, 'output1.json'); + await fs.writeFile(file1, '{}'); + + const file2 = path.join(toolOutputDir, 'output2.json'); + await fs.writeFile(file2, '{}'); + + // Manually backdate file1 + await fs.utimes(file1, oldTime, oldTime); + + // Create an old session subdirectory + const oldSubdir = path.join(toolOutputDir, 'session-old'); + await fs.mkdir(oldSubdir); + await fs.utimes(oldSubdir, oldTime, oldTime); + + return { file1, file2, oldSubdir }; + } + + it('should return early if cleanup is disabled', async () => { + const settings: Settings = { + general: { sessionRetention: { enabled: false } }, + }; + const result = await cleanupToolOutputFiles(settings, false, testTempDir); expect(result.disabled).toBe(true); expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Either maxAge or maxCount must be specified'), - ); + expect(result.deleted).toBe(0); }); - it('should validate maxCount range', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); + it('should gracefully handle missing tool-outputs directory', async () => { + await fs.rm(toolOutputDir, { recursive: true, force: true }); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 0, // Invalid count - }, - }, + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; - const result = await cleanupExpiredSessions(config, settings); + const result = await cleanupToolOutputFiles(settings, false, testTempDir); - expect(result.disabled).toBe(true); + expect(result.disabled).toBe(false); expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('maxCount must be at least 1'), - ); }); - describe('maxAge format validation', () => { - it('should reject invalid maxAge format - no unit', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30', // Missing unit - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 30'), - ); - }); - it('should reject invalid maxAge format - invalid unit', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30x', // Invalid unit 'x' - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 30x'), - ); - }); - it('should reject invalid maxAge format - no number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: 'd', // No number - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: d'), - ); - }); - it('should reject invalid maxAge format - decimal number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '1.5d', // Decimal not supported - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: 1.5d'), - ); - }); - it('should reject invalid maxAge format - negative number', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '-5d', // Negative not allowed - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format: -5d'), - ); - }); - it('should accept valid maxAge format - hours', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '48h', // Valid: 48 hours - maxCount: 10, // Need at least one valid retention method - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - days', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '7d', // Valid: 7 days - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - weeks', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '2w', // Valid: 2 weeks - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept valid maxAge format - months', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '3m', // Valid: 3 months - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('minRetention validation', () => { - it('should reject maxAge less than default minRetention (1d)', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '12h', // Less than default 1d minRetention - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'maxAge cannot be less than minRetention (1d)', - ), - ); - }); - it('should reject maxAge less than custom minRetention', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '2d', - minRetention: '3d', // maxAge < minRetention - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'maxAge cannot be less than minRetention (3d)', - ), - ); - }); - it('should accept maxAge equal to minRetention', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '2d', - minRetention: '2d', // maxAge == minRetention (edge case) - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept maxAge greater than minRetention', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '7d', - minRetention: '2d', // maxAge > minRetention - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should handle invalid minRetention format gracefully', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '5d', - minRetention: 'invalid', // Invalid format - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - // When minRetention is invalid, it should default to 1d - // Since maxAge (5d) > default minRetention (1d), this should be valid - const result = await cleanupExpiredSessions(config, settings); - - // Should not reject due to minRetention (falls back to default) - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('maxCount boundary validation', () => { - it('should accept maxCount = 1 (minimum valid)', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 1, // Minimum valid value - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should accept maxCount = 1000 (maximum valid)', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 1000, // Maximum valid value - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should reject negative maxCount', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: -1, // Negative value - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('maxCount must be at least 1'), - ); - }); - it('should accept valid maxCount in normal range', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxCount: 50, // Normal valid value - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - }); - - describe('combined configuration validation', () => { - it('should accept valid maxAge and maxCount together', async () => { - const config = createMockConfig(); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30d', - maxCount: 10, - }, - }, - }; - - mockGetAllSessionFiles.mockResolvedValue([]); - - const result = await cleanupExpiredSessions(config, settings); - - // Should accept the configuration - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(0); - expect(result.failed).toBe(0); - }); - - it('should reject if both maxAge and maxCount are invalid', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: 'invalid', - maxCount: 0, - }, - }, - }; - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - // Should fail on first validation error (maxAge format) - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format'), - ); - }); - it('should reject if maxAge is invalid even when maxCount is valid', async () => { - const config = createMockConfig({ - getDebugMode: vi.fn().mockReturnValue(true), - }); - const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: 'invalid', // Invalid format - maxCount: 5, // Valid count - }, - }, - }; - - // The validation logic rejects invalid maxAge format even if maxCount is valid - const result = await cleanupExpiredSessions(config, settings); - - // Should reject due to invalid maxAge format - expect(result.disabled).toBe(true); - expect(result.scanned).toBe(0); - expect(debugLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid retention period format'), - ); - }); - }); - - it('should never throw an exception, always returning a result', async () => { - const config = createMockConfig(); + it('should delete flat files and subdirectories based on maxAge', async () => { + const { file1, file2, oldSubdir } = await seedToolOutputs(); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '7d', - }, - }, + general: { sessionRetention: { enabled: true, maxAge: '5d' } }, }; - // Mock getSessionFiles to throw an error - mockGetAllSessionFiles.mockRejectedValue( - new Error('Failed to read directory'), - ); + const result = await cleanupToolOutputFiles(settings, false, testTempDir); - // Should not throw, should return a result with errors - const result = await cleanupExpiredSessions(config, settings); - - expect(result).toBeDefined(); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); + // file1 and oldSubdir should be deleted. + expect(result.deleted).toBe(2); + expect(existsSync(file1)).toBe(false); + expect(existsSync(oldSubdir)).toBe(false); + expect(existsSync(file2)).toBe(true); }); - it('should delete corrupted session files', async () => { - const config = createMockConfig(); + it('should delete oldest-first flat files based on maxCount when maxAge does not hit', async () => { + const { file1, file2 } = await seedToolOutputs(); const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30d', - }, - }, + general: { sessionRetention: { enabled: true, maxCount: 1 } }, }; - // Mock getAllSessionFiles to return both valid and corrupted files - const validSession = createTestSessions()[0]; - mockGetAllSessionFiles.mockResolvedValue([ - { fileName: validSession.fileName, sessionInfo: validSession }, - { - fileName: `${SESSION_FILE_PREFIX}2025-01-02T10-00-00-corrupt1.json`, - sessionInfo: null, - }, - { - fileName: `${SESSION_FILE_PREFIX}2025-01-03T10-00-00-corrupt2.json`, - sessionInfo: null, - }, - ]); + const result = await cleanupToolOutputFiles(settings, false, testTempDir); - mockFs.unlink.mockResolvedValue(undefined); - - const result = await cleanupExpiredSessions(config, settings); - - expect(result.disabled).toBe(false); - expect(result.scanned).toBe(3); // 1 valid + 2 corrupted - expect(result.deleted).toBe(2); // Should delete the 2 corrupted files - expect(result.skipped).toBe(1); // The valid session is kept - - // Verify corrupted files were deleted - expect(mockFs.unlink).toHaveBeenCalledWith( - expect.stringContaining('corrupt1.json'), - ); - expect(mockFs.unlink).toHaveBeenCalledWith( - expect.stringContaining('corrupt2.json'), - ); + // Excess is 1. Oldest is file1. So file1 is deleted. + expect(result.deleted).toBe(1); + expect(existsSync(file1)).toBe(false); + expect(existsSync(file2)).toBe(true); }); - it('should handle unexpected errors without throwing', async () => { - const config = createMockConfig(); + it('should skip tool-output subdirectories with unsafe names', async () => { const settings: Settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '7d', - }, - }, + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, }; - // Mock getSessionFiles to throw a non-Error object - mockGetAllSessionFiles.mockRejectedValue('String error'); + // Create a directory with a name that is semantically unsafe for sanitization rules + const unsafeSubdir = path.join(toolOutputDir, 'session-unsafe@name'); + await fs.mkdir(unsafeSubdir); - // Should not throw, should return a result with errors - const result = await cleanupExpiredSessions(config, settings); + // Backdate it so it WOULD be deleted if it were safely named + const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + await fs.utimes(unsafeSubdir, oldTime, oldTime); - expect(result).toBeDefined(); - expect(result.disabled).toBe(false); - expect(result.failed).toBe(1); + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + // Must be scanned but actively skipped from deletion due to sanitization mismatch + expect(result.deleted).toBe(0); + expect(existsSync(unsafeSubdir)).toBe(true); + }); + + it('should initialize Storage when projectTempDir is not explicitly provided', async () => { + const getProjectTempDirSpy = vi + .spyOn(Storage.prototype, 'getProjectTempDir') + .mockReturnValue(testTempDir); + const initializeSpy = vi + .spyOn(Storage.prototype, 'initialize') + .mockResolvedValue(undefined); + + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + const { oldSubdir } = await seedToolOutputs(); + + // Call explicitly without third parameter + const result = await cleanupToolOutputFiles(settings, false); + + expect(initializeSpy).toHaveBeenCalled(); + expect(result.deleted).toBeGreaterThan(0); + expect(existsSync(oldSubdir)).toBe(false); + + getProjectTempDirSpy.mockRestore(); + initializeSpy.mockRestore(); }); }); }); diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index 57f2fdd189..5ed4547604 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -9,6 +9,7 @@ import * as path from 'node:path'; import { debugLogger, sanitizeFilenamePart, + SESSION_FILE_PREFIX, Storage, TOOL_OUTPUTS_DIR, type Config, @@ -26,6 +27,12 @@ const MULTIPLIERS = { m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms }; +/** + * Matches a trailing hyphen followed by exactly 8 alphanumeric characters before the .json extension. + * Example: session-20250110-abcdef12.json -> captures "abcdef12" + */ +const SHORT_ID_REGEX = /-([a-zA-Z0-9]{8})\.json$/; + /** * Result of session cleanup operation */ @@ -37,6 +44,65 @@ export interface CleanupResult { failed: number; } +/** + * Helpers for session cleanup. + */ + +/** + * Derives an 8-character shortId from a session filename. + */ +function deriveShortIdFromFileName(fileName: string): string | null { + if (fileName.startsWith(SESSION_FILE_PREFIX) && fileName.endsWith('.json')) { + const match = fileName.match(SHORT_ID_REGEX); + return match ? match[1] : null; + } + return null; +} + +/** + * Gets the log path for a session ID. + */ +function getSessionLogPath(tempDir: string, safeSessionId: string): string { + return path.join(tempDir, 'logs', `session-${safeSessionId}.jsonl`); +} + +/** + * Cleans up associated artifacts (logs, tool-outputs, directory) for a session. + */ +async function deleteSessionArtifactsAsync( + sessionId: string, + config: Config, +): Promise { + const tempDir = config.storage.getProjectTempDir(); + + // Cleanup logs + const logsDir = path.join(tempDir, 'logs'); + const safeSessionId = sanitizeFilenamePart(sessionId); + const logPath = getSessionLogPath(tempDir, safeSessionId); + if (logPath.startsWith(logsDir)) { + await fs.unlink(logPath).catch(() => {}); + } + + // Cleanup tool outputs + const toolOutputDir = path.join( + tempDir, + TOOL_OUTPUTS_DIR, + `session-${safeSessionId}`, + ); + const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR); + if (toolOutputDir.startsWith(toolOutputsBase)) { + await fs + .rm(toolOutputDir, { recursive: true, force: true }) + .catch(() => {}); + } + + // Cleanup session directory + const sessionDir = path.join(tempDir, safeSessionId); + if (safeSessionId && sessionDir.startsWith(tempDir + path.sep)) { + await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => {}); + } +} + /** * Main entry point for session cleanup during CLI startup */ @@ -72,7 +138,6 @@ export async function cleanupExpiredSessions( return { ...result, disabled: true }; } - // Get all session files (including corrupted ones) for this project const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); result.scanned = allFiles.length; @@ -86,78 +151,110 @@ export async function cleanupExpiredSessions( retentionConfig, ); + const processedShortIds = new Set(); + // Delete all sessions that need to be deleted for (const sessionToDelete of sessionsToDelete) { try { - const sessionPath = path.join(chatsDir, sessionToDelete.fileName); - await fs.unlink(sessionPath); + const shortId = deriveShortIdFromFileName(sessionToDelete.fileName); - // ALSO cleanup Activity logs in the project logs directory - const sessionId = sessionToDelete.sessionInfo?.id; - if (sessionId) { - const logsDir = path.join(config.storage.getProjectTempDir(), 'logs'); - const logPath = path.join(logsDir, `session-${sessionId}.jsonl`); - try { - await fs.unlink(logPath); - } catch { - /* ignore if log doesn't exist */ + if (shortId) { + if (processedShortIds.has(shortId)) { + continue; } + processedShortIds.add(shortId); - // ALSO cleanup tool outputs for this session - const safeSessionId = sanitizeFilenamePart(sessionId); - const toolOutputDir = path.join( - config.storage.getProjectTempDir(), - TOOL_OUTPUTS_DIR, - `session-${safeSessionId}`, - ); - try { - await fs.rm(toolOutputDir, { recursive: true, force: true }); - } catch { - /* ignore if doesn't exist */ - } - - // ALSO cleanup the session-specific directory (contains plans, tasks, etc.) - const sessionDir = path.join( - config.storage.getProjectTempDir(), - sessionId, - ); - try { - await fs.rm(sessionDir, { recursive: true, force: true }); - } catch { - /* ignore if doesn't exist */ - } - } - - if (config.getDebugMode()) { - if (sessionToDelete.sessionInfo === null) { - debugLogger.debug( - `Deleted corrupted session file: ${sessionToDelete.fileName}`, + const matchingFiles = allFiles + .map((f) => f.fileName) + .filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + f.endsWith(`-${shortId}.json`), ); - } else { + + for (const file of matchingFiles) { + const filePath = path.join(chatsDir, file); + let fullSessionId: string | undefined; + + try { + // Try to read file to get full sessionId + try { + const fileContent = await fs.readFile(filePath, 'utf8'); + const content: unknown = JSON.parse(fileContent); + if ( + content && + typeof content === 'object' && + 'sessionId' in content + ) { + const record = content as Record; + const id = record['sessionId']; + if (typeof id === 'string') { + fullSessionId = id; + } + } + } catch { + // If read/parse fails, skip getting sessionId, just delete the file + } + + // Delete the session file + if (!fullSessionId || fullSessionId !== config.getSessionId()) { + await fs.unlink(filePath); + + if (fullSessionId) { + await deleteSessionArtifactsAsync(fullSessionId, config); + } + result.deleted++; + } else { + result.skipped++; + } + } catch (error) { + // Ignore ENOENT (file already deleted) + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + // File already deleted, do nothing. + } else { + debugLogger.warn( + `Failed to delete matching file ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + result.failed++; + } + } + } + } else { + // Fallback to old logic + const sessionPath = path.join(chatsDir, sessionToDelete.fileName); + await fs.unlink(sessionPath); + + const sessionId = sessionToDelete.sessionInfo?.id; + if (sessionId) { + await deleteSessionArtifactsAsync(sessionId, config); + } + + if (config.getDebugMode()) { debugLogger.debug( - `Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`, + `Deleted fallback session: ${sessionToDelete.fileName}`, ); } + result.deleted++; } - result.deleted++; } catch (error) { - // Ignore ENOENT errors (file already deleted) + // Ignore ENOENT (file already deleted) if ( error instanceof Error && 'code' in error && error.code === 'ENOENT' ) { - // File already deleted, do nothing. + // File already deleted } else { - // Log error directly to console const sessionId = sessionToDelete.sessionInfo === null ? sessionToDelete.fileName : sessionToDelete.sessionInfo.id; - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; debugLogger.warn( - `Failed to delete session ${sessionId}: ${errorMessage}`, + `Failed to delete session ${sessionId}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); result.failed++; } @@ -182,9 +279,6 @@ export async function cleanupExpiredSessions( return result; } -/** - * Identifies sessions that should be deleted (corrupted or expired based on retention policy) - */ /** * Identifies sessions that should be deleted (corrupted or expired based on retention policy) */ @@ -248,13 +342,19 @@ export async function identifySessionsToDelete( let shouldDelete = false; // Age-based retention check - if (cutoffDate && new Date(session.lastUpdated) < cutoffDate) { - shouldDelete = true; + if (cutoffDate) { + const lastUpdatedDate = new Date(session.lastUpdated); + const isExpired = lastUpdatedDate < cutoffDate; + if (isExpired) { + shouldDelete = true; + } } // Count-based retention check (keep only N most recent deletable sessions) - if (maxDeletableSessions !== undefined && i >= maxDeletableSessions) { - shouldDelete = true; + if (maxDeletableSessions !== undefined) { + if (i >= maxDeletableSessions) { + shouldDelete = true; + } } if (shouldDelete) { From d7dfcf7f99af96197bcabecca49b3f8544aaf4f5 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 18 Mar 2026 16:38:56 +0000 Subject: [PATCH 03/49] refactor(cli): simplify keypress and mouse providers and update tests (#22853) --- packages/cli/src/interactiveCli.tsx | 14 +- packages/cli/src/test-utils/AppRig.tsx | 5 +- .../cli/src/test-utils/mockCommandContext.ts | 16 +- packages/cli/src/test-utils/render.tsx | 73 +--- packages/cli/src/test-utils/settings.ts | 10 +- packages/cli/src/ui/App.test.tsx | 49 ++- packages/cli/src/ui/AppContainer.test.tsx | 158 +++------ packages/cli/src/ui/AppContainer.tsx | 6 - .../cli/src/ui/IdeIntegrationNudge.test.tsx | 39 +- .../ui/components/AgentConfigDialog.test.tsx | 53 ++- .../src/ui/components/AskUserDialog.test.tsx | 23 +- .../components/EditorSettingsDialog.test.tsx | 7 +- .../ui/components/ExitPlanModeDialog.test.tsx | 18 +- .../ui/components/FolderTrustDialog.test.tsx | 33 +- .../ui/components/HistoryItemDisplay.test.tsx | 36 +- .../src/ui/components/InputPrompt.test.tsx | 11 +- .../src/ui/components/MainContent.test.tsx | 18 +- .../src/ui/components/SettingsDialog.test.tsx | 82 +++-- .../components/ToolConfirmationQueue.test.tsx | 14 +- .../components/messages/DiffRenderer.test.tsx | 79 ++++- .../messages/ShellToolMessage.test.tsx | 120 ++++--- .../messages/SubagentGroupDisplay.test.tsx | 54 +-- .../components/messages/ToolMessage.test.tsx | 17 +- .../messages/ToolMessageRawMarkdown.test.tsx | 10 +- .../ToolOverflowConsistencyChecks.test.tsx | 13 +- .../messages/ToolResultDisplay.test.tsx | 84 ++++- .../ToolResultDisplayOverflow.test.tsx | 18 +- .../shared/BaseSettingsDialog.test.tsx | 63 ++-- .../components/shared/ScrollableList.test.tsx | 333 ++++++++---------- .../components/shared/SearchableList.test.tsx | 15 +- .../views/ExtensionDetails.test.tsx | 19 +- .../views/ExtensionRegistryView.test.tsx | 45 +-- .../src/ui/contexts/KeypressContext.test.tsx | 119 +++---- .../cli/src/ui/contexts/KeypressContext.tsx | 19 +- .../cli/src/ui/contexts/MouseContext.test.tsx | 41 ++- packages/cli/src/ui/contexts/MouseContext.tsx | 14 +- packages/cli/src/ui/hooks/useFocus.test.tsx | 9 +- .../cli/src/ui/hooks/useKeypress.test.tsx | 16 +- packages/cli/src/ui/hooks/useMouse.test.ts | 20 +- .../cli/src/ui/utils/borderStyles.test.tsx | 13 +- 40 files changed, 923 insertions(+), 863 deletions(-) diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index a27cdbbb78..a6337ef29c 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -101,18 +101,8 @@ export async function startInteractiveUI( return ( - - + + diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 6043c7f8cc..39a896a3f8 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -204,6 +204,7 @@ export class AppRig { enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, + useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); @@ -275,6 +276,9 @@ export class AppRig { enabled: false, hasSeenNudge: true, }, + ui: { + useAlternateBuffer: false, + }, }, }); } @@ -410,7 +414,6 @@ export class AppRig { config: this.config!, settings: this.settings!, width: this.options.terminalWidth ?? 120, - useAlternateBuffer: false, uiState: { terminalHeight: this.options.terminalHeight ?? 40, }, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 47e56e1a44..b153aaf85e 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -37,14 +37,14 @@ export const createMockCommandContext = ( }, services: { config: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + logger: { log: vi.fn(), logMessage: vi.fn(), @@ -53,7 +53,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + ui: { addItem: vi.fn(), clear: vi.fn(), @@ -72,7 +72,7 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, @@ -93,14 +93,12 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const targetValue = output[key]; if ( @@ -108,11 +106,10 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + output[key] = sourceValue; } } @@ -120,6 +117,5 @@ export const createMockCommandContext = ( return output; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 74bac044c4..ede4fd6a5c 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -18,7 +18,7 @@ import type React from 'react'; import { act, useState } from 'react'; import os from 'node:os'; import path from 'node:path'; -import { LoadedSettings } from '../config/settings.js'; +import type { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; @@ -416,11 +416,10 @@ export const render = ( stdout.clear(); act(() => { instance = inkRenderDirect(tree, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stdout: stdout as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, debug: false, exitOnCtrlC: false, @@ -499,7 +498,6 @@ const getMockConfigInternal = (): Config => { return mockConfigInternal; }; -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const configProxy = new Proxy({} as Config, { get(_target, prop) { if (prop === 'getTargetDir') { @@ -526,21 +524,13 @@ const configProxy = new Proxy({} as Config, { } const internal = getMockConfigInternal(); if (prop in internal) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return internal[prop as keyof typeof internal]; } throw new Error(`mockConfig does not have property ${String(prop)}`); }, }); -export const mockSettings = new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - true, - [], -); +export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. @@ -657,9 +647,8 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + config = configProxy as unknown as Config, - useAlternateBuffer = true, uiActions, persistentState, appState = mockAppState, @@ -670,7 +659,6 @@ export const renderWithProviders = ( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; @@ -685,20 +673,17 @@ export const renderWithProviders = ( button?: 0 | 1 | 2, ) => Promise; } => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); @@ -716,31 +701,8 @@ export const renderWithProviders = ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; - let finalSettings = settings; - if (useAlternateBuffer !== undefined) { - finalSettings = createMockSettings({ - ...settings.merged, - ui: { - ...settings.merged.ui, - useAlternateBuffer, - }, - }); - } - - // Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value, - // without replacing the entire config object and its other values. - let finalConfig = config; - if (useAlternateBuffer !== undefined) { - finalConfig = new Proxy(config, { - get(target, prop, receiver) { - if (prop === 'getUseAlternateBuffer') { - return () => useAlternateBuffer; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver); - }, - }); - } + const finalSettings = settings; + const finalConfig = config; const mainAreaWidth = terminalWidth; @@ -768,7 +730,7 @@ export const renderWithProviders = ( capturedOverflowState = undefined; capturedOverflowActions = undefined; - const renderResult = render( + const wrapWithProviders = (comp: React.ReactElement) => ( @@ -803,7 +765,7 @@ export const renderWithProviders = ( flexGrow={0} flexDirection="column" > - {component} + {comp} @@ -821,12 +783,16 @@ export const renderWithProviders = ( - , - terminalWidth, + ); + const renderResult = render(wrapWithProviders(component), terminalWidth); + return { ...renderResult, + rerender: (newComponent: React.ReactElement) => { + renderResult.rerender(wrapWithProviders(newComponent)); + }, capturedOverflowState, capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => @@ -847,9 +813,8 @@ export function renderHook( waitUntilReady: () => Promise; generateSvg: () => string; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + let currentProps = options?.initialProps as Props; function TestComponent({ @@ -884,7 +849,6 @@ export function renderHook( function rerender(props?: Props) { if (arguments.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion currentProps = props as Props; } act(() => { @@ -911,7 +875,6 @@ export function renderHookWithProviders( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; } = {}, ): { result: { current: Result }; @@ -920,7 +883,6 @@ export function renderHookWithProviders( waitUntilReady: () => Promise; generateSvg: () => string; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -942,7 +904,7 @@ export function renderHookWithProviders( act(() => { renderResult = renderWithProviders( - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} + {} , options, @@ -952,7 +914,6 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index dd498b6625..ab2420849d 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,23 +46,22 @@ export const createMockSettings = ( workspace, isTrusted, errors, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + merged: mergedOverride, ...settingsOverrides } = overrides; const loaded = new LoadedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (system as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], @@ -76,7 +75,6 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..969e8b23aa 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; import { type UIState } from './contexts/UIStateContext.js'; @@ -97,7 +98,10 @@ describe('App', () => { , { uiState: mockUIState, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); await waitUntilReady(); @@ -118,7 +122,10 @@ describe('App', () => { , { uiState: quittingUIState, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); await waitUntilReady(); @@ -139,7 +146,10 @@ describe('App', () => { , { uiState: quittingUIState, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -159,6 +169,10 @@ describe('App', () => { , { uiState: dialogUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -185,6 +199,10 @@ describe('App', () => { , { uiState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -201,6 +219,10 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -219,6 +241,10 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -265,7 +291,7 @@ describe('App', () => { ], } as UIState; - const configWithExperiment = makeFakeConfig(); + const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); @@ -274,6 +300,9 @@ describe('App', () => { { uiState: stateWithConfirmingTool, config: configWithExperiment, + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -293,6 +322,10 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -306,6 +339,10 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); @@ -322,6 +359,10 @@ describe('App', () => { , { uiState: dialogUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 13550d3f42..26ee1a87c1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -95,7 +95,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { mergeSettings, type LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings } from '../config/settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; @@ -484,23 +485,20 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - mockSettings = { + mockSettings = createMockSettings({ merged: { - ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', ui: { - ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, useAlternateBuffer: false, }, }, - } as unknown as LoadedSettings; + }); // Mock InitializationResult mockInitResult = { @@ -1008,16 +1006,14 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsAllHidden = { + const settingsAllHidden = createMockSettings({ merged: { - ...defaultMergedSettings, hideBanner: true, hideFooter: true, hideTips: true, showMemoryUsage: false, }, - } as unknown as LoadedSettings; + }); let unmount: () => void; await act(async () => { @@ -1029,16 +1025,11 @@ describe('AppContainer State Management', () => { }); it('handles settings with memory usage enabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithMemory = { + const settingsWithMemory = createMockSettings({ merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, showMemoryUsage: true, }, - } as unknown as LoadedSettings; + }); let unmount: () => void; await act(async () => { @@ -1078,9 +1069,7 @@ describe('AppContainer State Management', () => { }); it('handles undefined settings gracefully', async () => { - const undefinedSettings = { - merged: mergeSettings({}, {}, {}, {}, true), - } as LoadedSettings; + const undefinedSettings = createMockSettings(); let unmount: () => void; await act(async () => { @@ -1498,18 +1487,14 @@ describe('AppContainer State Management', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithShowStatusFalse = { - ...mockSettings, + const mockSettingsWithShowStatusFalse = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ @@ -1537,17 +1522,14 @@ describe('AppContainer State Management', () => { it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled - const mockSettingsWithDynamicTitleFalse = { - ...mockSettings, + const mockSettingsWithDynamicTitleFalse = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, dynamicWindowTitle: false, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ @@ -1575,18 +1557,14 @@ describe('AppContainer State Management', () => { it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithHideTitleTrue = { - ...mockSettings, + const mockSettingsWithHideTitleTrue = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: true, }, }, - } as unknown as LoadedSettings; + }); // Act: Render the container const { unmount } = renderAppContainer({ @@ -1604,18 +1582,14 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Processing request'; @@ -1644,18 +1618,14 @@ describe('AppContainer State Management', () => { it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); @@ -1679,18 +1649,14 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; @@ -1742,17 +1708,14 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ @@ -1801,17 +1764,14 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ @@ -1871,17 +1831,14 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ @@ -1921,17 +1878,14 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; @@ -2005,18 +1959,14 @@ describe('AppContainer State Management', () => { it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; @@ -2046,18 +1996,14 @@ describe('AppContainer State Management', () => { it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, + const mockSettingsWithTitleEnabled = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const title = 'Test Title'; @@ -2085,17 +2031,14 @@ describe('AppContainer State Management', () => { it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) - const mockSettingsWithTitleDisabled = { - ...mockSettings, + const mockSettingsWithTitleDisabled = createMockSettings({ merged: { - ...mockSettings.merged, ui: { - ...mockSettings.merged.ui, showStatusInTitle: false, hideWindowTitle: false, }, }, - } as unknown as LoadedSettings; + }); // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); @@ -2664,17 +2607,13 @@ describe('AppContainer State Management', () => { ); // Update settings for this test run - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const testSettings = { - ...mockSettings, + const testSettings = createMockSettings({ merged: { - ...defaultMergedSettings, ui: { - ...defaultMergedSettings.ui, useAlternateBuffer: isAlternateMode, }, }, - } as unknown as LoadedSettings; + }); function TestChild() { useKeypress(childHandler || (() => {}), { @@ -3384,13 +3323,11 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, + settings: createMockSettings({ merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, + ui: { useAlternateBuffer: false }, }, - } as LoadedSettings, + }), }).unmount; }); @@ -3426,13 +3363,11 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, + settings: createMockSettings({ merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, + ui: { useAlternateBuffer: true }, }, - } as LoadedSettings, + }), }).unmount; }); @@ -3701,16 +3636,13 @@ describe('AppContainer State Management', () => { }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { - const alternateSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithAlternateBuffer = { + const settingsWithAlternateBuffer = createMockSettings({ merged: { - ...alternateSettings, ui: { - ...alternateSettings.ui, useAlternateBuffer: true, }, }, - } as unknown as LoadedSettings; + }); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b0a936a81b..b2402f9fe9 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1677,11 +1677,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleGlobalKeypress = useCallback( (key: Key): boolean => { - // Debug log keystrokes if enabled - if (settings.merged.general.debugKeystrokeLogging) { - debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1860,7 +1855,6 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, handleSuspend, embeddedShellFocused, - settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 52d00550ea..1b30e0e0b2 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -5,10 +5,9 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render } from '../test-utils/render.js'; +import { renderWithProviders } from '../test-utils/render.js'; import { act } from 'react'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { KeypressProvider } from './contexts/KeypressContext.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock debugLogger @@ -54,10 +53,8 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - - - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); const frame = lastFrame(); @@ -71,10 +68,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -94,10 +89,8 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -122,10 +115,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -155,10 +146,8 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -184,10 +173,8 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = render( - - - , + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 52cda094e0..2e5b6ecdb2 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -4,21 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { AgentConfigDialog } from './AgentConfigDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import type { AgentDefinition } from '@google/gemini-cli-core'; -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -122,17 +115,16 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = render( - - - , + const result = renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await result.waitUntilReady(); return result; @@ -331,18 +323,17 @@ describe('AgentConfigDialog', () => { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) - const { lastFrame, unmount } = render( - - - , + const { lastFrame, unmount } = renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await waitFor(() => expect(lastFrame()).toContain('Configure: Test Agent'), diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 0469bec373..2f4f711e75 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -7,6 +7,8 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; @@ -313,7 +315,12 @@ describe('AskUserDialog', () => { width={80} availableHeight={10} // Small height to force scrolling />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(async () => { @@ -1291,7 +1298,12 @@ describe('AskUserDialog', () => { width={80} /> , - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), + }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) @@ -1327,7 +1339,12 @@ describe('AskUserDialog', () => { width={40} // Small width to force wrapping /> , - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), + }, ); // Should NOT contain the truncation message diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 6ebe22d982..d3b285c3a4 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { waitFor } from '../../test-utils/async.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -52,8 +51,8 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = (ui: React.ReactNode) => - render({ui}); + const renderWithProvider = (ui: React.ReactElement) => + renderWithProviders(ui); it('renders correctly', async () => { const { lastFrame, waitUntilReady } = renderWithProvider( diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 33daca1e33..272ccbdc27 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -138,8 +139,9 @@ Implement a comprehensive authentication system with multiple providers. vi.restoreAllMocks(); }); - const renderDialog = (options?: { useAlternateBuffer?: boolean }) => - renderWithProviders( + const renderDialog = (options?: { useAlternateBuffer?: boolean }) => { + const useAlternateBuffer = options?.useAlternateBuffer ?? true; + return renderWithProviders( options?.useAlternateBuffer ?? true, + getUseAlternateBuffer: () => useAlternateBuffer, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), }, ); + }; describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])( 'useAlternateBuffer: $useAlternateBuffer', @@ -429,7 +435,6 @@ Implement a comprehensive authentication system with multiple providers. /> , { - useAlternateBuffer, config: { getTargetDir: () => mockTargetDir, getIdeMode: () => false, @@ -443,6 +448,11 @@ Implement a comprehensive authentication system with multiple providers. }), getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + merged: { + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, + }, + }), }, ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index e68417fc55..0ff0e9b0df 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,11 +5,12 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; -import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; vi.mock('../../utils/processUtils.js', () => ({ @@ -78,7 +79,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); @@ -108,7 +112,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); @@ -139,7 +146,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); @@ -168,7 +178,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -194,7 +207,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -434,7 +450,10 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index f049ffe15e..d258a8089d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -16,6 +16,7 @@ import { import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; // Mock child components vi.mock('./messages/ToolGroupMessage.js', () => ({ @@ -84,7 +85,12 @@ describe('', () => { }; const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -352,7 +358,12 @@ describe('', () => { terminalWidth={80} availableTerminalHeight={10} />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitUntilReady(); @@ -374,7 +385,12 @@ describe('', () => { availableTerminalHeight={10} availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER} />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitUntilReady(); @@ -395,7 +411,12 @@ describe('', () => { terminalWidth={80} availableTerminalHeight={10} />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitUntilReady(); @@ -417,7 +438,12 @@ describe('', () => { availableTerminalHeight={10} availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER} />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index c092e600b9..003f24c66b 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -6,6 +6,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { act, useState } from 'react'; import { @@ -3512,7 +3513,10 @@ describe('InputPrompt', () => { , { mouseEventsEnabled: true, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiActions, }, ); @@ -3603,7 +3607,10 @@ describe('InputPrompt', () => { , { mouseEventsEnabled: true, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiActions, }, ); diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index e0880e624c..23218647f9 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -5,6 +5,8 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { MainContent } from './MainContent.js'; import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; @@ -18,7 +20,6 @@ import { useUIState, type UIState, } from '../contexts/UIStateContext.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay } from '../types.js'; // Mock dependencies @@ -482,7 +483,10 @@ describe('MainContent', () => { , { uiState: uiState as Partial, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); @@ -509,7 +513,10 @@ describe('MainContent', () => { , { uiState: uiState as unknown as Partial, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); @@ -733,7 +740,10 @@ describe('MainContent', () => { , { uiState: uiState as Partial, - useAlternateBuffer: isAlternateBuffer, + config: makeFakeConfig({ useAlternateBuffer: isAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: isAlternateBuffer } }, + }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 4a2fd6a854..bc9249877c 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -20,16 +20,14 @@ * */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { SettingScope } from '../../config/settings.js'; import { createMockSettings } from '../../test-utils/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { TEST_ONLY } from '../../utils/settingsUtils.js'; -import { SettingsContext } from '../contexts/SettingsContext.js'; import { getSettingsSchema, type SettingDefinition, @@ -37,12 +35,6 @@ import { } from '../../config/settingsSchema.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - terminalWidth: 100, // Fixed width for consistent snapshots - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -96,7 +88,25 @@ const ENUM_SETTING: SettingDefinition = { showInDialog: true, }; +// Minimal general schema for KeypressProvider +const MINIMAL_GENERAL_SCHEMA = { + general: { + showInDialog: false, + properties: { + debugKeystrokeLogging: { + type: 'boolean', + label: 'Debug Keystroke Logging', + category: 'General', + requiresRestart: false, + default: false, + showInDialog: false, + }, + }, + }, +}; + const ENUM_FAKE_SCHEMA: SettingsSchemaType = { + ...MINIMAL_GENERAL_SCHEMA, ui: { showInDialog: false, properties: { @@ -108,6 +118,7 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = { } as unknown as SettingsSchemaType; const ARRAY_FAKE_SCHEMA: SettingsSchemaType = { + ...MINIMAL_GENERAL_SCHEMA, context: { type: 'object', label: 'Context', @@ -164,6 +175,7 @@ const ARRAY_FAKE_SCHEMA: SettingsSchemaType = { } as unknown as SettingsSchemaType; const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = { + ...MINIMAL_GENERAL_SCHEMA, tools: { type: 'object', label: 'Tools', @@ -224,16 +236,16 @@ const renderDialog = ( availableTerminalHeight?: number; }, ) => - render( - - - - - , + renderWithProviders( + , + { + settings, + uiState: { terminalBackgroundColor: undefined }, + }, ); describe('SettingsDialog', () => { @@ -1344,17 +1356,14 @@ describe('SettingsDialog', () => { describe('String Settings Editing', () => { it('should allow editing and committing a string setting', async () => { - let settings = createMockSettings({ + const settings = createMockSettings({ 'general.sessionCleanup.maxAge': 'initial', }); const onSelect = vi.fn(); - const { stdin, unmount, rerender, waitUntilReady } = render( - - - - - , + const { stdin, unmount, waitUntilReady } = renderWithProviders( + , + { settings }, ); await waitUntilReady(); @@ -1384,20 +1393,15 @@ describe('SettingsDialog', () => { }); await waitUntilReady(); - settings = createMockSettings({ - user: { - settings: { 'general.sessionCleanup.maxAge': 'new value' }, - originalSettings: { 'general.sessionCleanup.maxAge': 'new value' }, - path: '', - }, + // Simulate the settings file being updated on disk + await act(async () => { + settings.setValue( + SettingScope.User, + 'general.sessionCleanup.maxAge', + 'new value', + ); }); - rerender( - - - - - , - ); + await waitUntilReady(); // Press Escape to exit await act(async () => { diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 77d072b02e..05ec5d5591 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -9,6 +9,7 @@ import { Box } from 'ink'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { StreamingState } from '../types.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js'; @@ -162,8 +163,13 @@ describe('ToolConfirmationQueue', () => { /> , { - config: mockConfig, - useAlternateBuffer: true, + config: { + ...mockConfig, + getUseAlternateBuffer: () => true, + } as unknown as Config, + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { terminalWidth: 80, terminalHeight: 20, @@ -212,7 +218,9 @@ describe('ToolConfirmationQueue', () => { />, { config: mockConfig, - useAlternateBuffer: false, + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { terminalWidth: 80, terminalHeight: 40, diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 9063606146..5e88151715 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -6,6 +6,8 @@ import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../../test-utils/async.js'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; @@ -42,7 +44,12 @@ index 0000000..e69de29 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(mockColorizeCode).toHaveBeenCalledWith({ @@ -74,7 +81,12 @@ index 0000000..e69de29 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(mockColorizeCode).toHaveBeenCalledWith({ @@ -102,7 +114,12 @@ index 0000000..e69de29 , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(mockColorizeCode).toHaveBeenCalledWith({ @@ -135,7 +152,12 @@ index 0000001..0000002 100644 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); // colorizeCode is used internally by the line-by-line rendering, not for the whole block await waitFor(() => expect(lastFrame()).toContain('new line')); @@ -166,7 +188,12 @@ index 1234567..1234567 100644 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toBeDefined()); expect(lastFrame()).toMatchSnapshot(); @@ -178,7 +205,12 @@ index 1234567..1234567 100644 , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toBeDefined()); expect(lastFrame()).toMatchSnapshot(); @@ -208,7 +240,12 @@ index 123..456 100644 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toContain('added line')); expect(lastFrame()).toMatchSnapshot(); @@ -242,7 +279,12 @@ index abc..def 100644 terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toContain('context line 15')); expect(lastFrame()).toMatchSnapshot(); @@ -292,7 +334,12 @@ index 123..789 100644 availableTerminalHeight={height} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toContain('anotherNew')); const output = lastFrame(); @@ -326,7 +373,12 @@ fileDiff Index: file.txt terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toContain('newVar')); expect(lastFrame()).toMatchSnapshot(); @@ -353,7 +405,12 @@ fileDiff Index: Dockerfile terminalWidth={80} /> , - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), + }, ); await waitFor(() => expect(lastFrame()).toContain('RUN npm run build')); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index b650ee4d9d..39fd44bcdf 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -16,6 +16,8 @@ import { CoreToolCallStatus, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; @@ -48,14 +50,6 @@ describe('', () => { setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }; - const renderShell = ( - props: Partial = {}, - options: Parameters[1] = {}, - ) => - renderWithProviders(, { - uiActions, - ...options, - }); beforeEach(() => { vi.clearAllMocks(); }); @@ -65,9 +59,9 @@ describe('', () => { ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME], ['SHELL_TOOL_NAME', SHELL_TOOL_NAME], ])('clicks inside the shell area sets focus for %s', async (_, name) => { - const { lastFrame, simulateClick, unmount } = renderShell( - { name }, - { mouseEventsEnabled: true }, + const { lastFrame, simulateClick, unmount } = renderWithProviders( + , + { uiActions, mouseEventsEnabled: true }, ); await waitFor(() => { @@ -152,7 +146,10 @@ describe('', () => { ptyId: 1, }, { - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { embeddedShellFocused: true, activePtyId: 1, @@ -166,7 +163,10 @@ describe('', () => { ptyId: 1, }, { - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { embeddedShellFocused: false, activePtyId: 1, @@ -174,9 +174,9 @@ describe('', () => { }, ], ])('%s', async (_, props, options) => { - const { lastFrame, waitUntilReady, unmount } = renderShell( - props, - options, + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { uiActions, ...options }, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -223,16 +223,21 @@ describe('', () => { focused, constrainHeight, ) => { - const { lastFrame, waitUntilReady, unmount } = renderShell( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - resultDisplay: LONG_OUTPUT, - renderOutputAsMarkdown: false, - availableTerminalHeight, - ptyId: 1, - status: CoreToolCallStatus.Executing, - }, - { - useAlternateBuffer: true, + uiActions, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { activePtyId: focused ? 1 : 2, embeddedShellFocused: focused, @@ -250,14 +255,21 @@ describe('', () => { ); it('fully expands in standard mode when availableTerminalHeight is undefined', async () => { - const { lastFrame, unmount } = renderShell( + const { lastFrame, unmount } = renderWithProviders( + , { - resultDisplay: LONG_OUTPUT, - renderOutputAsMarkdown: false, - availableTerminalHeight: undefined, - status: CoreToolCallStatus.Executing, + uiActions, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, - { useAlternateBuffer: false }, ); await waitFor(() => { @@ -269,16 +281,21 @@ describe('', () => { }); it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => { - const { lastFrame, waitUntilReady, unmount } = renderShell( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - resultDisplay: LONG_OUTPUT, - renderOutputAsMarkdown: false, - availableTerminalHeight: undefined, - status: CoreToolCallStatus.Success, - isExpandable: true, - }, - { - useAlternateBuffer: true, + uiActions, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { constrainHeight: false, }, @@ -296,16 +313,21 @@ describe('', () => { }); it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => { - const { lastFrame, waitUntilReady, unmount } = renderShell( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - resultDisplay: LONG_OUTPUT, - renderOutputAsMarkdown: false, - availableTerminalHeight: undefined, - status: CoreToolCallStatus.Success, - isExpandable: false, - }, - { - useAlternateBuffer: true, + uiActions, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), uiState: { constrainHeight: false, }, diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx index 197b78e356..5af99541b5 100644 --- a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx @@ -4,12 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ import { waitFor } from '../../../test-utils/async.js'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core'; import type { IndividualToolCallDisplay } from '../../types.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { vi } from 'vitest'; import { Text } from 'ink'; @@ -69,36 +67,32 @@ describe('', () => { const renderSubagentGroup = ( toolCallsToRender: IndividualToolCallDisplay[], height?: number, - ) => ( - - - - - - ); + ) => + renderWithProviders( + , + ); it('renders nothing if there are no agent tool calls', async () => { - const { lastFrame } = render(renderSubagentGroup([], 40)); + const { lastFrame } = renderSubagentGroup([], 40); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders collapsed view by default with correct agent counts and states', async () => { - const { lastFrame, waitUntilReady } = render( - renderSubagentGroup(mockToolCalls, 40), + const { lastFrame, waitUntilReady } = renderSubagentGroup( + mockToolCalls, + 40, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('expands when availableTerminalHeight is undefined', async () => { - const { lastFrame, rerender } = render( - renderSubagentGroup(mockToolCalls, 40), - ); + const { lastFrame, rerender } = renderSubagentGroup(mockToolCalls, 40); // Default collapsed view await waitFor(() => { @@ -106,13 +100,27 @@ describe('', () => { }); // Expand view - rerender(renderSubagentGroup(mockToolCalls, undefined)); + rerender( + , + ); await waitFor(() => { expect(lastFrame()).toContain('(ctrl+o to collapse)'); }); // Collapse view - rerender(renderSubagentGroup(mockToolCalls, 40)); + rerender( + , + ); await waitFor(() => { expect(lastFrame()).toContain('(ctrl+o to expand)'); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index e3869b6e1b..c6142b2bf8 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -13,8 +13,10 @@ import { type AnsiOutput, CoreToolCallStatus, Kind, + makeFakeConfig, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; vi.mock('../GeminiRespondingSpinner.js', () => ({ @@ -462,7 +464,10 @@ describe('', () => { constrainHeight: true, }, width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); await waitUntilReady(); @@ -495,7 +500,10 @@ describe('', () => { uiActions, uiState: { streamingState: StreamingState.Idle }, width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); await waitUntilReady(); @@ -523,7 +531,10 @@ describe('', () => { uiActions, uiState: { streamingState: StreamingState.Idle }, width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx index 2375be7f0e..1300710ebe 100644 --- a/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessageRawMarkdown.test.tsx @@ -5,11 +5,12 @@ */ import { describe, it, expect } from 'vitest'; -import { ToolMessage, type ToolMessageProps } from './ToolMessage.js'; +import { type ToolMessageProps, ToolMessage } from './ToolMessage.js'; import { StreamingState } from '../../types.js'; import { StreamingContext } from '../../contexts/StreamingContext.js'; import { renderWithProviders } from '../../../test-utils/render.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core'; describe(' - Raw Markdown Display Snapshots', () => { const baseProps: ToolMessageProps = { @@ -72,7 +73,10 @@ describe(' - Raw Markdown Display Snapshots', () => { , { uiState: { renderMarkdown, streamingState: StreamingState.Idle }, - useAlternateBuffer, + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer } }, + }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx index 20b8d13459..8b2da8b95e 100644 --- a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx @@ -7,9 +7,10 @@ import { describe, it, expect } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; import { waitFor } from '../../../test-utils/async.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core'; import { useOverflowState } from '../../contexts/OverflowContext.js'; describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => { @@ -56,7 +57,10 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay streamingState: StreamingState.Idle, constrainHeight: true, }, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), }, ); @@ -106,7 +110,10 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay streamingState: StreamingState.Idle, constrainHeight: true, }, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), }, ); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 02f466e72f..538a647744 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -5,9 +5,10 @@ */ import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; import { describe, it, expect, vi } from 'vitest'; -import type { AnsiOutput } from '@google/gemini-cli-core'; +import { makeFakeConfig, type AnsiOutput } from '@google/gemini-cli-core'; describe('ToolResultDisplay', () => { beforeEach(() => { @@ -36,7 +37,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} maxLines={10} />, - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -52,7 +58,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} maxLines={10} />, - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -69,7 +80,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} hasFocus={true} />, - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), + }, ); await waitUntilReady(); @@ -80,7 +96,12 @@ describe('ToolResultDisplay', () => { it('renders string result as markdown by default', async () => { const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -98,7 +119,10 @@ describe('ToolResultDisplay', () => { renderOutputAsMarkdown={false} />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -118,7 +142,10 @@ describe('ToolResultDisplay', () => { availableTerminalHeight={20} />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -140,7 +167,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -170,7 +202,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -189,7 +226,12 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), + }, ); await waitUntilReady(); const output = lastFrame({ allowEmpty: true }); @@ -208,7 +250,10 @@ describe('ToolResultDisplay', () => { renderOutputAsMarkdown={true} />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -226,7 +271,12 @@ describe('ToolResultDisplay', () => { availableTerminalHeight={20} renderOutputAsMarkdown={true} />, - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -306,7 +356,10 @@ describe('ToolResultDisplay', () => { maxLines={3} />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -342,7 +395,10 @@ describe('ToolResultDisplay', () => { availableTerminalHeight={undefined} />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index b809e89748..3ee86cc06e 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -5,9 +5,10 @@ */ import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; import { describe, it, expect } from 'vitest'; -import { type AnsiOutput } from '@google/gemini-cli-core'; +import { makeFakeConfig, type AnsiOutput } from '@google/gemini-cli-core'; describe('ToolResultDisplay Overflow', () => { it('shows the head of the content when overflowDirection is bottom (string)', async () => { @@ -20,7 +21,10 @@ describe('ToolResultDisplay Overflow', () => { overflowDirection="bottom" />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -46,7 +50,10 @@ describe('ToolResultDisplay Overflow', () => { overflowDirection="top" />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); @@ -83,7 +90,10 @@ describe('ToolResultDisplay Overflow', () => { overflowDirection="bottom" />, { - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: false } }, + }), uiState: { constrainHeight: true }, }, ); diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx index 1ac701eff1..ebabe87133 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; @@ -14,15 +14,8 @@ import { type BaseSettingsDialogProps, type SettingsDialogItem, } from './BaseSettingsDialog.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { SettingScope } from '../../../config/settings.js'; -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -115,10 +108,8 @@ describe('BaseSettingsDialog', () => { ...props, }; - const result = render( - - - , + const result = renderWithProviders( + , ); await result.waitUntilReady(); return result; @@ -331,22 +322,18 @@ describe('BaseSettingsDialog', () => { const filteredItems = [items[0], items[2], items[4]]; await act(async () => { rerender( - - - , + , ); }); - await waitUntilReady(); - // Verify the dialog hasn't crashed and the items are displayed await waitFor(() => { const frame = lastFrame(); @@ -391,22 +378,18 @@ describe('BaseSettingsDialog', () => { const filteredItems = [items[0], items[1]]; await act(async () => { rerender( - - - , + , ); }); - await waitUntilReady(); - await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Boolean Setting'); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index 1dd72b89a2..2a1182a5f3 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -5,21 +5,12 @@ */ import { useState, useEffect, useRef, act } from 'react'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { Box, Text } from 'ink'; import { ScrollableList, type ScrollableListRef } from './ScrollableList.js'; -import { ScrollProvider } from '../../contexts/ScrollProvider.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { MouseProvider } from '../../contexts/MouseContext.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { waitFor } from '../../../test-utils/async.js'; -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: vi.fn(() => ({ - copyModeEnabled: false, - })), -})); - // Mock useStdout to provide a fixed size for testing vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); @@ -85,51 +76,45 @@ const TestComponent = ({ }, [onRef]); return ( - - - - - - ( - + + + ( + + + {item.title} - {item.title} - - - } - > - {item.title} - - {getLorem(index)} + borderStyle="single" + borderTop={true} + borderBottom={false} + borderLeft={false} + borderRight={false} + borderColor="gray" + /> - )} - estimatedItemHeight={() => 14} - keyExtractor={(item) => item.id} - hasFocus={true} - initialScrollIndex={Number.MAX_SAFE_INTEGER} - /> + } + > + {item.title} + + {getLorem(index)} - Count: {items.length} - - - - + )} + estimatedItemHeight={() => 14} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + + Count: {items.length} + ); }; describe('ScrollableList Demo Behavior', () => { @@ -147,10 +132,10 @@ describe('ScrollableList Demo Behavior', () => { let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined; let waitUntilReady: () => Promise; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render( + result = renderWithProviders( { addItem = add; @@ -230,45 +215,39 @@ describe('ScrollableList Demo Behavior', () => { }, []); return ( - - - - - ( - - {index === 0 ? ( - [STICKY] {item.title}} - > - [Normal] {item.title} - - ) : ( - [Normal] {item.title} - )} - Content for {item.title} - More content for {item.title} - - )} - estimatedItemHeight={() => 3} - keyExtractor={(item) => item.id} - hasFocus={true} - /> + + ( + + {index === 0 ? ( + [STICKY] {item.title}} + > + [Normal] {item.title} + + ) : ( + [Normal] {item.title} + )} + Content for {item.title} + More content for {item.title} - - - + )} + estimatedItemHeight={() => 3} + keyExtractor={(item) => item.id} + hasFocus={true} + /> + ); }; let lastFrame: () => string | undefined; let waitUntilReady: () => Promise; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render(); + result = renderWithProviders(); lastFrame = result.lastFrame; waitUntilReady = result.waitUntilReady; }); @@ -334,27 +313,21 @@ describe('ScrollableList Demo Behavior', () => { title: `Item ${i}`, })); - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render( - - - - - { - listRef = ref; - }} - data={items} - renderItem={({ item }) => {item.title}} - estimatedItemHeight={() => 1} - keyExtractor={(item) => item.id} - hasFocus={true} - /> - - - - , + result = renderWithProviders( + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => {item.title}} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + /> + , ); lastFrame = result.lastFrame; stdin = result.stdin; @@ -444,25 +417,19 @@ describe('ScrollableList Demo Behavior', () => { let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined; let waitUntilReady: () => Promise; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render( - - - - - {item.title}} - estimatedItemHeight={() => 1} - keyExtractor={(item) => item.id} - hasFocus={true} - width={50} - /> - - - - , + result = renderWithProviders( + + {item.title}} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + width={50} + /> + , ); lastFrame = result.lastFrame; waitUntilReady = result.waitUntilReady; @@ -497,31 +464,25 @@ describe('ScrollableList Demo Behavior', () => { }, []); return ( - - - - - { - listRef = ref; - }} - data={items} - renderItem={({ item }) => {item.title}} - estimatedItemHeight={() => 1} - keyExtractor={(item) => item.id} - hasFocus={true} - initialScrollIndex={Number.MAX_SAFE_INTEGER} - /> - - - - + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => {item.title}} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + ); }; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render(); + result = renderWithProviders(); }); await result!.waitUntilReady(); @@ -622,33 +583,27 @@ describe('ScrollableList Demo Behavior', () => { ); return ( - - - - - { - listRef = ref; - }} - data={items} - renderItem={({ item, index }) => ( - - )} - estimatedItemHeight={() => 1} - keyExtractor={(item) => item.id} - hasFocus={true} - initialScrollIndex={Number.MAX_SAFE_INTEGER} - /> - - - - + + { + listRef = ref; + }} + data={items} + renderItem={({ item, index }) => ( + + )} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + ); }; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render(); + result = renderWithProviders(); }); await result!.waitUntilReady(); @@ -696,35 +651,29 @@ describe('ScrollableList Demo Behavior', () => { }, []); return ( - - - - - { - listRef = ref; - }} - data={items} - renderItem={({ item }) => ( - - {item.title} - - )} - estimatedItemHeight={() => 2} - keyExtractor={(item) => item.id} - hasFocus={true} - initialScrollIndex={Number.MAX_SAFE_INTEGER} - /> + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => ( + + {item.title} - - - + )} + estimatedItemHeight={() => 2} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + ); }; - let result: ReturnType; + let result: ReturnType; await act(async () => { - result = render(); + result = renderWithProviders(); }); await result!.waitUntilReady(); diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx index e156c12695..127a5feef8 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { @@ -14,7 +14,6 @@ import { type SearchListState, type GenericListItem, } from './SearchableList.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { useTextBuffer } from './text-buffer.js'; const useMockSearch = (props: { @@ -52,12 +51,6 @@ const useMockSearch = (props: { }; }; -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - const mockItems: GenericListItem[] = [ { key: 'item-1', @@ -98,11 +91,7 @@ describe('SearchableList', () => { ...props, }; - return render( - - - , - ); + return renderWithProviders(); }; it('should render all items initially', async () => { diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx index d7e4fb8ae4..d8df7012cc 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -5,11 +5,10 @@ */ import React from 'react'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExtensionDetails } from './ExtensionDetails.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; const mockExtension: RegistryExtension = { @@ -43,15 +42,13 @@ describe('ExtensionDetails', () => { }); const renderDetails = (isInstalled = false) => - render( - - - , + renderWithProviders( + , ); it('should render extension details correctly', async () => { diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index b13b202b90..55e307ecfe 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExtensionRegistryView } from './ExtensionRegistryView.js'; @@ -14,9 +14,7 @@ import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; -import { useUIState } from '../../contexts/UIStateContext.js'; -import { useConfig } from '../../contexts/ConfigContext.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { type UIState } from '../../contexts/UIStateContext.js'; import { type SearchListState, type GenericListItem, @@ -28,8 +26,6 @@ vi.mock('../../hooks/useExtensionRegistry.js'); vi.mock('../../hooks/useExtensionUpdates.js'); vi.mock('../../hooks/useRegistrySearch.js'); vi.mock('../../../config/extension-manager.js'); -vi.mock('../../contexts/UIStateContext.js'); -vi.mock('../../contexts/ConfigContext.js'); const mockExtensions: RegistryExtension[] = [ { @@ -123,34 +119,27 @@ describe('ExtensionRegistryView', () => { maxLabelWidth: 10, }) as unknown as SearchListState, ); - - vi.mocked(useUIState).mockReturnValue({ - mainAreaWidth: 100, - terminalHeight: 40, - staticExtraHeight: 5, - } as unknown as ReturnType); - - vi.mocked(useConfig).mockReturnValue({ - getEnableExtensionReloading: vi.fn().mockReturnValue(false), - getExtensionRegistryURI: vi - .fn() - .mockReturnValue('https://geminicli.com/extensions.json'), - } as unknown as ReturnType); }); const renderView = () => - render( - - - , + renderWithProviders( + , + { + uiState: { + staticExtraHeight: 5, + terminalHeight: 40, + } as Partial, + }, ); it('should render extensions', async () => { - const { lastFrame } = renderView(); + const { lastFrame, waitUntilReady } = renderView(); + await waitUntilReady(); + await waitFor(() => { expect(lastFrame()).toContain('Test Extension 1'); expect(lastFrame()).toContain('Test Extension 2'); diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 31e43af575..8eb9c7c94f 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -5,13 +5,12 @@ */ import { debugLogger } from '@google/gemini-cli-core'; -import type React from 'react'; import { act } from 'react'; -import { renderHook } from '../../test-utils/render.js'; +import { renderHookWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { vi, afterAll, beforeAll, type Mock } from 'vitest'; import { - KeypressProvider, useKeypressContext, ESC_TIMEOUT, FAST_RETURN_TIMEOUT, @@ -52,11 +51,8 @@ class MockStdin extends EventEmitter { // Helper function to setup keypress test with standard configuration const setupKeypressTest = () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); return { result, keyHandler }; @@ -66,10 +62,6 @@ describe('KeypressContext', () => { let stdin: MockStdin; const mockSetRawMode = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - beforeAll(() => vi.useFakeTimers()); afterAll(() => vi.useRealTimers()); @@ -269,10 +261,7 @@ describe('KeypressContext', () => { it('should handle double Escape', async () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); act(() => { @@ -306,10 +295,7 @@ describe('KeypressContext', () => { it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => { // Use real timers for this test to avoid issues with stream/buffer timing const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); // Send just ESC @@ -432,7 +418,7 @@ describe('KeypressContext', () => { ])('should $name', async ({ pastedText, writeSequence }) => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -452,7 +438,7 @@ describe('KeypressContext', () => { it('should parse valid OSC 52 response', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -473,7 +459,7 @@ describe('KeypressContext', () => { it('should handle split OSC 52 response', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -499,7 +485,7 @@ describe('KeypressContext', () => { it('should handle OSC 52 response terminated by ESC \\', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -520,7 +506,7 @@ describe('KeypressContext', () => { it('should ignore unknown OSC sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -537,7 +523,7 @@ describe('KeypressContext', () => { it('should ignore invalid OSC 52 format', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -569,13 +555,11 @@ describe('KeypressContext', () => { it('should not log keystrokes when debugKeystrokeLogging is false', async () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext(), { + settings: createMockSettings({ + general: { debugKeystrokeLogging: false }, + }), + }); act(() => result.current.subscribe(keyHandler)); @@ -593,13 +577,11 @@ describe('KeypressContext', () => { it('should log kitty buffer accumulation when debugKeystrokeLogging is true', async () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext(), { + settings: createMockSettings({ + general: { debugKeystrokeLogging: true }, + }), + }); act(() => result.current.subscribe(keyHandler)); @@ -614,13 +596,11 @@ describe('KeypressContext', () => { it('should show char codes when debugKeystrokeLogging is true even without debug mode', async () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext(), { + settings: createMockSettings({ + general: { debugKeystrokeLogging: true }, + }), + }); act(() => result.current.subscribe(keyHandler)); @@ -765,7 +745,7 @@ describe('KeypressContext', () => { 'should recognize sequence "$sequence" as $expected.name', ({ sequence, expected }) => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); act(() => stdin.write(sequence)); @@ -1000,12 +980,7 @@ describe('KeypressContext', () => { 'should handle Alt+$key in $terminal', ({ chunk, expected }: { chunk: string; expected: Partial }) => { const keyHandler = vi.fn(); - const testWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { - wrapper: testWrapper, - }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); act(() => stdin.write(chunk)); @@ -1042,7 +1017,7 @@ describe('KeypressContext', () => { it('should timeout and flush incomplete kitty sequences after 50ms', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1077,7 +1052,7 @@ describe('KeypressContext', () => { it('should immediately flush non-kitty CSI sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1099,7 +1074,7 @@ describe('KeypressContext', () => { it('should parse valid kitty sequences immediately when complete', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1117,7 +1092,7 @@ describe('KeypressContext', () => { it('should handle batched kitty sequences correctly', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1144,7 +1119,7 @@ describe('KeypressContext', () => { it('should handle mixed valid and invalid sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1172,7 +1147,7 @@ describe('KeypressContext', () => { 'should handle sequences arriving character by character with %s ms delay', async (delay) => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1196,7 +1171,7 @@ describe('KeypressContext', () => { it('should reset timeout when new input arrives', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1231,7 +1206,7 @@ describe('KeypressContext', () => { describe('SGR Mouse Handling', () => { it('should ignore SGR mouse sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1249,7 +1224,7 @@ describe('KeypressContext', () => { it('should handle mixed SGR mouse and key sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1275,7 +1250,7 @@ describe('KeypressContext', () => { it('should ignore X11 mouse sequences', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1291,7 +1266,7 @@ describe('KeypressContext', () => { it('should not flush slow SGR mouse sequences as garbage', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1311,7 +1286,7 @@ describe('KeypressContext', () => { it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); @@ -1342,12 +1317,7 @@ describe('KeypressContext', () => { { name: 'another mouse', sequence: '\u001b[<0;29;19m' }, ])('should ignore $name sequence', async ({ sequence }) => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { - wrapper, - }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); for (const char of sequence) { @@ -1372,10 +1342,7 @@ describe('KeypressContext', () => { it('should handle F12', async () => { const keyHandler = vi.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); act(() => { @@ -1404,7 +1371,7 @@ describe('KeypressContext', () => { 'A你B好C', // Mixed characters ])('should correctly handle string "%s"', async (inputString) => { const keyHandler = vi.fn(); - const { result } = renderHook(() => useKeypressContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useKeypressContext()); act(() => result.current.subscribe(keyHandler)); act(() => stdin.write(inputString)); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index cdd6da7feb..3189172792 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -13,6 +13,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, } from 'react'; @@ -21,6 +22,7 @@ import { parseMouseEvent } from '../utils/mouse.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; import { appEvents, AppEvent } from '../../utils/events.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; +import { useSettingsStore } from './SettingsContext.js'; export const BACKSLASH_ENTER_TIMEOUT = 5; export const ESC_TIMEOUT = 50; @@ -766,12 +768,13 @@ export function useKeypressContext() { export function KeypressProvider({ children, config, - debugKeystrokeLogging, }: { children: React.ReactNode; config?: Config; - debugKeystrokeLogging?: boolean; }) { + const { settings } = useSettingsStore(); + const debugKeystrokeLogging = settings.merged.general.debugKeystrokeLogging; + const { stdin, setRawMode } = useStdin(); const subscribersToPriority = useRef>( @@ -828,6 +831,9 @@ export function KeypressProvider({ const broadcast = useCallback( (key: Key) => { + if (debugKeystrokeLogging) { + debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); + } // Use cached sorted priorities to avoid sorting on every keypress for (const p of sortedPriorities.current) { const set = subscribers.get(p); @@ -842,7 +848,7 @@ export function KeypressProvider({ } } }, - [subscribers], + [subscribers, debugKeystrokeLogging], ); useEffect(() => { @@ -882,8 +888,13 @@ export function KeypressProvider({ }; }, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]); + const contextValue = useMemo( + () => ({ subscribe, unsubscribe }), + [subscribe, unsubscribe], + ); + return ( - + {children} ); diff --git a/packages/cli/src/ui/contexts/MouseContext.test.tsx b/packages/cli/src/ui/contexts/MouseContext.test.tsx index c6288ab4ef..d35c57c863 100644 --- a/packages/cli/src/ui/contexts/MouseContext.test.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.test.tsx @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderHook } from '../../test-utils/render.js'; -import type React from 'react'; +import { renderHookWithProviders } from '../../test-utils/render.js'; import { act } from 'react'; -import { MouseProvider, useMouseContext, useMouse } from './MouseContext.js'; +import { useMouseContext, useMouse } from './MouseContext.js'; import { vi, type Mock } from 'vitest'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; @@ -49,7 +48,6 @@ class MockStdin extends EventEmitter { describe('MouseContext', () => { let stdin: MockStdin; - let wrapper: React.FC<{ children: React.ReactNode }>; beforeEach(() => { stdin = new MockStdin(); @@ -57,9 +55,6 @@ describe('MouseContext', () => { stdin, setRawMode: vi.fn(), }); - wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); vi.mocked(appEvents.emit).mockClear(); }); @@ -69,7 +64,9 @@ describe('MouseContext', () => { it('should subscribe and unsubscribe a handler', () => { const handler = vi.fn(); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { result.current.subscribe(handler); @@ -94,8 +91,8 @@ describe('MouseContext', () => { it('should not call handler if not active', () => { const handler = vi.fn(); - renderHook(() => useMouse(handler, { isActive: false }), { - wrapper, + renderHookWithProviders(() => useMouse(handler, { isActive: false }), { + mouseEventsEnabled: true, }); act(() => { @@ -106,7 +103,9 @@ describe('MouseContext', () => { }); it('should emit SelectionWarning when move event is unhandled and has coordinates', () => { - renderHook(() => useMouseContext(), { wrapper }); + renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { // Move event (32) at 10, 20 @@ -118,7 +117,9 @@ describe('MouseContext', () => { it('should not emit SelectionWarning when move event is handled', () => { const handler = vi.fn().mockReturnValue(true); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { result.current.subscribe(handler); @@ -218,7 +219,9 @@ describe('MouseContext', () => { 'should recognize sequence "$sequence" as $expected.name', ({ sequence, expected }) => { const mouseHandler = vi.fn(); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => result.current.subscribe(mouseHandler)); act(() => stdin.write(sequence)); @@ -232,7 +235,9 @@ describe('MouseContext', () => { it('should emit a double-click event when two left-presses occur quickly at the same position', () => { const handler = vi.fn(); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { result.current.subscribe(handler); @@ -262,7 +267,9 @@ describe('MouseContext', () => { it('should NOT emit a double-click event if clicks are too far apart', () => { const handler = vi.fn(); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { result.current.subscribe(handler); @@ -287,7 +294,9 @@ describe('MouseContext', () => { it('should NOT emit a double-click event if too much time passes', async () => { vi.useFakeTimers(); const handler = vi.fn(); - const { result } = renderHook(() => useMouseContext(), { wrapper }); + const { result } = renderHookWithProviders(() => useMouseContext(), { + mouseEventsEnabled: true, + }); act(() => { result.current.subscribe(handler); diff --git a/packages/cli/src/ui/contexts/MouseContext.tsx b/packages/cli/src/ui/contexts/MouseContext.tsx index d36867bdbf..15ebd33ff8 100644 --- a/packages/cli/src/ui/contexts/MouseContext.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.tsx @@ -11,6 +11,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, } from 'react'; import { ESC } from '../utils/input.js'; @@ -25,6 +26,7 @@ import { DOUBLE_CLICK_THRESHOLD_MS, DOUBLE_CLICK_DISTANCE_TOLERANCE, } from '../utils/mouse.js'; +import { useSettingsStore } from './SettingsContext.js'; export type { MouseEvent, MouseEventName, MouseHandler }; @@ -61,12 +63,13 @@ export function useMouse(handler: MouseHandler, { isActive = true } = {}) { export function MouseProvider({ children, mouseEventsEnabled, - debugKeystrokeLogging, }: { children: React.ReactNode; mouseEventsEnabled?: boolean; - debugKeystrokeLogging?: boolean; }) { + const { settings } = useSettingsStore(); + const debugKeystrokeLogging = settings.merged.general.debugKeystrokeLogging; + const { stdin } = useStdin(); const subscribers = useRef>(new Set()).current; const lastClickRef = useRef<{ @@ -189,8 +192,13 @@ export function MouseProvider({ }; }, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]); + const contextValue = useMemo( + () => ({ subscribe, unsubscribe }), + [subscribe, unsubscribe], + ); + return ( - + {children} ); diff --git a/packages/cli/src/ui/hooks/useFocus.test.tsx b/packages/cli/src/ui/hooks/useFocus.test.tsx index 86484cc1b9..dacac1aea6 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -4,12 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { EventEmitter } from 'node:events'; import { useFocus } from './useFocus.js'; import { vi, type Mock } from 'vitest'; import { useStdin, useStdout } from 'ink'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; // Mock the ink hooks @@ -54,11 +53,7 @@ describe('useFocus', () => { hookResult = useFocus(); return null; } - const { unmount } = render( - - - , - ); + const { unmount } = renderWithProviders(); return { result: { get current() { diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index 0ebfb76f8b..9a986c2c4c 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -5,9 +5,8 @@ */ import { act } from 'react'; -import { render } from '../../test-utils/render.js'; +import { renderHookWithProviders } from '../../test-utils/render.js'; import { useKeypress } from './useKeypress.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { useStdin } from 'ink'; import { EventEmitter } from 'node:events'; import type { Mock } from 'vitest'; @@ -44,17 +43,8 @@ describe(`useKeypress`, () => { const onKeypress = vi.fn(); let originalNodeVersion: string; - const renderKeypressHook = (isActive = true) => { - function TestComponent() { - useKeypress(onKeypress, { isActive }); - return null; - } - return render( - - - , - ); - }; + const renderKeypressHook = (isActive = true) => + renderHookWithProviders(() => useKeypress(onKeypress, { isActive })); beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/ui/hooks/useMouse.test.ts b/packages/cli/src/ui/hooks/useMouse.test.ts index 2dea0ee16c..28439f6850 100644 --- a/packages/cli/src/ui/hooks/useMouse.test.ts +++ b/packages/cli/src/ui/hooks/useMouse.test.ts @@ -7,7 +7,7 @@ import { vi } from 'vitest'; import { renderHook } from '../../test-utils/render.js'; import { useMouse } from './useMouse.js'; -import { MouseProvider, useMouseContext } from '../contexts/MouseContext.js'; +import { useMouseContext } from '../contexts/MouseContext.js'; vi.mock('../contexts/MouseContext.js', async (importOriginal) => { const actual = @@ -16,10 +16,10 @@ vi.mock('../contexts/MouseContext.js', async (importOriginal) => { const unsubscribe = vi.fn(); return { ...actual, - useMouseContext: () => ({ + useMouseContext: vi.fn(() => ({ subscribe, unsubscribe, - }), + })), }; }); @@ -31,27 +31,22 @@ describe('useMouse', () => { }); it('should not subscribe when isActive is false', () => { - renderHook(() => useMouse(mockOnMouseEvent, { isActive: false }), { - wrapper: MouseProvider, - }); + renderHook(() => useMouse(mockOnMouseEvent, { isActive: false })); const { subscribe } = useMouseContext(); expect(subscribe).not.toHaveBeenCalled(); }); it('should subscribe when isActive is true', () => { - renderHook(() => useMouse(mockOnMouseEvent, { isActive: true }), { - wrapper: MouseProvider, - }); + renderHook(() => useMouse(mockOnMouseEvent, { isActive: true })); const { subscribe } = useMouseContext(); expect(subscribe).toHaveBeenCalledWith(mockOnMouseEvent); }); it('should unsubscribe on unmount', () => { - const { unmount } = renderHook( - () => useMouse(mockOnMouseEvent, { isActive: true }), - { wrapper: MouseProvider }, + const { unmount } = renderHook(() => + useMouse(mockOnMouseEvent, { isActive: true }), ); const { unsubscribe } = useMouseContext(); @@ -65,7 +60,6 @@ describe('useMouse', () => { useMouse(mockOnMouseEvent, { isActive }), { initialProps: { isActive: true }, - wrapper: MouseProvider, }, ); diff --git a/packages/cli/src/ui/utils/borderStyles.test.tsx b/packages/cli/src/ui/utils/borderStyles.test.tsx index 1852a0cb82..fa8cee693b 100644 --- a/packages/cli/src/ui/utils/borderStyles.test.tsx +++ b/packages/cli/src/ui/utils/borderStyles.test.tsx @@ -6,10 +6,11 @@ import { describe, expect, it, vi } from 'vitest'; import { getToolGroupBorderAppearance } from './borderStyles.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import type { IndividualToolCallDisplay } from '../types.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { MainContent } from '../components/MainContent.js'; import { Text } from 'ink'; @@ -17,6 +18,13 @@ vi.mock('../components/CliSpinner.js', () => ({ CliSpinner: () => , })); +const altBufferOptions = { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ + merged: { ui: { useAlternateBuffer: true } }, + }), +}; + describe('getToolGroupBorderAppearance', () => { it('should use warning color for pending non-shell tools', () => { const item = { @@ -105,6 +113,7 @@ describe('getToolGroupBorderAppearance', () => { describe('MainContent tool group border SVG snapshots', () => { it('should render SVG snapshot for a pending search dialog (google_web_search)', async () => { const renderResult = renderWithProviders(, { + ...altBufferOptions, uiState: { history: [], pendingHistoryItems: [ @@ -129,6 +138,7 @@ describe('MainContent tool group border SVG snapshots', () => { it('should render SVG snapshot for an empty slice following a search tool', async () => { const renderResult = renderWithProviders(, { + ...altBufferOptions, uiState: { history: [], pendingHistoryItems: [ @@ -157,6 +167,7 @@ describe('MainContent tool group border SVG snapshots', () => { it('should render SVG snapshot for a shell tool', async () => { const renderResult = renderWithProviders(, { + ...altBufferOptions, uiState: { history: [], pendingHistoryItems: [ From fac36619807349fbc465075830a4b87a057ecab7 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 18 Mar 2026 10:23:05 -0700 Subject: [PATCH 04/49] Changelog for v0.34.0 (#22860) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> --- docs/changelogs/index.md | 11 + docs/changelogs/latest.md | 671 ++++++++++++++++++++++++++------------ 2 files changed, 470 insertions(+), 212 deletions(-) diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 84b499c7a6..d79bd910d1 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,17 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.34.0 - 2026-03-17 + +- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help + you break down complex tasks and execute them systematically + ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). +- **Sandboxing Enhancements:** We've added native gVisor (runsc) and + experimental LXC container sandboxing support for safer execution environments + ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by + @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) + by @h30s). + ## Announcements: v0.33.0 - 2026-03-11 - **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 9b0724e2a9..e49ef1c652 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.33.2 +# Latest stable release: v0.34.0 -Released: March 16, 2026 +Released: March 17, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,227 +11,474 @@ npm install -g @google/gemini-cli ## Highlights -- **Agent Architecture Enhancements:** Introduced HTTP authentication support - for A2A remote agents, authenticated A2A agent card discovery, and directly - indicated auth-required states. -- **Plan Mode Updates:** Expanded Plan Mode capabilities with built-in research - subagents, annotation support for feedback during iteration, and a new `copy` - subcommand. -- **CLI UX Improvements:** Redesigned the header to be compact with an ASCII - icon, inverted the context window display to show usage, and allowed sub-agent - confirmation requests in the UI while preventing background flicker. -- **ACP & MCP Integrations:** Implemented slash command handling in ACP for - `/memory`, `/init`, `/extensions`, and `/restore`, added an MCPOAuthProvider, - and introduced a `set models` interface for ACP. -- **Admin & Core Stability:** Enabled a 30-day default retention for chat - history, added tool name validation in TOML policy files, and improved tool - parameter extraction. +- **Plan Mode Enabled by Default**: The comprehensive planning capability is now + enabled by default, allowing for better structured task management and + execution. +- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) + sandboxing as well as experimental LXC container sandboxing to provide more + robust and isolated execution environments. +- **Improved Loop Detection & Recovery**: Implemented iterative loop detection + and model feedback mechanisms to prevent the CLI from getting stuck in + repetitive actions. +- **Customizable UI Elements**: You can now configure a custom footer using the + new `/footer` command, and enjoy standardized semantic focus colors for better + history visibility. +- **Extensive Subagent Updates**: Refinements across the tracker visualization + tools, background process logging, and broader fallback support for models in + tool execution scenarios. ## What's Changed -- fix(patch): cherry-pick 48130eb to release/v0.33.1-pr-22665 [CONFLICTS] by +- feat(cli): add chat resume footer on session quit by @lordshashank in + [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) +- Support bold and other styles in svg snapshots by @jacob314 in + [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) +- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in + [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) +- Cleanup old branches. by @jacob314 in + [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) +- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by @gemini-cli-robot in - [#22720](https://github.com/google-gemini/gemini-cli/pull/22720) -- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version - v0.33.0 and create version 0.33.1 by @gemini-cli-robot in - [#22206](https://github.com/google-gemini/gemini-cli/pull/22206) -- Docs: Update model docs to remove Preview Features. by @jkcinouye in - [#20084](https://github.com/google-gemini/gemini-cli/pull/20084) -- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in - [#20153](https://github.com/google-gemini/gemini-cli/pull/20153) -- docs: add Windows PowerShell equivalents for environments and scripting by - @scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333) -- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in - [#20626](https://github.com/google-gemini/gemini-cli/pull/20626) -- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10 - in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637) -- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in - [#20641](https://github.com/google-gemini/gemini-cli/pull/20641) -- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by + [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) +- feat(ui): standardize semantic focus colors and enhance history visibility by + @keithguerin in + [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) +- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in + [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) +- Add extra safety checks for proto pollution by @jacob314 in + [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) +- feat(core): Add tracker CRUD tools & visualization by @anj-s in + [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) +- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" + by @jacob314 in + [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) +- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in + [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) +- fix: model persistence for all scenarios by @sripasg in + [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) +- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by @gemini-cli-robot in - [#20644](https://github.com/google-gemini/gemini-cli/pull/20644) -- Changelog for v0.31.0 by @gemini-cli-robot in - [#20634](https://github.com/google-gemini/gemini-cli/pull/20634) -- fix: use full paths for ACP diff payloads by @JagjeevanAK in - [#19539](https://github.com/google-gemini/gemini-cli/pull/19539) -- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in - [#20627](https://github.com/google-gemini/gemini-cli/pull/20627) -- fix: acp/zed race condition between MCP initialisation and prompt by - @kartikangiras in - [#20205](https://github.com/google-gemini/gemini-cli/pull/20205) -- fix(cli): reset themeManager between tests to ensure isolation by - @NTaylorMullen in - [#20598](https://github.com/google-gemini/gemini-cli/pull/20598) -- refactor(core): Extract tool parameter names as constants by @SandyTao520 in - [#20460](https://github.com/google-gemini/gemini-cli/pull/20460) -- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme - mismatches by @sehoon38 in - [#20706](https://github.com/google-gemini/gemini-cli/pull/20706) -- feat(skills): add github-issue-creator skill by @sehoon38 in - [#20709](https://github.com/google-gemini/gemini-cli/pull/20709) -- fix(cli): allow sub-agent confirmation requests in UI while preventing - background flicker by @abhipatel12 in - [#20722](https://github.com/google-gemini/gemini-cli/pull/20722) -- Merge User and Agent Card Descriptions #20849 by @adamfweidman in - [#20850](https://github.com/google-gemini/gemini-cli/pull/20850) -- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in - [#20701](https://github.com/google-gemini/gemini-cli/pull/20701) -- fix(plan): deflake plan mode integration tests by @Adib234 in - [#20477](https://github.com/google-gemini/gemini-cli/pull/20477) -- Add /unassign support by @scidomino in - [#20864](https://github.com/google-gemini/gemini-cli/pull/20864) -- feat(core): implement HTTP authentication support for A2A remote agents by - @SandyTao520 in - [#20510](https://github.com/google-gemini/gemini-cli/pull/20510) -- feat(core): centralize read_file limits and update gemini-3 description by + [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) +- Consistently guard restarts against concurrent auto updates by @scidomino in + [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) +- Defensive coding to reduce the risk of Maximum update depth errors by + @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) +- fix(cli): Polish shell autocomplete rendering to be a little more shell native + feeling. by @jacob314 in + [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) +- Docs: Update plan mode docs by @jkcinouye in + [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) +- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in + [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) +- fix(cli): register extension lifecycle events in DebugProfiler by + @fayerman-source in + [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) +- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in + [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) +- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in + [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) +- Changelog for v0.32.0 by @gemini-cli-robot in + [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) +- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in + [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) +- feat(core): improve @scripts/copy_files.js autocomplete to prioritize + filenames by @sehoon38 in + [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) +- feat(sandbox): add experimental LXC container sandbox support by @h30s in + [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) +- feat(evals): add overall pass rate row to eval nightly summary table by + @gundermanc in + [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) +- feat(telemetry): include language in telemetry and fix accepted lines + computation by @gundermanc in + [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) +- Changelog for v0.32.1 by @gemini-cli-robot in + [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) +- feat(core): add robustness tests, logging, and metrics for CodeAssistServer + SSE parsing by @yunaseoul in + [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) +- feat: add issue assignee workflow by @kartikangiras in + [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) +- fix: improve error message when OAuth succeeds but project ID is required by + @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) +- feat(loop-reduction): implement iterative loop detection and model feedback by @aishaneeshah in - [#20619](https://github.com/google-gemini/gemini-cli/pull/20619) -- Do not block CI on evals by @gundermanc in - [#20870](https://github.com/google-gemini/gemini-cli/pull/20870) -- document node limitation for shift+tab by @scidomino in - [#20877](https://github.com/google-gemini/gemini-cli/pull/20877) -- Add install as an option when extension is selected. by @DavidAPierce in - [#20358](https://github.com/google-gemini/gemini-cli/pull/20358) -- Update CODEOWNERS for README.md reviewers by @g-samroberts in - [#20860](https://github.com/google-gemini/gemini-cli/pull/20860) -- feat(core): truncate large MCP tool output by @SandyTao520 in - [#19365](https://github.com/google-gemini/gemini-cli/pull/19365) -- Subagent activity UX. by @gundermanc in - [#17570](https://github.com/google-gemini/gemini-cli/pull/17570) -- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in - [#17930](https://github.com/google-gemini/gemini-cli/pull/17930) -- feat: redesign header to be compact with ASCII icon by @keithguerin in - [#18713](https://github.com/google-gemini/gemini-cli/pull/18713) -- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in - [#20801](https://github.com/google-gemini/gemini-cli/pull/20801) -- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in - [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) -- refactor(cli): fully remove React anti patterns, improve type safety and fix - UX oversights in SettingsDialog.tsx by @psinha40898 in - [#18963](https://github.com/google-gemini/gemini-cli/pull/18963) -- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by - @Nayana-Parameswarappa in - [#20121](https://github.com/google-gemini/gemini-cli/pull/20121) -- feat(core): add tool name validation in TOML policy files by @allenhutchison - in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281) -- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in - [#20300](https://github.com/google-gemini/gemini-cli/pull/20300) -- refactor(core): replace manual syncPlanModeTools with declarative policy rules - by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596) -- fix(core): increase default headers timeout to 5 minutes by @gundermanc in - [#20890](https://github.com/google-gemini/gemini-cli/pull/20890) -- feat(admin): enable 30 day default retention for chat history & remove warning + [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) +- chore(github): require prompt approvers for agent prompt files by @gundermanc + in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) +- Docs: Create tools reference by @jkcinouye in + [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) +- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions + by @spencer426 in + [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) +- chore(cli): enable deprecated settings removal by default by @yashodipmore in + [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) +- feat(core): Disable fast ack helper for hints. by @joshualitt in + [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) +- fix(ui): suppress redundant failure note when tool error note is shown by + @NTaylorMullen in + [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) +- docs: document planning workflows with Conductor example by @jerop in + [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) +- feat(release): ship esbuild bundle in npm package by @genneth in + [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) +- fix(extensions): preserve symlinks in extension source path while enforcing + folder trust by @galz10 in + [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) +- fix(cli): defer tool exclusions to policy engine in non-interactive mode by + @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) +- fix(ui): removed double padding on rendered content by @devr0306 in + [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) +- fix(core): truncate excessively long lines in grep search output by + @gundermanc in + [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) +- feat: add custom footer configuration via `/footer` by @jackwotherspoon in + [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) +- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in + [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) +- refactor(cli): categorize built-in themes into dark/ and light/ directories by + @JayadityaGit in + [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) +- fix(core): explicitly allow codebase_investigator and cli_help in read-only + mode by @Adib234 in + [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) +- test: add browser agent integration tests by @kunal-10-cloud in + [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) +- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in + [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) +- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by + @SandyTao520 in + [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) +- fix(ui): add partial output to cancelled shell UI by @devr0306 in + [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) +- fix(cli): replace hardcoded keybinding strings with dynamic formatters by + @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) +- DOCS: Update quota and pricing page by @g-samroberts in + [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) +- feat(telemetry): implement Clearcut logging for startup statistics by + @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) +- feat(triage): add area/documentation to issue triage by @g-samroberts in + [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) +- Fix so shell calls are formatted by @jacob314 in + [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) +- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in + [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) +- docs: use absolute paths for internal links in plan-mode.md by @jerop in + [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) +- fix(core): prevent unhandled AbortError crash during stream loop detection by + @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) +- fix:reorder env var redaction checks to scan values first by @kartikangiras in + [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) +- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences by @skeshive in - [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) -- feat(plan): support annotating plans with feedback for iteration by @Adib234 - in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876) -- Add some dos and don'ts to behavioral evals README. by @gundermanc in - [#20629](https://github.com/google-gemini/gemini-cli/pull/20629) -- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in - [#19477](https://github.com/google-gemini/gemini-cli/pull/19477) -- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 - models by @SandyTao520 in - [#20897](https://github.com/google-gemini/gemini-cli/pull/20897) -- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in - [#20898](https://github.com/google-gemini/gemini-cli/pull/20898) -- Build binary by @aswinashok44 in - [#18933](https://github.com/google-gemini/gemini-cli/pull/18933) -- Code review fixes as a pr by @jacob314 in - [#20612](https://github.com/google-gemini/gemini-cli/pull/20612) -- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in - [#20919](https://github.com/google-gemini/gemini-cli/pull/20919) -- feat(cli): invert context window display to show usage by @keithguerin in - [#20071](https://github.com/google-gemini/gemini-cli/pull/20071) -- fix(plan): clean up session directories and plans on deletion by @jerop in - [#20914](https://github.com/google-gemini/gemini-cli/pull/20914) -- fix(core): enforce optionality for API response fields in code_assist by - @sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714) -- feat(extensions): add support for plan directory in extension manifest by - @mahimashanware in - [#20354](https://github.com/google-gemini/gemini-cli/pull/20354) -- feat(plan): enable built-in research subagents in plan mode by @Adib234 in - [#20972](https://github.com/google-gemini/gemini-cli/pull/20972) -- feat(agents): directly indicate auth required state by @adamfweidman in - [#20986](https://github.com/google-gemini/gemini-cli/pull/20986) -- fix(cli): wait for background auto-update before relaunching by @scidomino in - [#20904](https://github.com/google-gemini/gemini-cli/pull/20904) -- fix: pre-load @scripts/copy_files.js references from external editor prompts - by @kartikangiras in - [#20963](https://github.com/google-gemini/gemini-cli/pull/20963) -- feat(evals): add behavioral evals for ask_user tool by @Adib234 in - [#20620](https://github.com/google-gemini/gemini-cli/pull/20620) -- refactor common settings logic for skills,agents by @ishaanxgupta in - [#17490](https://github.com/google-gemini/gemini-cli/pull/17490) -- Update docs-writer skill with new resource by @g-samroberts in - [#20917](https://github.com/google-gemini/gemini-cli/pull/20917) -- fix(cli): pin clipboardy to ~5.2.x by @scidomino in - [#21009](https://github.com/google-gemini/gemini-cli/pull/21009) -- feat: Implement slash command handling in ACP for - `/memory`,`/init`,`/extensions` and `/restore` by @sripasg in - [#20528](https://github.com/google-gemini/gemini-cli/pull/20528) -- Docs/add hooks reference by @AadithyaAle in - [#20961](https://github.com/google-gemini/gemini-cli/pull/20961) -- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in - [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) -- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12 - in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987) -- Format the quota/limit style guide. by @g-samroberts in - [#21017](https://github.com/google-gemini/gemini-cli/pull/21017) -- fix(core): send shell output to model on cancel by @devr0306 in - [#20501](https://github.com/google-gemini/gemini-cli/pull/20501) -- remove hardcoded tiername when missing tier by @sehoon38 in - [#21022](https://github.com/google-gemini/gemini-cli/pull/21022) -- feat(acp): add set models interface by @skeshive in - [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) -- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch - version v0.33.0-preview.0 and create version 0.33.0-preview.1 by + [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) +- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 + in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) +- test(core): improve testing for API request/response parsing by @sehoon38 in + [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) +- docs(links): update docs-writer skill and fix broken link by @g-samroberts in + [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) +- Fix code colorizer ansi escape bug. by @jacob314 in + [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) +- remove wildcard behavior on keybindings by @scidomino in + [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) +- feat(acp): Add support for AI Gateway auth by @skeshive in + [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) +- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in + [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) +- feat (core): Implement tracker related SI changes by @anj-s in + [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) +- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in + [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) +- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in + [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) +- docs: format release times as HH:MM UTC by @pavan-sh in + [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) +- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in + [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) +- docs: fix incorrect relative links to command reference by @kanywst in + [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) +- documentiong ensures ripgrep by @Jatin24062005 in + [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) +- fix(core): handle AbortError thrown during processTurn by @MumuTW in + [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) +- docs(cli): clarify ! command output visibility in shell commands tutorial by + @MohammedADev in + [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) +- fix: logic for task tracker strategy and remove tracker tools by @anj-s in + [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) +- fix(partUtils): display media type and size for inline data parts by @Aboudjem + in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) +- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in + [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) +- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by + @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) +- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive + filesystems (#19904) by @Nixxx19 in + [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) +- feat(core): add concurrency safety guidance for subagent delegation (#17753) + by @abhipatel12 in + [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) +- feat(ui): dynamically generate all keybinding hints by @scidomino in + [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) +- feat(core): implement unified KeychainService and migrate token storage by + @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) +- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in + [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) +- fix(plan): keep approved plan during chat compression by @ruomengz in + [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) +- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 + in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) +- Update quota and pricing documentation with subscription tiers by @srithreepo + in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) +- fix(core): append correct OTLP paths for HTTP exporters by + @sebastien-prudhomme in + [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) +- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in + [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) +- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in + [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) +- refactor(core): standardize MCP tool naming to mcp\_ FQN format by + @abhipatel12 in + [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) +- feat(cli): hide gemma settings from display and mark as experimental by + @abhipatel12 in + [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) +- feat(skills): refine string-reviewer guidelines and description by @clocky in + [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) +- fix(core): whitelist TERM and COLORTERM in environment sanitization by + @deadsmash07 in + [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) +- fix(billing): fix overage strategy lifecycle and settings integration by + @gsquared94 in + [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) +- fix: expand paste placeholders in TextInput on submit by @Jefftree in + [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) +- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by + @SandyTao520 in + [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) +- feat(cli): overhaul thinking UI by @keithguerin in + [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) +- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by + @jwhelangoog in + [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) +- fix(cli): correct shell height reporting by @jacob314 in + [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) +- Make test suite pass when the GEMINI_SYSTEM_MD env variable or + GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in + [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) +- Disallow underspecified types by @gundermanc in + [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) +- refactor(cli): standardize on 'reload' verb for all components by @keithguerin + in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) +- feat(cli): Invert quota language to 'percent used' by @keithguerin in + [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) +- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye + in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) +- Code review comments as a pr by @jacob314 in + [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) +- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in + [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) +- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + @Gyanranjan-Priyam in + [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) +- fix(core): display actual graph output in tracker_visualize tool by @anj-s in + [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) +- fix(core): sanitize SSE-corrupted JSON and domain strings in error + classification by @gsquared94 in + [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) +- Docs: Make documentation links relative by @diodesign in + [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) +- feat(cli): expose /tools desc as explicit subcommand for discoverability by + @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) +- feat(cli): add /compact alias for /compress command by @jackwotherspoon in + [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) +- feat(plan): enable Plan Mode by default by @jerop in + [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) +- feat(core): Introduce `AgentLoopContext`. by @joshualitt in + [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) +- fix(core): resolve symlinks for non-existent paths during validation by + @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) +- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in + [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) +- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 + in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) +- feat(cli): implement /upgrade command by @sehoon38 in + [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) +- Feat/browser agent progress emission by @kunal-10-cloud in + [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) +- fix(settings): display objects as JSON instead of [object Object] by + @Zheyuan-Lin in + [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) +- Unmarshall update by @DavidAPierce in + [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) +- Update mcp's list function to check for disablement. by @DavidAPierce in + [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) +- robustness(core): static checks to validate history is immutable by @jacob314 + in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) +- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in + [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) +- feat(security): implement robust IP validation and safeFetch foundation by + @alisa-alisa in + [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) +- feat(core): improve subagent result display by @joshualitt in + [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) +- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in + [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) +- feat(policy): support subagent-specific policies in TOML by @akh64bit in + [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) +- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in + [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) +- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in + [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) +- fix(docs): fix headless mode docs by @ame2en in + [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) +- feat/redesign header compact by @jacob314 in + [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) +- refactor: migrate to useKeyMatchers hook by @scidomino in + [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) +- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by + @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) +- fix(core): resolve Windows line ending and path separation bugs across CLI by + @muhammadusman586 in + [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) +- docs: fix heading formatting in commands.md and phrasing in tools-api.md by + @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) +- refactor(ui): unify keybinding infrastructure and support string + initialization by @scidomino in + [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) +- Add support for updating extension sources and names by @chrstnb in + [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) +- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed + in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) +- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy + in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) +- fix(docs): update theme screenshots and add missing themes by @ashmod in + [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) +- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in + [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) +- build(release): restrict npm bundling to non-stable tags by @sehoon38 in + [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) +- fix(core): override toolRegistry property for sub-agent schedulers by + @gsquared94 in + [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) +- fix(cli): make footer items equally spaced by @jacob314 in + [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) +- docs: clarify global policy rules application in plan mode by @jerop in + [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) +- fix(core): ensure correct flash model steering in plan mode implementation + phase by @jerop in + [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) +- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in + [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) +- refactor(core): improve API response error logging when retry by @yunaseoul in + [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) +- fix(ui): handle headless execution in credits and upgrade dialogs by + @gsquared94 in + [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) +- fix(core): treat retryable errors with >5 min delay as terminal quota errors + by @gsquared94 in + [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) +- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub + Actions by @cocosheng-g in + [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) +- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by + @SandyTao520 in + [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) +- feat(cli): give visibility to /tools list command in the TUI and follow the + subcommand pattern of other commands by @JayadityaGit in + [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) +- Handle dirty worktrees better and warn about running scripts/review.sh on + untrusted code. by @jacob314 in + [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) +- feat(policy): support auto-add to policy by default and scoped persistence by + @spencer426 in + [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) +- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 + in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) +- fix(release): Improve Patch Release Workflow Comments: Clearer Approval + Guidance by @jerop in + [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) +- docs: clarify telemetry setup and comprehensive data map by @jerop in + [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) +- feat(core): add per-model token usage to stream-json output by @yongruilin in + [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) +- docs: remove experimental badge from plan mode in sidebar by @jerop in + [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) +- fix(cli): prevent race condition in loop detection retry by @skyvanguard in + [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) +- Add behavioral evals for tracker by @anj-s in + [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) +- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in + [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) +- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in + [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) +- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in + [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) +- make command names consistent by @scidomino in + [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) +- refactor: remove agent_card_requires_auth config flag by @adamfweidman in + [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) +- feat(a2a): implement standardized normalization and streaming reassembly by + @alisa-alisa in + [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) +- feat(cli): enable skill activation via slash commands by @NTaylorMullen in + [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) +- docs(cli): mention per-model token usage in stream-json result event by + @yongruilin in + [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) +- fix(plan): prevent plan truncation in approval dialog by supporting + unconstrained heights by @Adib234 in + [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) +- feat(a2a): switch from callback-based to event-driven tool scheduler by + @cocosheng-g in + [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) +- feat(voice): implement speech-friendly response formatter by @ayush31010 in + [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) +- feat: add pulsating blue border automation overlay to browser agent by + @kunal-10-cloud in + [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) +- Add extensionRegistryURI setting to change where the registry is read from by + @kevinjwang1 in + [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) +- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in + [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) +- fix: prevent hangs in non-interactive mode and improve agent guidance by + @cocosheng-g in + [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) +- Add ExtensionDetails dialog and support install by @chrstnb in + [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) +- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by @gemini-cli-robot in - [#21047](https://github.com/google-gemini/gemini-cli/pull/21047) -- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch - version v0.33.0-preview.1 and create version 0.33.0-preview.2 by - @gemini-cli-robot in - [#21300](https://github.com/google-gemini/gemini-cli/pull/21300) -- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 + [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) +- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in + [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) +- fix(cli): stabilize prompt layout to prevent jumping when typing by + @NTaylorMullen in + [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) +- fix: preserve prompt text when cancelling streaming by @Nixxx19 in + [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) +- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in + [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) +- feat: implement background process logging and cleanup by @galz10 in + [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) +- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in + [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) +- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] by @gemini-cli-robot in - [#21336](https://github.com/google-gemini/gemini-cli/pull/21336) -- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch - version v0.33.0-preview.3 and create version 0.33.0-preview.4 by + [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) +- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch + version v0.34.0-preview.1 and create version 0.34.0-preview.2 by @gemini-cli-robot in - [#21349](https://github.com/google-gemini/gemini-cli/pull/21349) -- fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 - [CONFLICTS] by @gemini-cli-robot in - [#21478](https://github.com/google-gemini/gemini-cli/pull/21478) -- fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch - version v0.33.0-preview.5 and create version 0.33.0-preview.6 by + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) +- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch + version v0.34.0-preview.2 and create version 0.34.0-preview.3 by @gemini-cli-robot in - [#21720](https://github.com/google-gemini/gemini-cli/pull/21720) -- fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch - version v0.33.0-preview.7 and create version 0.33.0-preview.8 by + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch + version v0.34.0-preview.3 and create version 0.34.0-preview.4 by @gemini-cli-robot in - [#21782](https://github.com/google-gemini/gemini-cli/pull/21782) -- fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236 - [CONFLICTS] by @gemini-cli-robot in - [#21788](https://github.com/google-gemini/gemini-cli/pull/21788) -- fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch - version v0.33.0-preview.9 and create version 0.33.0-preview.10 by - @gemini-cli-robot in - [#21800](https://github.com/google-gemini/gemini-cli/pull/21800) -- fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 by - @gemini-cli-robot in - [#21859](https://github.com/google-gemini/gemini-cli/pull/21859) -- fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 by - @gemini-cli-robot in - [#21876](https://github.com/google-gemini/gemini-cli/pull/21876) -- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to - patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by - @gemini-cli-robot in - [#21922](https://github.com/google-gemini/gemini-cli/pull/21922) -- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 - [CONFLICTS] by @gemini-cli-robot in - [#21930](https://github.com/google-gemini/gemini-cli/pull/21930) -- fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884 - [CONFLICTS] by @gemini-cli-robot in - [#21952](https://github.com/google-gemini/gemini-cli/pull/21952) + [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.2 +https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 From a5a461c23400aaac839b168c86750cd426c4e801 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 18 Mar 2026 18:12:44 +0000 Subject: [PATCH 05/49] test(cli): simplify createMockSettings calls (#22952) --- packages/cli/src/ui/App.test.tsx | 44 ++--- packages/cli/src/ui/AppContainer.test.tsx | 162 ++++++------------ .../src/ui/components/AskUserDialog.test.tsx | 12 +- .../cli/src/ui/components/Composer.test.tsx | 2 +- .../DetailedMessagesDisplay.test.tsx | 20 +-- .../ui/components/ExitPlanModeDialog.test.tsx | 8 +- .../ui/components/FolderTrustDialog.test.tsx | 24 +-- .../cli/src/ui/components/Footer.test.tsx | 12 +- .../ui/components/HistoryItemDisplay.test.tsx | 32 +--- .../src/ui/components/InputPrompt.test.tsx | 8 +- .../src/ui/components/MainContent.test.tsx | 10 +- .../components/ToolConfirmationQueue.test.tsx | 8 +- .../components/messages/DiffRenderer.test.tsx | 44 ++--- .../messages/ShellToolMessage.test.tsx | 24 +-- .../messages/ToolGroupMessage.test.tsx | 8 +- .../components/messages/ToolMessage.test.tsx | 12 +- .../messages/ToolMessageRawMarkdown.test.tsx | 4 +- .../ToolOverflowConsistencyChecks.test.tsx | 8 +- .../messages/ToolResultDisplay.test.tsx | 52 ++---- .../ToolResultDisplayOverflow.test.tsx | 12 +- .../cli/src/ui/utils/borderStyles.test.tsx | 4 +- 21 files changed, 145 insertions(+), 365 deletions(-) diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 969e8b23aa..4e59ab854e 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -99,9 +99,7 @@ describe('App', () => { { uiState: mockUIState, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -123,9 +121,7 @@ describe('App', () => { { uiState: quittingUIState, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -147,9 +143,7 @@ describe('App', () => { { uiState: quittingUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -170,9 +164,7 @@ describe('App', () => { { uiState: dialogUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -200,9 +192,7 @@ describe('App', () => { { uiState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -220,9 +210,7 @@ describe('App', () => { { uiState: mockUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -242,9 +230,7 @@ describe('App', () => { { uiState: mockUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -300,9 +286,7 @@ describe('App', () => { { uiState: stateWithConfirmingTool, config: configWithExperiment, - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -323,9 +307,7 @@ describe('App', () => { { uiState: mockUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -340,9 +322,7 @@ describe('App', () => { { uiState: mockUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -360,9 +340,7 @@ describe('App', () => { { uiState: dialogUIState, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 26ee1a87c1..3e420f141d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -486,17 +486,15 @@ describe('AppContainer State Management', () => { // Mock LoadedSettings mockSettings = createMockSettings({ - merged: { - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: false, - theme: 'default', - ui: { - showStatusInTitle: false, - hideWindowTitle: false, - useAlternateBuffer: false, - }, + hideBanner: false, + hideFooter: false, + hideTips: false, + showMemoryUsage: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useAlternateBuffer: false, }, }); @@ -1007,12 +1005,10 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { const settingsAllHidden = createMockSettings({ - merged: { - hideBanner: true, - hideFooter: true, - hideTips: true, - showMemoryUsage: false, - }, + hideBanner: true, + hideFooter: true, + hideTips: true, + showMemoryUsage: false, }); let unmount: () => void; @@ -1026,9 +1022,7 @@ describe('AppContainer State Management', () => { it('handles settings with memory usage enabled', async () => { const settingsWithMemory = createMockSettings({ - merged: { - showMemoryUsage: true, - }, + showMemoryUsage: true, }); let unmount: () => void; @@ -1488,11 +1482,9 @@ describe('AppContainer State Management', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled const mockSettingsWithShowStatusFalse = createMockSettings({ - merged: { - ui: { - showStatusInTitle: false, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, }); @@ -1523,11 +1515,9 @@ describe('AppContainer State Management', () => { it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled const mockSettingsWithDynamicTitleFalse = createMockSettings({ - merged: { - ui: { - dynamicWindowTitle: false, - hideWindowTitle: false, - }, + ui: { + dynamicWindowTitle: false, + hideWindowTitle: false, }, }); @@ -1558,11 +1548,9 @@ describe('AppContainer State Management', () => { it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled const mockSettingsWithHideTitleTrue = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: true, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: true, }, }); @@ -1583,11 +1571,9 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1619,11 +1605,9 @@ describe('AppContainer State Management', () => { it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1650,11 +1634,9 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1709,11 +1691,9 @@ describe('AppContainer State Management', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1765,11 +1745,9 @@ describe('AppContainer State Management', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1832,11 +1810,9 @@ describe('AppContainer State Management', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1879,11 +1855,9 @@ describe('AppContainer State Management', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1960,11 +1934,9 @@ describe('AppContainer State Management', () => { it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -1997,11 +1969,9 @@ describe('AppContainer State Management', () => { it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: true, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, }); @@ -2032,11 +2002,9 @@ describe('AppContainer State Management', () => { it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) const mockSettingsWithTitleDisabled = createMockSettings({ - merged: { - ui: { - showStatusInTitle: false, - hideWindowTitle: false, - }, + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, }); @@ -2608,11 +2576,7 @@ describe('AppContainer State Management', () => { // Update settings for this test run const testSettings = createMockSettings({ - merged: { - ui: { - useAlternateBuffer: isAlternateMode, - }, - }, + ui: { useAlternateBuffer: isAlternateMode }, }); function TestChild() { @@ -3323,11 +3287,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: createMockSettings({ - merged: { - ui: { useAlternateBuffer: false }, - }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }).unmount; }); @@ -3363,11 +3323,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: createMockSettings({ - merged: { - ui: { useAlternateBuffer: true }, - }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }).unmount; }); @@ -3637,11 +3593,7 @@ describe('AppContainer State Management', () => { it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { const settingsWithAlternateBuffer = createMockSettings({ - merged: { - ui: { - useAlternateBuffer: true, - }, - }, + ui: { useAlternateBuffer: true }, }); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 2f4f711e75..67289769be 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -317,9 +317,7 @@ describe('AskUserDialog', () => { />, { config: makeFakeConfig({ useAlternateBuffer }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); @@ -1300,9 +1298,7 @@ describe('AskUserDialog', () => { , { config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); @@ -1341,9 +1337,7 @@ describe('AskUserDialog', () => { , { config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 84f8d15a06..e0919947fb 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -408,7 +408,7 @@ describe('Composer', () => { thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ - merged: { ui: { loadingPhrases: 'off' } }, + ui: { loadingPhrases: 'off' }, }); const { lastFrame } = await renderComposer(uiState, settings); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 65d54e50d6..b6fd50b33f 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -38,9 +38,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={false} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -64,9 +62,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -89,9 +85,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'low' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), }, ); await waitUntilReady(); @@ -112,9 +106,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -135,9 +127,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={false} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 272ccbdc27..231d5f102f 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -167,9 +167,7 @@ Implement a comprehensive authentication system with multiple providers. }), getUseAlternateBuffer: () => useAlternateBuffer, } as unknown as import('@google/gemini-cli-core').Config, - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); }; @@ -449,9 +447,7 @@ Implement a comprehensive authentication system with multiple providers. getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, settings: createMockSettings({ - merged: { - ui: { useAlternateBuffer: useAlternateBuffer ?? true }, - }, + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, }), }, ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 0ff0e9b0df..9ad4fac02d 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -80,9 +80,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); @@ -113,9 +111,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); @@ -147,9 +143,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); @@ -179,9 +173,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -208,9 +200,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: false } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -451,9 +441,7 @@ describe('FolderTrustDialog', () => { { width: 80, config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ - merged: { ui: { useAlternateBuffer: true } }, - }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index ab487a440f..84782b2513 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -673,9 +673,7 @@ describe('