diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index cc5c559365..a830cc12c6 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.33.0-preview.4 +# Preview release: v0.33.0-preview.14 -Released: March 06, 2026 +Released: March 10, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -29,6 +29,13 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- 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 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 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 @gemini-cli-robot in @@ -198,4 +205,4 @@ npm install -g @google/gemini-cli@preview [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.14 diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 0e84d8321b..570ef91770 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -22,19 +22,20 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | -| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | -| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | -| Clear Context on Plan Approval | `general.plan.clearContextOnApproval` | Automatically clear conversation context after a plan is approved and implementation begins. | `undefined` | -| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | +| UI Label | Setting | Description | Default | +| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | +| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | +| Clear Context on Plan Approval | `general.plan.clearContextOnApproval` | Automatically clear conversation context after a plan is approved and implementation begins. | `undefined` | +| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | +| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 01c9d76c41..27cd6d8dee 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -151,7 +151,7 @@ their corresponding top-level category object in your `settings.json` file. - **`general.retryFetchErrors`** (boolean): - **Description:** Retry on "exception TypeError: fetch failed sending request" errors. - - **Default:** `false` + - **Default:** `true` - **`general.maxAttempts`** (number): - **Description:** Maximum number of attempts for requests to the main chat diff --git a/package-lock.json b/package-lock.json index 8a43a9f7d0..7e0a853d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "workspaces": [ "packages/*" ], @@ -16815,7 +16815,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "dependencies": { "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", @@ -16930,7 +16930,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -17102,7 +17102,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "0.3.11", @@ -17358,7 +17358,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17373,7 +17373,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17390,7 +17390,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17407,7 +17407,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index 8d931c1462..0067054629 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 71430291c7..ecf3ee3d66 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index bf15d7fc49..26039ae3aa 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -4,25 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Task } from './task.js'; import { GeminiEventType, - ApprovalMode, - ToolConfirmationOutcome, type Config, type ToolCallRequestInfo, type GitService, type CompletedToolCall, - type ToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; @@ -389,214 +378,6 @@ describe('Task', () => { ); }); - describe('_schedulerToolCallsUpdate', () => { - let task: Task; - type SpyInstance = ReturnType; - let setTaskStateAndPublishUpdateSpy: SpyInstance; - let mockConfig: Config; - let mockEventBus: ExecutionEventBus; - - beforeEach(() => { - mockConfig = createMockConfig() as Config; - mockEventBus = { - publish: vi.fn(), - on: vi.fn(), - off: vi.fn(), - once: vi.fn(), - removeAllListeners: vi.fn(), - finished: vi.fn(), - }; - - // @ts-expect-error - Calling private constructor - task = new Task('task-id', 'context-id', mockConfig, mockEventBus); - - // Spy on the method we want to check calls for - setTaskStateAndPublishUpdateSpy = vi.spyOn( - task, - 'setTaskStateAndPublishUpdate', - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should set state to input-required when a tool is awaiting approval and none are executing', () => { - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - // The last call should be the final state update - expect(setTaskStateAndPublishUpdateSpy).toHaveBeenLastCalledWith( - 'input-required', - { kind: 'state-change' }, - undefined, - undefined, - true, // final: true - ); - }); - - it('should NOT set state to input-required if a tool is awaiting approval but another is executing', () => { - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - { request: { callId: '2' }, status: 'executing' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - // It will be called for status updates, but not with final: true - const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - }); - - it('should set state to input-required once an executing tool finishes, leaving one awaiting approval', () => { - const initialToolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - { request: { callId: '2' }, status: 'executing' }, - ] as ToolCall[]; - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(initialToolCalls); - - // No final call yet - let finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - - // Now, the executing tool finishes. The scheduler would call _resolveToolCall for it. - // @ts-expect-error - Calling private method - task._resolveToolCall('2'); - - // Then another update comes in for the awaiting tool (e.g., a re-check) - const subsequentToolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(subsequentToolCalls); - - // NOW we should get the final call - finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeDefined(); - expect(finalCall?.[0]).toBe('input-required'); - }); - - it('should NOT set state to input-required if skipFinalTrueAfterInlineEdit is true', () => { - task.skipFinalTrueAfterInlineEdit = true; - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - }); - - describe('auto-approval', () => { - it('should NOT publish ToolCallConfirmationEvent when autoExecute is true', () => { - task.autoExecute = true; - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - correlationId: 'test-corr-id', - confirmationDetails: { - type: 'edit', - onConfirm: onConfirmSpy, - }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - ); - const calls = (mockEventBus.publish as Mock).mock.calls; - // Search if ToolCallConfirmationEvent was published - const confEvent = calls.find( - (call) => - call[0].metadata?.coderAgent?.kind === - CoderAgentEvent.ToolCallConfirmationEvent, - ); - expect(confEvent).toBeUndefined(); - }); - - it('should NOT publish ToolCallConfirmationEvent when approval mode is YOLO', () => { - (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); - task.autoExecute = false; - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - correlationId: 'test-corr-id', - confirmationDetails: { - type: 'edit', - onConfirm: onConfirmSpy, - }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - ); - const calls = (mockEventBus.publish as Mock).mock.calls; - // Search if ToolCallConfirmationEvent was published - const confEvent = calls.find( - (call) => - call[0].metadata?.coderAgent?.kind === - CoderAgentEvent.ToolCallConfirmationEvent, - ); - expect(confEvent).toBeUndefined(); - }); - - it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => { - task.autoExecute = false; - (mockConfig.getApprovalMode as Mock).mockReturnValue( - ApprovalMode.DEFAULT, - ); - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - confirmationDetails: { onConfirm: onConfirmSpy }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).not.toHaveBeenCalled(); - const calls = (mockEventBus.publish as Mock).mock.calls; - // Search if ToolCallConfirmationEvent was published - const confEvent = calls.find( - (call) => - call[0].metadata?.coderAgent?.kind === - CoderAgentEvent.ToolCallConfirmationEvent, - ); - expect(confEvent).toBeDefined(); - }); - }); - }); - describe('currentPromptId and promptCount', () => { it('should correctly initialize and update promptId and promptCount', async () => { const mockConfig = createMockConfig(); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 652635779b..94a03171d7 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -6,7 +6,6 @@ import { Scheduler, - CoreToolScheduler, type GeminiClient, GeminiEventType, ToolConfirmationOutcome, @@ -69,37 +68,10 @@ import type { PartUnion, Part as genAiPart } from '@google/genai'; type UnionKeys = T extends T ? keyof T : never; -type ConfirmationType = ToolCallConfirmationDetails['type']; - -const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [ - 'edit', - 'exec', - 'mcp', - 'info', - 'ask_user', - 'exit_plan_mode', -] as const; - -function isToolCallConfirmationDetails( - value: unknown, -): value is ToolCallConfirmationDetails { - if ( - typeof value !== 'object' || - value === null || - !('onConfirm' in value) || - typeof value.onConfirm !== 'function' || - !('type' in value) || - typeof value.type !== 'string' - ) { - return false; - } - return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type); -} - export class Task { id: string; contextId: string; - scheduler: Scheduler | CoreToolScheduler; + scheduler: Scheduler; config: Config; geminiClient: GeminiClient; pendingToolConfirmationDetails: Map; @@ -140,11 +112,7 @@ export class Task { this.contextId = contextId; this.config = config; - if (this.config.isEventDrivenSchedulerEnabled()) { - this.scheduler = this.setupEventDrivenScheduler(); - } else { - this.scheduler = this.createLegacyScheduler(); - } + this.scheduler = this.setupEventDrivenScheduler(); this.geminiClient = this.config.getGeminiClient(); this.pendingToolConfirmationDetails = new Map(); @@ -260,11 +228,7 @@ export class Task { this.pendingToolCalls.clear(); this.pendingCorrelationIds.clear(); - if (this.scheduler instanceof Scheduler) { - this.scheduler.cancelAll(); - } else { - this.scheduler.cancelAll(new AbortController().signal); - } + this.scheduler.cancelAll(); // Reset the promise for any future operations, ensuring it's in a clean state. this._resetToolCompletionPromise(); } @@ -409,140 +373,13 @@ export class Task { this.eventBus?.publish(artifactEvent); } - private async _schedulerAllToolCallsComplete( - completedToolCalls: CompletedToolCall[], - ): Promise { - logger.info( - '[Task] All tool calls completed by scheduler (batch):', - completedToolCalls.map((tc) => tc.request.callId), - ); - this.completedToolCalls.push(...completedToolCalls); - completedToolCalls.forEach((tc) => { - this._resolveToolCall(tc.request.callId); - }); - } - - private _schedulerToolCallsUpdate(toolCalls: ToolCall[]): void { - logger.info( - '[Task] Scheduler tool calls updated:', - toolCalls.map((tc) => `${tc.request.callId} (${tc.status})`), - ); - - // Update state and send continuous, non-final updates - toolCalls.forEach((tc) => { - const previousStatus = this.pendingToolCalls.get(tc.request.callId); - const hasChanged = previousStatus !== tc.status; - - // Resolve tool call if it has reached a terminal state - if (['success', 'error', 'cancelled'].includes(tc.status)) { - this._resolveToolCall(tc.request.callId); - } else { - // This will update the map - this._registerToolCall(tc.request.callId, tc.status); - } - - if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { - const details = tc.confirmationDetails; - if (isToolCallConfirmationDetails(details)) { - this.pendingToolConfirmationDetails.set(tc.request.callId, details); - } - } - - // Only send an update if the status has actually changed. - if (hasChanged) { - // Skip sending confirmation event if we are going to auto-approve it anyway - if ( - tc.status === 'awaiting_approval' && - tc.confirmationDetails && - this.isYoloMatch - ) { - logger.info( - `[Task] Skipping ToolCallConfirmationEvent for ${tc.request.callId} due to YOLO mode.`, - ); - } else { - const coderAgentMessage: CoderAgentMessage = - tc.status === 'awaiting_approval' - ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } - : { kind: CoderAgentEvent.ToolCallUpdateEvent }; - const message = this.toolStatusMessage(tc, this.id, this.contextId); - - const event = this._createStatusUpdateEvent( - this.taskState, - coderAgentMessage, - message, - false, // Always false for these continuous updates - ); - this.eventBus?.publish(event); - } - } - }); - - if (this.isYoloMatch) { - logger.info( - '[Task] ' + - (this.autoExecute ? '' : 'YOLO mode enabled. ') + - 'Auto-approving all tool calls.', - ); - toolCalls.forEach((tc: ToolCall) => { - if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { - const details = tc.confirmationDetails; - if (isToolCallConfirmationDetails(details)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - details.onConfirm(ToolConfirmationOutcome.ProceedOnce); - this.pendingToolConfirmationDetails.delete(tc.request.callId); - } - } - }); - return; - } - - const allPendingStatuses = Array.from(this.pendingToolCalls.values()); - const isAwaitingApproval = allPendingStatuses.some( - (status) => status === 'awaiting_approval', - ); - const isExecuting = allPendingStatuses.some( - (status) => status === 'executing', - ); - - // The turn is complete and requires user input if at least one tool - // is waiting for the user's decision, and no other tool is actively - // running in the background. - if ( - isAwaitingApproval && - !isExecuting && - !this.skipFinalTrueAfterInlineEdit - ) { - this.skipFinalTrueAfterInlineEdit = false; - - // We don't need to send another message, just a final status update. - this.setTaskStateAndPublishUpdate( - 'input-required', - { kind: CoderAgentEvent.StateChangeEvent }, - undefined, - undefined, - /*final*/ true, - ); - } - } - - private createLegacyScheduler(): CoreToolScheduler { - const scheduler = new CoreToolScheduler({ - outputUpdateHandler: this._schedulerOutputUpdate.bind(this), - onAllToolCallsComplete: this._schedulerAllToolCallsComplete.bind(this), - onToolCallsUpdate: this._schedulerToolCallsUpdate.bind(this), - getPreferredEditor: () => DEFAULT_GUI_EDITOR, - config: this.config, - }); - return scheduler; - } - private messageBusListener?: (message: ToolCallsUpdateMessage) => void; private setupEventDrivenScheduler(): Scheduler { const messageBus = this.config.getMessageBus(); const scheduler = new Scheduler({ schedulerId: this.id, - config: this.config, + context: this.config, messageBus, getPreferredEditor: () => DEFAULT_GUI_EDITOR, }); @@ -564,9 +401,7 @@ export class Task { this.messageBusListener = undefined; } - if (this.scheduler instanceof Scheduler) { - this.scheduler.dispose(); - } + this.scheduler.dispose(); } private handleEventDrivenToolCallsUpdate( diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 229abc65c9..5b6757701d 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -106,8 +106,6 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - enableEventDrivenScheduler: - settings.experimental?.enableEventDrivenScheduler ?? true, interactive: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 0c353b46aa..da9db4e069 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -40,9 +40,6 @@ export interface Settings { general?: { previewFeatures?: boolean; }; - experimental?: { - enableEventDrivenScheduler?: boolean; - }; // Git-aware file filtering settings fileFiltering?: { diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 7262be42a8..4a883992b5 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -65,7 +65,12 @@ vi.mock('../utils/logger.js', () => ({ })); let config: Config; -const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT); +const getToolRegistrySpy = vi.fn().mockReturnValue({ + getTool: vi.fn(), + getAllToolNames: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + getToolsByServer: vi.fn().mockReturnValue([]), +}); const getApprovalModeSpy = vi.fn(); const getShellExecutionConfigSpy = vi.fn(); const getExtensionsSpy = vi.fn(); diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 4981dbbd67..f63e66e85e 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -20,6 +20,7 @@ import { tmpdir, type Config, type Storage, + type ToolRegistry, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { expect, vi } from 'vitest'; @@ -30,6 +31,10 @@ export function createMockConfig( const tmpDir = tmpdir(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { + get toolRegistry(): ToolRegistry { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (this as unknown as Config).getToolRegistry(); + }, getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), getAllToolNames: vi.fn().mockReturnValue([]), @@ -64,7 +69,6 @@ export function createMockConfig( getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'), getSessionId: vi.fn().mockReturnValue('test-session-id'), getUserTier: vi.fn(), - isEventDrivenSchedulerEnabled: vi.fn().mockReturnValue(false), getMessageBus: vi.fn(), getPolicyEngine: vi.fn(), getEnableExtensionReloading: vi.fn().mockReturnValue(false), diff --git a/packages/cli/package.json b/packages/cli/package.json index b849fb4659..648c4751e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,7 +26,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 5da4f1ed44..80c48193e2 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -157,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { if ( this.settings.security?.allowedExtensions && @@ -247,7 +248,7 @@ export class ExtensionManager extends ExtensionLoader { (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. - (await this.requestConsent( + (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, @@ -321,7 +322,7 @@ Would you like to attempt to install via "git clone" instead?`, await maybeRequestConsentOrFail( newExtensionConfig, - this.requestConsent, + requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0b4549d442..bfa2576cde 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -316,10 +316,10 @@ const SETTINGS_SCHEMA = { label: 'Retry Fetch Errors', category: 'General', requiresRestart: false, - default: false, + default: true, description: 'Retry on "exception TypeError: fetch failed sending request" errors.', - showInDialog: false, + showInDialog: true, }, maxAttempts: { type: 'number', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 331ec0c018..4e95629908 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -92,6 +92,8 @@ import { computeTerminalTitle } from './utils/windowTitle.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js'; +import { loadKeyMatchers } from './ui/key/keyMatchers.js'; import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { @@ -109,6 +111,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; +import { cleanupBackgroundLogs } from './utils/logCleanup.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; const SLOW_RENDER_MS = 200; @@ -207,6 +210,11 @@ export async function startInteractiveUI( }); } + const { matchers, errors } = await loadKeyMatchers(); + errors.forEach((error) => { + coreEvents.emitFeedback('warning', error); + }); + const version = await getVersion(); setWindowTitle(basename(workspaceRoot), settings); @@ -229,35 +237,39 @@ export async function startInteractiveUI( return ( - - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; @@ -370,6 +382,7 @@ export async function main() { await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c2cab72353..c25e452ee0 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -211,7 +211,7 @@ export async function runNonInteractive({ const geminiClient = config.getGeminiClient(); const scheduler = new Scheduler({ - config, + context: config, messageBus: config.getMessageBus(), getPreferredEditor: () => undefined, schedulerId: ROOT_SCHEDULER_ID, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index c8ab45a35d..86479dda89 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -125,7 +125,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getEnableInteractiveShell: vi.fn().mockReturnValue(false), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn().mockReturnValue(false), - getRetryFetchErrors: vi.fn().mockReturnValue(false), + getRetryFetchErrors: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), getShellExecutionConfig: vi.fn().mockReturnValue({}), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0b6eaa037b..13550d3f42 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -2770,7 +2770,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should exit copy mode on any key press', async () => { + it('should exit copy mode on non-scroll key press', async () => { await setupCopyModeTest(isAlternateMode); // Enter copy mode @@ -2792,6 +2792,61 @@ describe('AppContainer State Management', () => { unmount(); }); + it('should not exit copy mode on PageDown and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + // PageDown should be passed through to lower-priority handlers. + act(() => { + stdin.write('\x1b[6~'); + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'pagedown' }), + ); + unmount(); + }); + + it('should not exit copy mode on Shift+Down and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + act(() => { + stdin.write('\x1b[1;2B'); // Shift+Down + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'down', shift: true }), + ); + unmount(); + }); + it('should have higher priority than other priority listeners when enabled', async () => { // 1. Initial state with a child component's priority listener (already subscribed) // It should NOT handle Ctrl+S so we can enter copy mode. @@ -3145,7 +3200,7 @@ describe('AppContainer State Management', () => { }); }); - it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => { + it('preserves buffer when cancelling, even if empty (user is in control)', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); @@ -3161,7 +3216,45 @@ describe('AppContainer State Management', () => { onCancelSubmit(false); }); - expect(mockSetText).toHaveBeenCalledWith(''); + // Should NOT modify buffer when cancelling - user is in control + expect(mockSetText).not.toHaveBeenCalled(); + + unmount!(); + }); + + it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { + // Mock buffer with text that user typed while streaming (same as last message) + const promptText = 'What is Python?'; + mockedUseTextBuffer.mockReturnValue({ + text: promptText, + setText: mockSetText, + }); + + // Mock input history with same message + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: [promptText], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + act(() => { + // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) + onCancelSubmit(false); + }); + + // Should NOT call setText - prompt should be preserved regardless of content + expect(mockSetText).not.toHaveBeenCalled(); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4b9a013328..bd3b02c724 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -500,9 +500,11 @@ export const AppContainer = (props: AppContainerProps) => { disableMouseEvents(); // Kill all background shells - for (const pid of backgroundShellsRef.current.keys()) { - ShellExecutionService.kill(pid); - } + await Promise.all( + Array.from(backgroundShellsRef.current.keys()).map((pid) => + ShellExecutionService.kill(pid), + ), + ); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); @@ -1247,8 +1249,15 @@ Logging in with Google... Restarting Gemini CLI to continue. return; } + // If cancelling (shouldRestorePrompt=false), never modify the buffer + // User is in control - preserve whatever text they typed, pasted, or restored + if (!shouldRestorePrompt) { + return; + } + + // Restore the last message when shouldRestorePrompt=true const lastUserMessage = inputHistory.at(-1); - let textToSet = shouldRestorePrompt ? lastUserMessage || '' : ''; + let textToSet = lastUserMessage || ''; const queuedText = getQueuedMessagesText(); if (queuedText) { @@ -1256,7 +1265,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearQueue(); } - if (textToSet || !shouldRestorePrompt) { + if (textToSet) { buffer.setText(textToSet); } }, @@ -1894,7 +1903,18 @@ Logging in with Google... Restarting Gemini CLI to continue. useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useKeypress( - () => { + (key: Key) => { + if ( + keyMatchers[Command.SCROLL_UP](key) || + keyMatchers[Command.SCROLL_DOWN](key) || + keyMatchers[Command.PAGE_UP](key) || + keyMatchers[Command.PAGE_DOWN](key) || + keyMatchers[Command.SCROLL_HOME](key) || + keyMatchers[Command.SCROLL_END](key) + ) { + return false; + } + setCopyModeEnabled(false); enableMouseEvents(); return true; diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 89147a1b90..d1c2ede5e8 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -475,14 +475,18 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: extension.url }); // Call onSelect - component.props.onSelect?.(extension); + await component.props.onSelect?.(extension); await waitFor(() => { expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: extension.url, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); }); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); @@ -622,10 +626,14 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: packageName }); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Installing extension from "${packageName}"...`, @@ -647,10 +655,14 @@ describe('extensionsCommand', () => { await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to install extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 051d337019..6693d36b18 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -279,9 +279,9 @@ async function exploreAction( return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { + onSelect: async (extension, requestConsentOverride) => { debugLogger.log(`Selected extension: ${extension.extensionName}`); - void installAction(context, extension.url); + await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), @@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) { } } -async function installAction(context: CommandContext, args: string) { +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( @@ -505,8 +509,11 @@ async function installAction(context: CommandContext, args: string) { try { const installMetadata = await inferInstallMetadata(source); - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" installed successfully.`, diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 4d37de24c3..847dcd9a87 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -35,6 +35,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), + getLogFilePath: vi.fn( + (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, + ), + getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), }, }; }); @@ -222,7 +226,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, - 21, + 20, ); rerender( @@ -242,7 +246,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, - 27, + 26, ); unmount(); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index a2187fc2f3..bb4c1f26da 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { theme } from '../semantic-colors.js'; import { ShellExecutionService, + shortenPath, + tildeifyPath, type AnsiOutput, type AnsiLine, type AnsiToken, @@ -43,8 +45,14 @@ interface BackgroundShellDisplayProps { const CONTENT_PADDING_X = 1; const BORDER_WIDTH = 2; // Left and Right border -const HEADER_HEIGHT = 3; // 2 for border, 1 for header +const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border +const HEADER_HEIGHT = 1; +const FOOTER_HEIGHT = 1; +const TOTAL_OVERHEAD_HEIGHT = + MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; +const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom const TAB_DISPLAY_HORIZONTAL_PADDING = 4; +const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) const formatShellCommandForDisplay = (command: string, maxWidth: number) => { const commandFirstLine = command.split('\n')[0]; @@ -81,7 +89,7 @@ export const BackgroundShellDisplay = ({ if (!activePid) return; const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); - const ptyHeight = Math.max(1, height - HEADER_HEIGHT); + const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); }, [activePid, width, height]); @@ -150,7 +158,7 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - dismissBackgroundShell(highlightedPid); + void dismissBackgroundShell(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - dismissBackgroundShell(activeShell.pid); + void dismissBackgroundShell(activeShell.pid); return true; } @@ -336,7 +344,10 @@ export const BackgroundShellDisplay = ({ }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} - maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header + maxItemsToShow={Math.max( + 1, + height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, + )} renderItem={( item, { isSelected: _isSelected, titleColor: _titleColor }, @@ -383,6 +394,23 @@ export const BackgroundShellDisplay = ({ ); }; + const renderFooter = () => { + const pidToDisplay = isListOpenProp + ? (highlightedPid ?? activePid) + : activePid; + if (!pidToDisplay) return null; + const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); + const displayPath = shortenPath( + tildeifyPath(logPath), + width - LOG_PATH_OVERHEAD, + ); + return ( + + Log: {displayPath} + + ); + }; + const renderOutput = () => { const lines = typeof output === 'string' ? output.split('\n') : output; @@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({ {isListOpenProp ? renderProcessList() : renderOutput()} + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index b1f804dd42..84f8d15a06 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -831,7 +831,7 @@ describe('Composer', () => { expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); }); - it('does not show shortcuts hint immediately when buffer has text', async () => { + it('hides shortcuts hint when text is typed in buffer', async () => { const uiState = createMockUIState({ buffer: { text: 'hello' } as unknown as TextBuffer, cleanUiDetailsVisible: false, @@ -901,16 +901,6 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('hides shortcuts hint when text is typed in buffer', async () => { - const uiState = createMockUIState({ - buffer: { text: 'hello' } as unknown as TextBuffer, - }); - - const { lastFrame } = await renderComposer(uiState); - - expect(lastFrame()).not.toContain('ShortcutsHint'); - }); - it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d30f52dddf..0864b8f02b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return () => clearTimeout(timeout); }, [canShowShortcutsHint]); + const shouldReserveSpaceForShortcutsHint = + settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; const showShortcutsHint = - settings.merged.ui.showShortcutsHint && - !hideShortcutsHintForSuggestions && - showShortcutsHintDebounced; + shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; @@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || - showShortcutsHint); + shouldReserveSpaceForShortcutsHint); return ( { marginTop={isNarrow ? 1 : 0} flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} + minHeight={ + showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0 + } > {showUiDetails && showShortcutsHint && } @@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {(showMinimalContextBleedThrough || showShortcutsHint) && ( + {(showMinimalContextBleedThrough || + shouldReserveSpaceForShortcutsHint) && ( {showMinimalContextBleedThrough && ( { terminalWidth={uiState.terminalWidth} /> )} - {showShortcutsHint && ( - - - - )} + + {showShortcutsHint && } + )} diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index de7cb3a888..6f202ced4a 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -35,7 +35,8 @@ describe('CopyModeWarning', () => { const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); expect(lastFrame()).toContain('In Copy Mode'); - expect(lastFrame()).toContain('Press any key to exit'); + expect(lastFrame()).toContain('Use Page Up/Down to scroll'); + expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx index 8d5423bb89..4b6328274b 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.tsx @@ -19,7 +19,8 @@ export const CopyModeWarning: React.FC = () => { return ( - In Copy Mode. Press any key to exit. + In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key + to exit. ); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 21aa6ee5c0..ab487a440f 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -101,6 +101,12 @@ describe('