Compare commits

...

32 Commits

Author SHA1 Message Date
Jack Wotherspoon bae474d68f Merge branch 'main' into restart-resume 2026-03-11 09:16:30 +01:00
Nick Salerni 88638c14f7 fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) (#19933)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-11 07:16:25 +00:00
Krrish Verma c2d38bac54 test(core): add missing tests for prompts/utils.ts (#19941)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-11 07:00:37 +00:00
Ali Anari f16f13fba8 fix(ui): clamp cursor to last char after all NORMAL mode deletes (#21973) 2026-03-11 06:22:12 +00:00
Sehoon Shon e54c450bc1 fix(core): remove OAuth check from handleFallback and clean up stray file (#21962) 2026-03-11 04:56:06 +00:00
Tommaso Sciortino 075e0b1a81 feat(cli): support literal character keybindings and extended Kitty protocol keys (#21972) 2026-03-11 04:49:20 +00:00
Aishanee Shah f8ad3a200a Feat/retry fetch notifications (#21813) 2026-03-11 03:33:50 +00:00
Ali Anari 8b09ccc288 feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) (#21932) 2026-03-11 03:27:06 +00:00
Adam Weidman 5020d8fa57 refactor(a2a): remove legacy CoreToolScheduler (#21955) 2026-03-11 02:38:38 +00:00
gemini-cli-robot 3a0c9a0d39 chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f (#21966) 2026-03-11 02:36:40 +00:00
joshualitt 20a226a5ab feat(core): Thread AgentLoopContext through core. (#21944) 2026-03-11 01:12:59 +00:00
Tommaso Sciortino daf3701194 feat(cli): customizable keyboard shortcuts (#21945) 2026-03-11 01:05:50 +00:00
gemini-cli-robot 657f19c1f3 Changelog for v0.33.0-preview.14 (#21938)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-11 00:49:26 +00:00
Gal Zahavi 524679d23c feat: implement background process logging and cleanup (#21189) 2026-03-11 00:13:20 +00:00
Shyam Raghuwanshi 7c4570339e fix: robust UX for remote agent errors (#20307)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-03-10 23:50:25 +00:00
Jack Wotherspoon fa26a2cb56 Merge branch 'main' into restart-resume 2026-03-10 16:41:56 +01:00
Jack Wotherspoon f1974faac6 Merge branch 'main' into restart-resume 2026-03-10 14:56:33 +01:00
Jack Wotherspoon 5b6fbcd0af Merge branch 'main' into restart-resume 2026-03-10 09:22:25 +01:00
Jack Wotherspoon 2aea682c8f Merge branch 'main' into restart-resume 2026-03-10 09:21:29 +01:00
Jack Wotherspoon 89f2afef02 chore: update test 2026-03-10 09:14:22 +01:00
Jack Wotherspoon e824cb897a chore: address PR comments 2026-03-09 23:53:31 +01:00
Jack Wotherspoon 7766b36411 Merge branch 'main' into restart-resume 2026-03-09 19:13:51 +01:00
Jack Wotherspoon fa1e95b360 Merge branch 'main' into restart-resume 2026-03-09 17:27:28 +01:00
Jack Wotherspoon b3b5df96b7 Merge branch 'main' into restart-resume 2026-03-09 15:43:55 +01:00
Jack Wotherspoon 84c3ee1f05 chore: fix test 2026-03-09 15:43:22 +01:00
Jack Wotherspoon 8849319e8b chore: update test 2026-03-09 15:17:34 +01:00
Jack Wotherspoon 0b3653262c Merge branch 'main' into restart-resume 2026-03-09 14:53:46 +01:00
Jack Wotherspoon fff88e794d chore: fix test 2026-03-09 14:49:21 +01:00
Jack Wotherspoon 395af2a1e4 chore: update restart to use env var 2026-03-09 14:24:20 +01:00
Jack Wotherspoon a45e42cf30 chore: simplify naming 2026-03-09 13:48:57 +01:00
Jack Wotherspoon 0d2ad092ec chore: only warn on empty sessions 2026-03-09 13:22:17 +01:00
Jack Wotherspoon 72068d40b6 feat(cli): resume session after CLI restart/relaunch 2026-03-09 12:05:55 +01:00
124 changed files with 4600 additions and 1234 deletions
+5 -2
View File
@@ -1,4 +1,4 @@
# Preview release: v0.33.0-preview.13
# Preview release: v0.33.0-preview.14
Released: March 10, 2026
@@ -29,6 +29,9 @@ 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
@@ -202,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.13
https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.14
+1
View File
@@ -30,6 +30,7 @@ they appear in the UI.
| 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` |
| 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` |
+1 -1
View File
@@ -146,7 +146,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
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.35.0-nightly.20260311.657f19c1f",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"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.20260310.4653b126f",
"version": "0.35.0-nightly.20260311.657f19c1f",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"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.20260310.4653b126f"
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.35.0-nightly.20260311.657f19c1f",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+1 -220
View File
@@ -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<typeof vi.spyOn>;
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();
+5 -170
View File
@@ -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> = 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<string, ToolCallConfirmationDetails>;
@@ -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<void> {
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(
-2
View File
@@ -106,8 +106,6 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler ?? true,
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
@@ -40,9 +40,6 @@ export interface Settings {
general?: {
previewFeatures?: boolean;
};
experimental?: {
enableEventDrivenScheduler?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
+6 -1
View File
@@ -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();
@@ -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),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.34.0-nightly.20260310.4653b126f",
"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.20260310.4653b126f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+2 -2
View File
@@ -306,10 +306,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',
+75 -41
View File
@@ -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 (
<SettingsContext.Provider value={settings}>
<KeypressProvider
config={config}
debugKeystrokeLogging={settings.merged.general.debugKeystrokeLogging}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</OverflowProvider>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</KeyMatchersProvider>
</SettingsContext.Provider>
);
};
@@ -370,6 +382,7 @@ export async function main() {
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
@@ -693,12 +706,19 @@ export async function main() {
})),
];
// Handle --resume flag
// Handle session resume — either from explicit --resume flag or from
// auto-restart via GEMINI_RESUME_SESSION_ID env var.
const resumeArg = argv.resume ?? process.env['GEMINI_RESUME_SESSION_ID'];
const isAutoRestart =
!argv.resume && !!process.env['GEMINI_RESUME_SESSION_ID'];
// Clean up the env var so it doesn't leak to further restarts
delete process.env['GEMINI_RESUME_SESSION_ID'];
let resumedSessionData: ResumedSessionData | undefined = undefined;
if (argv.resume) {
if (resumeArg) {
const sessionSelector = new SessionSelector(config);
try {
const result = await sessionSelector.resolveSession(argv.resume);
const result = await sessionSelector.resolveSession(resumeArg);
resumedSessionData = {
conversation: result.sessionData,
filePath: result.sessionPath,
@@ -706,14 +726,18 @@ export async function main() {
// Use the existing session ID to continue recording to the same session
config.setSessionId(resumedSessionData.conversation.sessionId);
} catch (error) {
if (
if (error instanceof SessionError && isAutoRestart) {
// Auto-restart tried to resume a session that doesn't exist on
// disk yet (e.g. empty session with no messages). Silently start
// a new session.
} else if (
error instanceof SessionError &&
error.code === 'NO_SESSIONS_FOUND'
) {
// No sessions to resume — start a fresh session with a warning
startupWarnings.push({
id: 'resume-no-sessions',
message: error.message,
id: 'resume-failure',
message: `${error.message} Started a new session.`,
priority: WarningPriority.High,
});
} else {
@@ -727,6 +751,14 @@ export async function main() {
}
}
// When this is an auto-restart (not explicit --resume), clear the
// original --prompt so it doesn't get submitted again — the resumed
// session already contains it.
if (isAutoRestart && resumedSessionData) {
config.clearQuestion();
input = undefined;
}
cliStartupHandle?.end();
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
@@ -782,7 +814,7 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
if (!input) {
if (!input && !resumedSessionData) {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
);
@@ -791,15 +823,17 @@ export async function main() {
}
const prompt_id = Math.random().toString(16).slice(2);
logUserPrompt(
config,
new UserPromptEvent(
input.length,
prompt_id,
config.getContentGeneratorConfig()?.authType,
input,
),
);
if (input) {
logUserPrompt(
config,
new UserPromptEvent(
input.length,
prompt_id,
config.getContentGeneratorConfig()?.authType,
input,
),
);
}
const authType = await validateNonInteractiveAuth(
settings.merged.security.auth.selectedType,
+52 -46
View File
@@ -50,7 +50,7 @@ import { TextOutput } from './ui/utils/textOutput.js';
interface RunNonInteractiveParams {
config: Config;
settings: LoadedSettings;
input: string;
input?: string;
prompt_id: string;
resumedSessionData?: ResumedSessionData;
}
@@ -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,
@@ -237,56 +237,62 @@ export async function runNonInteractive({
});
}
let query: Part[] | undefined;
let currentMessages: Content[] = [];
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
abortController,
config,
settings,
);
// If a slash command is found and returns a prompt, use it.
// Otherwise, slashCommandResult falls through to the default prompt
// handling.
if (slashCommandResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = slashCommandResult as Part[];
}
}
// When resuming with no new input (e.g. auto-restart), skip sending
// a new user message — the resumed session already contains it.
if (input) {
let query: Part[] | undefined;
if (!query) {
const { processedQuery, error } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (error || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
error || 'Exiting due to an error processing the @ command.',
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
abortController,
config,
settings,
);
// If a slash command is found and returns a prompt, use it.
// Otherwise, slashCommandResult falls through to the default prompt
// handling.
if (slashCommandResult) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = slashCommandResult as Part[];
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = processedQuery as Part[];
}
// Emit user message event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'user',
content: input,
});
}
if (!query) {
const { processedQuery, error } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
let currentMessages: Content[] = [{ role: 'user', parts: query }];
if (error || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
error || 'Exiting due to an error processing the @ command.',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
query = processedQuery as Part[];
}
// Emit user message event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'user',
content: input,
});
}
currentMessages = [{ role: 'user', parts: query }];
}
let turnCount = 0;
while (true) {
+1 -1
View File
@@ -125,7 +125,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): 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({}),
+56 -1
View File
@@ -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.
+19 -6
View File
@@ -473,9 +473,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();
@@ -794,7 +796,7 @@ export const AppContainer = (props: AppContainerProps) => {
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
await relaunchApp();
await relaunchApp(config.getSessionId());
}
}
setAuthState(AuthState.Authenticated);
@@ -1862,7 +1864,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;
@@ -2474,7 +2487,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
}
}
await relaunchApp();
await relaunchApp(config.getSessionId());
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
@@ -85,6 +85,7 @@ describe('AuthDialog', () => {
props = {
config: {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
settings: {
merged: {
@@ -357,6 +358,13 @@ describe('AuthDialog', () => {
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
const originalSend = process.send;
process.send = vi.fn(
(_msg: unknown, callback?: (err: Error | null) => void) => {
if (callback) callback(null);
return true;
},
);
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
@@ -376,6 +384,7 @@ describe('AuthDialog', () => {
exitSpy.mockRestore();
logSpy.mockRestore();
process.send = originalSend;
vi.useRealTimers();
unmount();
});
+1 -1
View File
@@ -132,7 +132,7 @@ export function AuthDialog({
config.isBrowserLaunchSuppressed()
) {
setExiting(true);
setTimeout(relaunchApp, 100);
setTimeout(() => relaunchApp(config.getSessionId()), 100);
return;
}
@@ -5,7 +5,15 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { runExitCleanup } from '../../utils/cleanup.js';
@@ -35,13 +43,27 @@ describe('LoginWithGoogleRestartDialog', () => {
const mockConfig = {
getRemoteAdminSettings: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
} as unknown as Config;
let originalSend: typeof process.send;
beforeEach(() => {
vi.clearAllMocks();
exitSpy.mockClear();
vi.useRealTimers();
_resetRelaunchStateForTesting();
originalSend = process.send;
process.send = vi.fn((_message, callback) => {
if (typeof callback === 'function') {
callback(null);
}
return true;
});
});
afterEach(() => {
process.send = originalSend;
});
it('renders correctly', async () => {
@@ -35,7 +35,7 @@ export const LoginWithGoogleRestartDialog = ({
});
}
}
await relaunchApp();
await relaunchApp(config.getSessionId());
}, 100);
return true;
}
@@ -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('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
20,
);
rerender(
@@ -242,7 +246,7 @@ describe('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
26,
);
unmount();
});
@@ -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 (
<Box paddingX={1}>
<Text color={theme.text.secondary}>Log: {displayPath}</Text>
</Box>
);
};
const renderOutput = () => {
const lines = typeof output === 'string' ? output.split('\n') : output;
@@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
{renderFooter()}
</Box>
);
};
@@ -35,7 +35,8 @@ describe('CopyModeWarning', () => {
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
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();
});
});
@@ -19,7 +19,8 @@ export const CopyModeWarning: React.FC = () => {
return (
<Box>
<Text color={theme.status.warning}>
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.
</Text>
</Box>
);
@@ -230,7 +230,7 @@ export const DialogManager = ({
<Box flexDirection="column">
<SettingsDialog
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={relaunchApp}
onRestartRequest={() => relaunchApp(config.getSessionId())}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
</Box>
@@ -18,6 +18,7 @@ import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
ExitCodes,
type FolderDiscoveryResults,
@@ -45,6 +46,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
isRestarting,
discoveryResults,
}) => {
const config = useConfig();
const [exiting, setExiting] = useState(false);
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
@@ -54,12 +56,12 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isRestarting) {
timer = setTimeout(relaunchApp, 250);
timer = setTimeout(() => relaunchApp(config.getSessionId()), 250);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isRestarting]);
}, [isRestarting, config]);
const handleExit = useCallback(() => {
setExiting(true);
+32 -39
View File
@@ -101,6 +101,12 @@ describe('<Footer />', () => {
beforeEach(() => {
const root = path.parse(process.cwd()).root;
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders the component', async () => {
@@ -427,15 +433,6 @@ describe('<Footer />', () => {
});
describe('footer configuration filtering (golden snapshots)', () => {
beforeEach(() => {
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
@@ -459,23 +456,21 @@ describe('<Footer />', () => {
});
it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
'footer-minimal',
);
@@ -797,21 +792,19 @@ describe('<Footer />', () => {
});
it('handles empty items array', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));
const output = lastFrame({ allowEmpty: true });
expect(output).toBeDefined();
@@ -8,6 +8,7 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -16,11 +17,12 @@ interface IdeTrustChangeDialogProps {
}
export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
const config = useConfig();
useKeypress(
(key) => {
if (key.name === 'r' || key.name === 'R') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
relaunchApp();
relaunchApp(config.getSessionId());
return true;
}
return false;
@@ -410,6 +410,7 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
expect(output).toMatchSnapshot();
unmount();
});
@@ -11,6 +11,7 @@ import * as path from 'node:path';
import { TrustLevel } from '../../config/trustedFolders.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js';
@@ -33,6 +34,7 @@ export function PermissionsModifyTrustDialog({
const currentDirectory = targetDirectory ?? process.cwd();
const dirName = path.basename(currentDirectory);
const parentFolder = path.basename(path.dirname(currentDirectory));
const config = useConfig();
const TRUST_LEVEL_ITEMS = [
{
@@ -72,7 +74,7 @@ export function PermissionsModifyTrustDialog({
void (async () => {
const success = await commitTrustLevelChange();
if (success) {
void relaunchApp();
void relaunchApp(config.getSessionId());
} else {
onExit();
}
@@ -5,6 +5,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -19,6 +20,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
│ Log: ~/.gemini/tmp/background-processes/background-1003.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -27,6 +29,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -35,6 +38,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -48,6 +52,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -61,6 +66,7 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
│ Log: ~/.gemini/tmp/background-processes/background-1002.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
@@ -165,6 +165,29 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Auto (Gemini 3) Stats For Nerds │
│ │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 200ms 50ms │
│ Tokens │
│ Total 6,000 12,000 │
│ ↳ Input 1,000 2,000 │
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="819" y="512" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">false*</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -83,20 +83,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -94,20 +94,20 @@
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Debug Keystroke Logging</text>
<text x="828" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">true*</text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="450" lengthAdjust="spacingAndGlyphs">Enable debug logging of keystrokes to the console.</text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -28,12 +28,12 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -74,12 +74,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -120,12 +120,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,12 +166,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -212,12 +212,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -258,12 +258,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -304,12 +304,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -350,12 +350,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -396,12 +396,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -1703,6 +1703,25 @@ export type TextBufferAction =
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line'; payload: { count: number } }
| { type: 'vim_delete_to_last_line'; payload: { count: number } }
| { type: 'vim_delete_char_before'; payload: { count: number } }
| { type: 'vim_toggle_case'; payload: { count: number } }
| { type: 'vim_replace_char'; payload: { char: string; count: number } }
| {
type: 'vim_find_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_find_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'toggle_paste_expansion';
payload: { id: string; row: number; col: number };
@@ -2484,6 +2503,13 @@ function textBufferReducerLogic(
case 'vim_change_to_first_nonwhitespace':
case 'vim_delete_to_first_line':
case 'vim_delete_to_last_line':
case 'vim_delete_char_before':
case 'vim_toggle_case':
case 'vim_replace_char':
case 'vim_find_char_forward':
case 'vim_find_char_backward':
case 'vim_delete_to_char_forward':
case 'vim_delete_to_char_backward':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
@@ -3043,6 +3069,58 @@ export function useTextBuffer({
dispatch({ type: 'vim_delete_char', payload: { count } });
}, []);
const vimDeleteCharBefore = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_char_before', payload: { count } });
}, []);
const vimToggleCase = useCallback((count: number): void => {
dispatch({ type: 'vim_toggle_case', payload: { count } });
}, []);
const vimReplaceChar = useCallback((char: string, count: number): void => {
dispatch({ type: 'vim_replace_char', payload: { char, count } });
}, []);
const vimFindCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimFindCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimInsertAtCursor = useCallback((): void => {
dispatch({ type: 'vim_insert_at_cursor' });
}, []);
@@ -3542,6 +3620,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3630,6 +3715,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3937,6 +4029,20 @@ export interface TextBuffer {
* Delete N characters at cursor (vim 'x' command)
*/
vimDeleteChar: (count: number) => void;
/** Delete N characters before cursor (vim 'X') */
vimDeleteCharBefore: (count: number) => void;
/** Toggle case of N characters at cursor (vim '~') */
vimToggleCase: (count: number) => void;
/** Replace N characters at cursor with char, stay in NORMAL mode (vim 'r') */
vimReplaceChar: (char: string, count: number) => void;
/** Move to Nth occurrence of char forward on line; till=true stops before it (vim 'f'/'t') */
vimFindCharForward: (char: string, count: number, till: boolean) => void;
/** Move to Nth occurrence of char backward on line; till=true stops after it (vim 'F'/'T') */
vimFindCharBackward: (char: string, count: number, till: boolean) => void;
/** Delete from cursor to Nth occurrence of char forward; till=true excludes the char (vim 'df'/'dt') */
vimDeleteToCharForward: (char: string, count: number, till: boolean) => void;
/** Delete from Nth occurrence of char backward to cursor; till=true excludes the char (vim 'dF'/'dT') */
vimDeleteToCharBackward: (char: string, count: number, till: boolean) => void;
/**
* Enter insert mode at cursor (vim 'i' command)
*/
@@ -572,6 +572,21 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel');
// Cursor clamps to last char of the shortened line (vim NORMAL mode
// cursor cannot rest past the final character).
expect(result.cursorCol).toBe(2);
});
it('should clamp cursor when deleting the last character on a line', () => {
const state = createTestState(['hello'], 0, 4);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hell');
expect(result.cursorCol).toBe(3);
});
@@ -626,7 +641,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
expect(result.cursorCol).toBe(5);
});
it('should delete only the word characters if it is the last word followed by whitespace', () => {
@@ -666,6 +681,55 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('foo ');
});
it('should clamp cursor when dW removes the last word leaving only a trailing space', () => {
// cursor on 'w' in 'hello world'; dW deletes 'world' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_forward' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
// col 6 is past the new line end (len 6, max valid = 5)
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_end', () => {
it('should clamp cursor when de removes the last word on a line', () => {
// cursor on 'w' in 'hello world'; de deletes through 'd' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_big_word_end', () => {
it('should delete from cursor to end of WORD (skipping punctuation)', () => {
// cursor on 'b' in 'foo bar.baz qux'; dE treats 'bar.baz' as one WORD
const state = createTestState(['foo bar.baz qux'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('foo qux');
expect(result.cursorCol).toBe(4);
});
it('should clamp cursor when dE removes the last WORD on a line', () => {
// cursor on 'w' in 'hello world'; dE deletes through 'd' → 'hello '
const state = createTestState(['hello world'], 0, 6);
const result = handleVimAction(state, {
type: 'vim_delete_big_word_end' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_backward', () => {
@@ -751,7 +815,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
expect(result.cursorCol).toBe(4);
});
it('should do nothing at end of line', () => {
@@ -781,7 +845,7 @@ describe('vim-buffer-actions', () => {
expect(result).toHaveOnlyValidCharacters();
// 2D at position 5 on "line one" should delete "one" + entire "line two"
expect(result.lines).toEqual(['line ', 'line three']);
expect(result.cursorCol).toBe(5);
expect(result.cursorCol).toBe(4);
});
it('should handle count exceeding available lines', () => {
@@ -1727,4 +1791,440 @@ describe('vim-buffer-actions', () => {
});
});
});
describe('Character manipulation commands (X, ~, r, f/F/t/T)', () => {
describe('vim_delete_char_before (X)', () => {
it('should delete the character before the cursor', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('helo');
expect(result.cursorCol).toBe(2);
});
it('should delete N characters before the cursor', () => {
const state = createTestState(['hello world'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 3 },
});
expect(result.lines[0]).toBe('he world');
expect(result.cursorCol).toBe(2);
});
it('should clamp to start of line when count exceeds position', () => {
const state = createTestState(['hello'], 0, 2);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 10 },
});
expect(result.lines[0]).toBe('llo');
expect(result.cursorCol).toBe(0);
});
it('should do nothing when cursor is at column 0', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_toggle_case (~)', () => {
it('should toggle lowercase to uppercase', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('Hello');
expect(result.cursorCol).toBe(1);
});
it('should toggle uppercase to lowercase', () => {
const state = createTestState(['HELLO'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hELLO');
expect(result.cursorCol).toBe(1);
});
it('should toggle N characters', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 5 },
});
expect(result.lines[0]).toBe('HELLO world');
expect(result.cursorCol).toBe(5); // cursor advances past the toggled range
});
it('should clamp count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 100 },
});
expect(result.lines[0]).toBe('hI');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_replace_char (r)', () => {
it('should replace the character under the cursor', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'a', count: 1 },
});
expect(result.lines[0]).toBe('hallo');
expect(result.cursorCol).toBe(1);
});
it('should replace N characters with the given char', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 3 },
});
expect(result.lines[0]).toBe('hxxxo');
expect(result.cursorCol).toBe(3); // cursor at last replaced char
});
it('should clamp replace count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 100 },
});
expect(result.lines[0]).toBe('hz');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
type FindActionCase = {
label: string;
type: 'vim_find_char_forward' | 'vim_find_char_backward';
cursorStart: number;
char: string;
count: number;
till: boolean;
expectedCol: number;
};
it.each<FindActionCase>([
{
label: 'f: move to char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'f: Nth occurrence',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 2,
till: false,
expectedCol: 7,
},
{
label: 't: move before char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: true,
expectedCol: 3,
},
{
label: 'f: not found',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'z',
count: 1,
till: false,
expectedCol: 0,
},
{
label: 'f: skip char at cursor',
type: 'vim_find_char_forward',
cursorStart: 1,
char: 'h',
count: 1,
till: false,
expectedCol: 1,
},
{
label: 'F: move to char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: false,
expectedCol: 7,
},
{
label: 'F: Nth occurrence',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 2,
till: false,
expectedCol: 4,
},
{
label: 'T: move after char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: true,
expectedCol: 8,
},
{
label: 'F: not found',
type: 'vim_find_char_backward',
cursorStart: 4,
char: 'z',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'F: skip char at cursor',
type: 'vim_find_char_backward',
cursorStart: 3,
char: 'o',
count: 1,
till: false,
expectedCol: 3,
},
])('$label', ({ type, cursorStart, char, count, till, expectedCol }) => {
const line =
type === 'vim_find_char_forward' ? ['hello world'] : ['hello world'];
const state = createTestState(line, 0, cursorStart);
const result = handleVimAction(state, {
type,
payload: { char, count, till },
});
expect(result.cursorCol).toBe(expectedCol);
});
});
describe('Unicode character support in find operations', () => {
it('vim_find_char_forward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_find_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3); // 'c','a','f','é' — é is at index 3
expect(result.lines[0]).toBe('café world');
});
it('vim_find_char_backward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 9);
const result = handleVimAction(state, {
type: 'vim_find_char_backward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3);
});
it('vim_delete_to_char_forward: handles multi-byte target char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
// Deletes 'caf' + 'é' → ' world' remains
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('vim_delete_to_char_forward (till): stops before multi-byte char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: true },
});
// Deletes 'caf', keeps 'é world'
expect(result.lines[0]).toBe('é world');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_char_forward (df/dt)', () => {
it('df: deletes from cursor through found char (inclusive)', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('dt: deletes from cursor up to (not including) found char', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: true },
});
expect(result.lines[0]).toBe('o world');
expect(result.cursorCol).toBe(0);
});
it('df with count: deletes to Nth occurrence', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 2, till: false },
});
expect(result.lines[0]).toBe('rld');
expect(result.cursorCol).toBe(0);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
it('df: clamps cursor when deleting through the last char on the line', () => {
// cursor at 1 in 'hello'; dfo finds 'o' at col 4 and deletes [1,4] → 'h'
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.lines[0]).toBe('h');
// cursor was at col 1, new line has only col 0 valid
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_char_backward (dF/dT)', () => {
it('dF: deletes from found char through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
// cursor at 7 ('o' in world), dFo finds 'o' at col 4
// delete [4, 8) — both ends inclusive → 'hell' + 'rld'
expect(result.lines[0]).toBe('hellrld');
expect(result.cursorCol).toBe(4);
});
it('dT: deletes from found+1 through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: true },
});
// dTo finds 'o' at col 4, deletes [5, 8) → 'hello' + 'rld'
expect(result.lines[0]).toBe('hellorld');
expect(result.cursorCol).toBe(5);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(4);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
it('dF: clamps cursor when deletion removes chars up to end of line', () => {
// 'hello', cursor on last char 'o' (col 4), dFe finds 'e' at col 1
// deletes [1, 5) → 'h'; without clamp cursor would be at col 1 (past end)
const state = createTestState(['hello'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'e', count: 1, till: false },
});
expect(result.lines[0]).toBe('h');
expect(result.cursorCol).toBe(0);
});
});
});
@@ -24,6 +24,13 @@ import { assumeExhaustive } from '@google/gemini-cli-core';
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_char_before' }
| { type: 'vim_toggle_case' }
| { type: 'vim_replace_char' }
| { type: 'vim_find_char_forward' }
| { type: 'vim_find_char_backward' }
| { type: 'vim_delete_to_char_forward' }
| { type: 'vim_delete_to_char_backward' }
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
@@ -73,6 +80,49 @@ export type VimAction = Extract<
| { type: 'vim_escape_insert_mode' }
>;
/**
* Find the Nth occurrence of `char` in `codePoints`, starting at `start` and
* stepping by `direction` (+1 forward, -1 backward). Returns the index or -1.
*/
function findCharInLine(
codePoints: string[],
char: string,
count: number,
start: number,
direction: 1 | -1,
): number {
let found = -1;
let hits = 0;
for (
let i = start;
direction === 1 ? i < codePoints.length : i >= 0;
i += direction
) {
if (codePoints[i] === char) {
hits++;
if (hits >= count) {
found = i;
break;
}
}
}
return found;
}
/**
* In NORMAL mode the cursor can never rest past the last character of a line.
* Call this after any delete action that stays in NORMAL mode to enforce that
* invariant. Change actions must NOT use this they immediately enter INSERT
* mode where the cursor is allowed to sit at the end of the line.
*/
function clampNormalCursor(state: TextBufferState): TextBufferState {
const line = state.lines[state.cursorRow] || '';
const len = cpLen(line);
const maxCol = Math.max(0, len - 1);
if (state.cursorCol <= maxCol) return state;
return { ...state, cursorCol: maxCol };
}
export function handleVimAction(
state: TextBufferState,
action: VimAction,
@@ -107,7 +157,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -115,6 +165,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_word_forward'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -149,7 +202,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -157,6 +210,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_big_word_forward'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -262,7 +318,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -270,6 +326,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_word_end'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -315,7 +374,7 @@ export function handleVimAction(
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -323,6 +382,9 @@ export function handleVimAction(
endCol,
'',
);
return action.type === 'vim_delete_big_word_end'
? clampNormalCursor(newState)
: newState;
}
return state;
}
@@ -396,12 +458,13 @@ export function handleVimAction(
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
const totalLines = lines.length;
const isDelete = action.type === 'vim_delete_to_end_of_line';
if (count === 1) {
// Single line: delete from cursor to end of current line
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -409,6 +472,7 @@ export function handleVimAction(
cpLen(currentLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
return state;
} else {
@@ -421,7 +485,7 @@ export function handleVimAction(
// No additional lines to delete, just delete to EOL
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -429,6 +493,7 @@ export function handleVimAction(
cpLen(currentLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
return state;
}
@@ -436,7 +501,7 @@ export function handleVimAction(
// Delete from cursor position to end of endRow (including newlines)
const nextState = detachExpandedPaste(pushUndo(state));
const endLine = lines[endRow] || '';
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -444,6 +509,7 @@ export function handleVimAction(
cpLen(endLine),
'',
);
return isDelete ? clampNormalCursor(newState) : newState;
}
}
@@ -999,7 +1065,7 @@ export function handleVimAction(
if (cursorCol < lineLength) {
const deleteCount = Math.min(count, lineLength - cursorCol);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
@@ -1007,6 +1073,7 @@ export function handleVimAction(
cursorCol + deleteCount,
'',
);
return clampNormalCursor(newState);
}
return state;
}
@@ -1183,6 +1250,157 @@ export function handleVimAction(
};
}
case 'vim_delete_char_before': {
const { count } = action.payload;
if (cursorCol > 0) {
const deleteStart = Math.max(0, cursorCol - count);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
deleteStart,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_toggle_case': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const end = Math.min(cursorCol + count, lineLen);
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol; i < end; i++) {
const ch = codePoints[i];
const upper = ch.toUpperCase();
const lower = ch.toLowerCase();
codePoints[i] = ch === upper ? lower : upper;
}
const newLine = codePoints.join('');
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines[cursorRow] = newLine;
const newCol = Math.min(end, lineLen > 0 ? lineLen - 1 : 0);
return {
...nextState,
lines: newLines,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_replace_char': {
const { char, count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const replaceCount = Math.min(count, lineLen - cursorCol);
const replacement = char.repeat(replaceCount);
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cursorCol + replaceCount,
replacement,
);
return {
...resultState,
cursorCol: cursorCol + replaceCount - 1,
preferredCol: null,
};
}
case 'vim_delete_to_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const endCol = till ? found : found + 1;
const nextState = detachExpandedPaste(pushUndo(state));
return clampNormalCursor(
replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
endCol,
'',
),
);
}
case 'vim_delete_to_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const startCol = till ? found + 1 : found;
const endCol = cursorCol + 1; // inclusive: cursor char is part of the deletion
if (startCol >= endCol) return state;
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
return clampNormalCursor({
...resultState,
cursorCol: startCol,
preferredCol: null,
});
}
case 'vim_find_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const newCol = till ? Math.max(cursorCol, found - 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
case 'vim_find_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const newCol = till ? Math.min(cursorCol, found + 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
default: {
// This should never happen if TypeScript is working correctly
assumeExhaustive(action);
@@ -637,6 +637,9 @@ describe('KeypressContext', () => {
describe('Parameterized functional keys', () => {
it.each([
// CSI-u numeric keys
{ sequence: `\x1b[53;5u`, expected: { name: '5', ctrl: true } },
{ sequence: `\x1b[51;2u`, expected: { name: '3', shift: true } },
// ModifyOtherKeys
{ sequence: `\x1b[27;2;13~`, expected: { name: 'enter', shift: true } },
{ sequence: `\x1b[27;5;13~`, expected: { name: 'enter', ctrl: true } },
@@ -665,6 +668,14 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[17~`, expected: { name: 'f6' } },
{ sequence: `\x1b[23~`, expected: { name: 'f11' } },
{ sequence: `\x1b[24~`, expected: { name: 'f12' } },
{ sequence: `\x1b[25~`, expected: { name: 'f13' } },
{ sequence: `\x1b[34~`, expected: { name: 'f20' } },
// Kitty Extended Function Keys (F13-F35)
{ sequence: `\x1b[302u`, expected: { name: 'f13' } },
{ sequence: `\x1b[324u`, expected: { name: 'f35' } },
// Modifier / Special Keys (Kitty Protocol)
{ sequence: `\x1b[57358u`, expected: { name: 'capslock' } },
{ sequence: `\x1b[57362u`, expected: { name: 'pausebreak' } },
// Reverse tabs
{ sequence: `\x1b[Z`, expected: { name: 'tab', shift: true } },
{ sequence: `\x1b[1;2Z`, expected: { name: 'tab', shift: true } },
@@ -820,6 +831,20 @@ describe('KeypressContext', () => {
sequence: '\x1bOn',
expected: { name: '.', sequence: '.', insertable: true },
},
// Kitty Numpad Support (CSI-u)
{
sequence: '\x1b[57404u',
expected: { name: 'numpad5', sequence: '5', insertable: true },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57404;5u',
expected: { name: 'numpad5', ctrl: true, insertable: false },
},
{
sequence: '\x1b[57411u',
expected: { name: 'numpad_multiply', sequence: '*', insertable: true },
},
])(
'should recognize numpad sequence "$sequence" as $expected.name',
({ sequence, expected }) => {
@@ -66,6 +66,14 @@ const KEY_INFO_MAP: Record<
'[21~': { name: 'f10' },
'[23~': { name: 'f11' },
'[24~': { name: 'f12' },
'[25~': { name: 'f13' },
'[26~': { name: 'f14' },
'[28~': { name: 'f15' },
'[29~': { name: 'f16' },
'[31~': { name: 'f17' },
'[32~': { name: 'f18' },
'[33~': { name: 'f19' },
'[34~': { name: 'f20' },
'[A': { name: 'up' },
'[B': { name: 'down' },
'[C': { name: 'right' },
@@ -91,12 +99,6 @@ const KEY_INFO_MAP: Record<
OZ: { name: 'tab', shift: true }, // SS3 Shift+Tab variant for Windows terminals
'[[5~': { name: 'pageup' },
'[[6~': { name: 'pagedown' },
'[9u': { name: 'tab' },
'[13u': { name: 'enter' },
'[27u': { name: 'escape' },
'[32u': { name: 'space' },
'[127u': { name: 'backspace' },
'[57414u': { name: 'enter' }, // Numpad Enter
'[a': { name: 'up', shift: true },
'[b': { name: 'down', shift: true },
'[c': { name: 'right', shift: true },
@@ -122,6 +124,46 @@ const KEY_INFO_MAP: Record<
'[8^': { name: 'end', ctrl: true },
};
// Kitty Keyboard Protocol (CSI u) code mappings
const KITTY_CODE_MAP: Record<number, { name: string; sequence?: string }> = {
2: { name: 'insert' },
3: { name: 'delete' },
5: { name: 'pageup' },
6: { name: 'pagedown' },
9: { name: 'tab' },
13: { name: 'enter' },
14: { name: 'up' },
15: { name: 'down' },
16: { name: 'right' },
17: { name: 'left' },
27: { name: 'escape' },
32: { name: 'space', sequence: ' ' },
127: { name: 'backspace' },
57358: { name: 'capslock' },
57359: { name: 'scrolllock' },
57360: { name: 'numlock' },
57361: { name: 'printscreen' },
57362: { name: 'pausebreak' },
57409: { name: 'numpad_decimal', sequence: '.' },
57410: { name: 'numpad_divide', sequence: '/' },
57411: { name: 'numpad_multiply', sequence: '*' },
57412: { name: 'numpad_subtract', sequence: '-' },
57413: { name: 'numpad_add', sequence: '+' },
57414: { name: 'enter' },
57416: { name: 'numpad_separator', sequence: ',' },
// Function keys F13-F35, not standard, but supported by Kitty
...Object.fromEntries(
Array.from({ length: 23 }, (_, i) => [302 + i, { name: `f${13 + i}` }]),
),
// Numpad keys in Numeric Keypad Mode (CSI u codes 57399-57408)
...Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [
57399 + i,
{ name: `numpad${i}`, sequence: String(i) },
]),
),
};
// Numpad keys in Application Keypad Mode (SS3 sequences)
const NUMPAD_MAP: Record<string, string> = {
Oj: '*',
@@ -565,17 +607,24 @@ function* emitKeys(
}
} else {
name = 'undefined';
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
codeNumber >= 'a'.charCodeAt(0) &&
codeNumber <= 'z'.charCodeAt(0)
) {
name = String.fromCharCode(codeNumber);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
}
}
}
}
@@ -80,7 +80,7 @@ export interface UIActions {
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
dismissBackgroundShell: (pid: number) => Promise<void>;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
@@ -830,8 +830,8 @@ describe('useShellCommandProcessor', () => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.dismissBackgroundShell(1001);
await act(async () => {
await result.current.dismissBackgroundShell(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
@@ -936,8 +936,8 @@ describe('useShellCommandProcessor', () => {
expect(shell?.exitCode).toBe(1);
// Now dismiss it
act(() => {
result.current.dismissBackgroundShell(999);
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
@@ -205,11 +205,11 @@ export const useShellCommandProcessor = (
}, [state.activeShellPtyId, activeToolPtyId, m]);
const dismissBackgroundShell = useCallback(
(pid: number) => {
async (pid: number) => {
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
ShellExecutionService.kill(pid);
await ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import type { KeyMatchers } from '../key/keyMatchers.js';
import { defaultKeyMatchers } from '../key/keyMatchers.js';
/**
* Hook to retrieve the currently active key matchers.
* This prepares the codebase for dynamic or custom key bindings in the future.
*/
export function useKeyMatchers(): KeyMatchers {
return useMemo(() => defaultKeyMatchers, []);
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { createContext, useContext } from 'react';
import type { KeyMatchers } from '../key/keyMatchers.js';
import { defaultKeyMatchers } from '../key/keyMatchers.js';
export const KeyMatchersContext =
createContext<KeyMatchers>(defaultKeyMatchers);
export const KeyMatchersProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: KeyMatchers;
}): React.JSX.Element => (
<KeyMatchersContext.Provider value={value}>
{children}
</KeyMatchersContext.Provider>
);
/**
* Hook to retrieve the currently active key matchers.
* Defaults to defaultKeyMatchers if no provider is present, allowing tests to run without explicit wrappers.
*/
export function useKeyMatchers(): KeyMatchers {
return useContext(KeyMatchersContext);
}
@@ -97,7 +97,7 @@ export function useToolScheduler(
const scheduler = useMemo(
() =>
new Scheduler({
config,
context: config,
messageBus,
getPreferredEditor: () => getPreferredEditorRef.current(),
schedulerId: ROOT_SCHEDULER_ID,
+451 -3
View File
@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import type React from 'react';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
@@ -166,6 +174,13 @@ describe('useVim hook', () => {
vimChangeBigWordBackward: vi.fn(),
vimChangeBigWordEnd: vi.fn(),
vimDeleteChar: vi.fn(),
vimDeleteCharBefore: vi.fn(),
vimToggleCase: vi.fn(),
vimReplaceChar: vi.fn(),
vimFindCharForward: vi.fn(),
vimFindCharBackward: vi.fn(),
vimDeleteToCharForward: vi.fn(),
vimDeleteToCharBackward: vi.fn(),
vimInsertAtCursor: vi.fn(),
vimAppendAtCursor: vi.fn().mockImplementation(() => {
// Append moves cursor right (vim 'a' behavior - position after current char)
@@ -1029,9 +1044,10 @@ describe('useVim hook', () => {
});
// Should delete "world" (no trailing space at end), leaving "hello "
// Cursor clamps to last valid index in NORMAL mode (col 5 = trailing space)
expect(result.lines).toEqual(['hello ']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(6);
expect(result.cursorCol).toBe(5);
});
it('should delete multiple words with count', () => {
@@ -1711,7 +1727,8 @@ describe('useVim hook', () => {
count: 1,
expectedLines: ['hello '],
expectedCursorRow: 0,
expectedCursorCol: 6,
// Cursor clamps to last valid index in NORMAL mode (col 5 = trailing space)
expectedCursorCol: 5,
},
{
command: 'D',
@@ -1939,4 +1956,435 @@ describe('useVim hook', () => {
expect(handled!).toBe(false);
});
});
describe('Character deletion and case toggle (X, ~)', () => {
it('X: should call vimDeleteCharBefore', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(1);
});
it('~: should call vimToggleCase', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: '~' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(1);
});
it('X can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(2);
});
it('~ can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(2);
});
it('3X calls vimDeleteCharBefore with count=3', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '3' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(3);
});
it('2~ calls vimToggleCase with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(2);
});
});
describe('Replace character (r)', () => {
it('r{char}: should call vimReplaceChar with the next key', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 1);
});
it('r: should consume the pending char without passing through', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let rHandled: boolean;
let charHandled: boolean;
act(() => {
rHandled = result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
charHandled = result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(rHandled!).toBe(true);
expect(charHandled!).toBe(true);
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('a', 1);
});
it('Escape cancels pending r (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
act(() => {
result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(mockBuffer.vimReplaceChar).not.toHaveBeenCalled();
});
it('2rx calls vimReplaceChar with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 2);
});
it('r{char} is dot-repeatable', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'z' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('z', 1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimReplaceChar).toHaveBeenLastCalledWith('z', 1);
});
});
describe('Character find motions (f, F, t, T, ;, ,)', () => {
type FindCase = {
key: string;
char: string;
mockFn: 'vimFindCharForward' | 'vimFindCharBackward';
till: boolean;
};
it.each<FindCase>([
{ key: 'f', char: 'o', mockFn: 'vimFindCharForward', till: false },
{ key: 'F', char: 'o', mockFn: 'vimFindCharBackward', till: false },
{ key: 't', char: 'w', mockFn: 'vimFindCharForward', till: true },
{ key: 'T', char: 'w', mockFn: 'vimFindCharBackward', till: true },
])(
'$key{char}: calls $mockFn (till=$till)',
({ key, char, mockFn, till }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: key }));
});
act(() => {
result.current.handleInput(createKey({ sequence: char }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith(char, 1, till);
},
);
it(';: should repeat last f forward find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ;
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimFindCharForward).toHaveBeenLastCalledWith(
'o',
1,
false,
);
});
it(',: should repeat last f find in reverse direction', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ,
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharBackward).toHaveBeenCalledWith(
'o',
1,
false,
);
});
it('; and , should do nothing if no prior find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
});
it('Escape cancels pending f (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
// o should NOT be consumed as find target
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
});
it('2fo calls vimFindCharForward with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledWith('o', 2, false);
});
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', () => {
it('df{char}: executes delete-to-char, not a dangling operator', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'x',
1,
false,
);
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
// Next key is a fresh normal-mode command — no dangling state
act(() => {
result.current.handleInput(createKey({ sequence: 'l' }));
});
expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1);
});
// operator + find/till motions (df, dt, dF, dT, cf, ct, ...)
type OperatorFindCase = {
operator: string;
findKey: string;
mockFn: 'vimDeleteToCharForward' | 'vimDeleteToCharBackward';
till: boolean;
entersInsert: boolean;
};
it.each<OperatorFindCase>([
{
operator: 'd',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: false,
},
{
operator: 'd',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: false,
},
{
operator: 'c',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: true,
},
{
operator: 'c',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: true,
},
])(
'$operator$findKey{char}: calls $mockFn (till=$till, insert=$entersInsert)',
({ operator, findKey, mockFn, till, entersInsert }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: operator }));
});
act(() => {
result.current.handleInput(createKey({ sequence: findKey }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith('o', 1, till);
if (entersInsert) {
expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT');
}
},
);
it('2df{char}: count is passed through to vimDeleteToCharForward', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'o',
2,
false,
);
});
});
});
+161 -8
View File
@@ -11,6 +11,7 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
import { toCodePoints } from '../utils/textUtils.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -35,6 +36,9 @@ const CMD_TYPES = {
CHANGE_BIG_WORD_BACKWARD: 'cB',
CHANGE_BIG_WORD_END: 'cE',
DELETE_CHAR: 'x',
DELETE_CHAR_BEFORE: 'X',
TOGGLE_CASE: '~',
REPLACE_CHAR: 'r',
DELETE_LINE: 'dd',
CHANGE_LINE: 'cc',
DELETE_TO_EOL: 'D',
@@ -61,18 +65,25 @@ const CMD_TYPES = {
CHANGE_TO_LAST_LINE: 'cG',
} as const;
// Helper function to clear pending state
type PendingFindOp = {
op: 'f' | 'F' | 't' | 'T' | 'r';
operator: 'd' | 'c' | undefined;
count: number; // captured at keypress time, before CLEAR_PENDING_STATES resets it
};
const createClearPendingState = () => ({
count: 0,
pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null,
pendingFindOp: undefined as PendingFindOp | undefined,
});
// State and action types for useReducer
type VimState = {
mode: VimMode;
count: number;
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
lastCommand: { type: string; count: number } | null;
pendingFindOp: PendingFindOp | undefined;
lastCommand: { type: string; count: number; char?: string } | null;
lastFind: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
};
type VimAction =
@@ -84,9 +95,14 @@ type VimAction =
type: 'SET_PENDING_OPERATOR';
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
}
| { type: 'SET_PENDING_FIND_OP'; pendingFindOp: PendingFindOp | undefined }
| {
type: 'SET_LAST_FIND';
find: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
}
| {
type: 'SET_LAST_COMMAND';
command: { type: string; count: number } | null;
command: { type: string; count: number; char?: string } | null;
}
| { type: 'CLEAR_PENDING_STATES' }
| { type: 'ESCAPE_TO_NORMAL' };
@@ -95,7 +111,9 @@ const initialVimState: VimState = {
mode: 'INSERT',
count: 0,
pendingOperator: null,
pendingFindOp: undefined,
lastCommand: null,
lastFind: undefined,
};
// Reducer function
@@ -116,6 +134,12 @@ const vimReducer = (state: VimState, action: VimAction): VimState => {
case 'SET_PENDING_OPERATOR':
return { ...state, pendingOperator: action.operator };
case 'SET_PENDING_FIND_OP':
return { ...state, pendingFindOp: action.pendingFindOp };
case 'SET_LAST_FIND':
return { ...state, lastFind: action.find };
case 'SET_LAST_COMMAND':
return { ...state, lastCommand: action.command };
@@ -195,7 +219,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
/** Executes common commands to eliminate duplication in dot (.) repeat command */
const executeCommand = useCallback(
(cmdType: string, count: number) => {
(cmdType: string, count: number, char?: string) => {
switch (cmdType) {
case CMD_TYPES.DELETE_WORD_FORWARD: {
buffer.vimDeleteWordForward(count);
@@ -268,6 +292,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
break;
}
case CMD_TYPES.DELETE_CHAR_BEFORE: {
buffer.vimDeleteCharBefore(count);
break;
}
case CMD_TYPES.TOGGLE_CASE: {
buffer.vimToggleCase(count);
break;
}
case CMD_TYPES.REPLACE_CHAR: {
if (char) buffer.vimReplaceChar(char, count);
break;
}
case CMD_TYPES.DELETE_LINE: {
buffer.vimDeleteLine(count);
break;
@@ -597,7 +636,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Handle NORMAL mode
if (state.mode === 'NORMAL') {
if (keyMatchers[Command.ESCAPE](normalizedKey)) {
if (state.pendingOperator) {
if (state.pendingOperator || state.pendingFindOp) {
dispatch({ type: 'CLEAR_PENDING_STATES' });
lastEscapeTimestampRef.current = 0;
return true; // Handled by vim
@@ -627,6 +666,47 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const repeatCount = getCurrentCount();
// Handle pending find/till/replace — consume the next char as the target
if (state.pendingFindOp !== undefined) {
const targetChar = normalizedKey.sequence;
const { op, operator, count: findCount } = state.pendingFindOp;
dispatch({ type: 'SET_PENDING_FIND_OP', pendingFindOp: undefined });
dispatch({ type: 'CLEAR_COUNT' });
if (targetChar && toCodePoints(targetChar).length === 1) {
if (op === 'r') {
buffer.vimReplaceChar(targetChar, findCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.REPLACE_CHAR,
count: findCount,
char: targetChar,
},
});
} else {
const isBackward = op === 'F' || op === 'T';
const isTill = op === 't' || op === 'T';
if (operator === 'd' || operator === 'c') {
const del = isBackward
? buffer.vimDeleteToCharBackward
: buffer.vimDeleteToCharForward;
del(targetChar, findCount, isTill);
if (operator === 'c') updateMode('INSERT');
} else {
const find = isBackward
? buffer.vimFindCharBackward
: buffer.vimFindCharForward;
find(targetChar, findCount, isTill);
dispatch({
type: 'SET_LAST_FIND',
find: { op, char: targetChar },
});
}
}
}
return true;
}
switch (normalizedKey.sequence) {
case 'h': {
// Check if this is part of a delete or change command (dh/ch)
@@ -789,8 +869,79 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return true;
}
case 'X': {
buffer.vimDeleteCharBefore(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.DELETE_CHAR_BEFORE,
count: repeatCount,
},
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '~': {
buffer.vimToggleCase(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.TOGGLE_CASE, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'r': {
// Replace char: next keypress is the replacement. Not composable with d/c.
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: {
op: 'r',
operator: undefined,
count: repeatCount,
},
});
return true;
}
case 'f':
case 'F':
case 't':
case 'T': {
const op = normalizedKey.sequence;
const operator =
state.pendingOperator === 'd' || state.pendingOperator === 'c'
? state.pendingOperator
: undefined;
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: { op, operator, count: repeatCount },
});
return true;
}
case ';':
case ',': {
if (state.lastFind) {
const { op, char } = state.lastFind;
const isForward = op === 'f' || op === 't';
const isTill = op === 't' || op === 'T';
const reverse = normalizedKey.sequence === ',';
const shouldMoveForward = reverse ? !isForward : isForward;
if (shouldMoveForward) {
buffer.vimFindCharForward(char, repeatCount, isTill);
} else {
buffer.vimFindCharBackward(char, repeatCount, isTill);
}
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'i': {
// Enter INSERT mode at current position
buffer.vimInsertAtCursor();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
@@ -1107,7 +1258,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const count = state.count > 0 ? state.count : cmdData.count;
// All repeatable commands are now handled by executeCommand
executeCommand(cmdData.type, count);
executeCommand(cmdData.type, count, cmdData.char);
}
dispatch({ type: 'CLEAR_COUNT' });
@@ -1194,7 +1345,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
state.mode,
state.count,
state.pendingOperator,
state.pendingFindOp,
state.lastCommand,
state.lastFind,
dispatch,
getCurrentCount,
handleChangeMovement,
+101 -30
View File
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Storage } from '@google/gemini-cli-core';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
defaultKeyBindingConfig,
KeyBinding,
loadCustomKeybindings,
} from './keyBindings.js';
describe('KeyBinding', () => {
@@ -93,37 +97,15 @@ describe('KeyBinding', () => {
'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
);
});
it('should throw an error for literal "+" as key (must use "=")', () => {
// VS Code style peeling logic results in "+" as the remains
expect(() => new KeyBinding('alt++')).toThrow(
'Invalid keybinding key: "+" in "alt++"',
);
});
});
});
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
it('should have bindings for all commands', () => {
const commands = Object.values(Command);
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
}
});
it('should export all required types', () => {
// Basic type checks
expect(typeof Command.HOME).toBe('string');
expect(typeof Command.END).toBe('string');
// Config should be readonly
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
it('should have bindings for all commands', () => {
for (const command of Object.values(Command)) {
expect(defaultKeyBindingConfig.has(command)).toBe(true);
expect(defaultKeyBindingConfig.get(command)?.length).toBeGreaterThan(0);
}
});
describe('command metadata', () => {
@@ -157,3 +139,92 @@ describe('keyBindings config', () => {
});
});
});
describe('loadCustomKeybindings', () => {
let tempDir: string;
let tempFilePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-keybindings-test-'),
);
tempFilePath = path.join(tempDir, 'keybindings.json');
vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('returns default bindings when file does not exist', async () => {
// We don't write the file.
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
});
it('merges valid custom bindings, prepending them to defaults', async () => {
const customJson = JSON.stringify([
{ command: Command.RETURN, key: 'ctrl+a' },
]);
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.RETURN)).toEqual([
new KeyBinding('ctrl+a'),
new KeyBinding('enter'),
]);
});
it('handles JSON with comments', async () => {
const customJson = `
[
// This is a comment
{ "command": "${Command.QUIT}", "key": "ctrl+x" }
]
`;
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors).toHaveLength(0);
expect(config.get(Command.QUIT)).toEqual([
new KeyBinding('ctrl+x'),
new KeyBinding('ctrl+c'),
]);
});
it('returns validation errors for invalid schema', async () => {
const invalidJson = JSON.stringify([{ command: 'unknown', key: 'a' }]);
await fs.writeFile(tempFilePath, invalidJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toMatch(/error at 0.command: Invalid enum value/);
// Should still have defaults
expect(config.get(Command.RETURN)).toEqual([new KeyBinding('enter')]);
});
it('returns validation errors for invalid key patterns but loads valid ones', async () => {
const mixedJson = JSON.stringify([
{ command: Command.RETURN, key: 'super+a' }, // invalid
{ command: Command.QUIT, key: 'ctrl+y' }, // valid
]);
await fs.writeFile(tempFilePath, mixedJson, 'utf8');
const { config, errors } = await loadCustomKeybindings();
expect(errors.length).toBe(1);
expect(errors[0]).toMatch(/Invalid keybinding/);
expect(config.get(Command.QUIT)).toEqual([
new KeyBinding('ctrl+y'),
new KeyBinding('ctrl+c'),
]);
});
});
+199 -114
View File
@@ -4,6 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { z } from 'zod';
import { parse as parseIgnoringComments } from 'comment-json';
import { isNodeError, Storage } from '@google/gemini-cli-core';
/**
* Command enum for all available keyboard shortcuts
*/
@@ -105,10 +110,8 @@ export enum Command {
* Data-driven key binding structure for user configuration
*/
export class KeyBinding {
private static readonly VALID_KEYS = new Set([
...'abcdefghijklmnopqrstuvwxyz0123456789', // Letters & Numbers
..."`-=[]\\;',./", // Punctuation
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`), // Function Keys
private static readonly VALID_LONG_KEYS = new Set([
...Array.from({ length: 35 }, (_, i) => `f${i + 1}`), // Function Keys
...Array.from({ length: 10 }, (_, i) => `numpad${i}`), // Numpad Numbers
// Navigation & Actions
'left',
@@ -125,6 +128,7 @@ export class KeyBinding {
'space',
'backspace',
'delete',
'clear',
'pausebreak',
'capslock',
'insert',
@@ -188,8 +192,11 @@ export class KeyBinding {
const key = remains;
if (!KeyBinding.VALID_KEYS.has(key)) {
throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
@@ -213,153 +220,172 @@ export class KeyBinding {
/**
* Configuration type mapping commands to their key bindings
*/
export type KeyBindingConfig = {
readonly [C in Command]: readonly KeyBinding[];
};
export type KeyBindingConfig = Map<Command, readonly KeyBinding[]>;
/**
* Default key binding configuration
* Matches the original hard-coded logic exactly
*/
export const defaultKeyBindings: KeyBindingConfig = {
export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Basic Controls
[Command.RETURN]: [new KeyBinding('enter')],
[Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
[Command.QUIT]: [new KeyBinding('ctrl+c')],
[Command.EXIT]: [new KeyBinding('ctrl+d')],
[Command.RETURN, [new KeyBinding('enter')]],
[Command.ESCAPE, [new KeyBinding('escape'), new KeyBinding('ctrl+[')]],
[Command.QUIT, [new KeyBinding('ctrl+c')]],
[Command.EXIT, [new KeyBinding('ctrl+d')]],
// Cursor Movement
[Command.HOME]: [new KeyBinding('ctrl+a'), new KeyBinding('home')],
[Command.END]: [new KeyBinding('ctrl+e'), new KeyBinding('end')],
[Command.MOVE_UP]: [new KeyBinding('up')],
[Command.MOVE_DOWN]: [new KeyBinding('down')],
[Command.MOVE_LEFT]: [new KeyBinding('left')],
[Command.MOVE_RIGHT]: [new KeyBinding('right'), new KeyBinding('ctrl+f')],
[Command.MOVE_WORD_LEFT]: [
new KeyBinding('ctrl+left'),
new KeyBinding('alt+left'),
new KeyBinding('alt+b'),
[Command.HOME, [new KeyBinding('ctrl+a'), new KeyBinding('home')]],
[Command.END, [new KeyBinding('ctrl+e'), new KeyBinding('end')]],
[Command.MOVE_UP, [new KeyBinding('up')]],
[Command.MOVE_DOWN, [new KeyBinding('down')]],
[Command.MOVE_LEFT, [new KeyBinding('left')]],
[Command.MOVE_RIGHT, [new KeyBinding('right'), new KeyBinding('ctrl+f')]],
[
Command.MOVE_WORD_LEFT,
[
new KeyBinding('ctrl+left'),
new KeyBinding('alt+left'),
new KeyBinding('alt+b'),
],
],
[Command.MOVE_WORD_RIGHT]: [
new KeyBinding('ctrl+right'),
new KeyBinding('alt+right'),
new KeyBinding('alt+f'),
[
Command.MOVE_WORD_RIGHT,
[
new KeyBinding('ctrl+right'),
new KeyBinding('alt+right'),
new KeyBinding('alt+f'),
],
],
// Editing
[Command.KILL_LINE_RIGHT]: [new KeyBinding('ctrl+k')],
[Command.KILL_LINE_LEFT]: [new KeyBinding('ctrl+u')],
[Command.CLEAR_INPUT]: [new KeyBinding('ctrl+c')],
[Command.DELETE_WORD_BACKWARD]: [
new KeyBinding('ctrl+backspace'),
new KeyBinding('alt+backspace'),
new KeyBinding('ctrl+w'),
[Command.KILL_LINE_RIGHT, [new KeyBinding('ctrl+k')]],
[Command.KILL_LINE_LEFT, [new KeyBinding('ctrl+u')]],
[Command.CLEAR_INPUT, [new KeyBinding('ctrl+c')]],
[
Command.DELETE_WORD_BACKWARD,
[
new KeyBinding('ctrl+backspace'),
new KeyBinding('alt+backspace'),
new KeyBinding('ctrl+w'),
],
],
[Command.DELETE_WORD_FORWARD]: [
new KeyBinding('ctrl+delete'),
new KeyBinding('alt+delete'),
new KeyBinding('alt+d'),
[
Command.DELETE_WORD_FORWARD,
[
new KeyBinding('ctrl+delete'),
new KeyBinding('alt+delete'),
new KeyBinding('alt+d'),
],
],
[Command.DELETE_CHAR_LEFT]: [
new KeyBinding('backspace'),
new KeyBinding('ctrl+h'),
[
Command.DELETE_CHAR_LEFT,
[new KeyBinding('backspace'), new KeyBinding('ctrl+h')],
],
[Command.DELETE_CHAR_RIGHT]: [
new KeyBinding('delete'),
new KeyBinding('ctrl+d'),
[
Command.DELETE_CHAR_RIGHT,
[new KeyBinding('delete'), new KeyBinding('ctrl+d')],
],
[Command.UNDO]: [new KeyBinding('cmd+z'), new KeyBinding('alt+z')],
[Command.REDO]: [
new KeyBinding('ctrl+shift+z'),
new KeyBinding('cmd+shift+z'),
new KeyBinding('alt+shift+z'),
[Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]],
[
Command.REDO,
[
new KeyBinding('ctrl+shift+z'),
new KeyBinding('cmd+shift+z'),
new KeyBinding('alt+shift+z'),
],
],
// Scrolling
[Command.SCROLL_UP]: [new KeyBinding('shift+up')],
[Command.SCROLL_DOWN]: [new KeyBinding('shift+down')],
[Command.SCROLL_HOME]: [
new KeyBinding('ctrl+home'),
new KeyBinding('shift+home'),
[Command.SCROLL_UP, [new KeyBinding('shift+up')]],
[Command.SCROLL_DOWN, [new KeyBinding('shift+down')]],
[
Command.SCROLL_HOME,
[new KeyBinding('ctrl+home'), new KeyBinding('shift+home')],
],
[Command.SCROLL_END]: [
new KeyBinding('ctrl+end'),
new KeyBinding('shift+end'),
[
Command.SCROLL_END,
[new KeyBinding('ctrl+end'), new KeyBinding('shift+end')],
],
[Command.PAGE_UP]: [new KeyBinding('pageup')],
[Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
[Command.PAGE_UP, [new KeyBinding('pageup')]],
[Command.PAGE_DOWN, [new KeyBinding('pagedown')]],
// History & Search
[Command.HISTORY_UP]: [new KeyBinding('ctrl+p')],
[Command.HISTORY_DOWN]: [new KeyBinding('ctrl+n')],
[Command.REVERSE_SEARCH]: [new KeyBinding('ctrl+r')],
[Command.SUBMIT_REVERSE_SEARCH]: [new KeyBinding('enter')],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
[Command.HISTORY_UP, [new KeyBinding('ctrl+p')]],
[Command.HISTORY_DOWN, [new KeyBinding('ctrl+n')]],
[Command.REVERSE_SEARCH, [new KeyBinding('ctrl+r')]],
[Command.SUBMIT_REVERSE_SEARCH, [new KeyBinding('enter')]],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, [new KeyBinding('tab')]],
// Navigation
[Command.NAVIGATION_UP]: [new KeyBinding('up')],
[Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
[Command.NAVIGATION_UP, [new KeyBinding('up')]],
[Command.NAVIGATION_DOWN, [new KeyBinding('down')]],
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
[Command.DIALOG_NAVIGATION_UP]: [new KeyBinding('up'), new KeyBinding('k')],
[Command.DIALOG_NAVIGATION_DOWN]: [
new KeyBinding('down'),
new KeyBinding('j'),
[Command.DIALOG_NAVIGATION_UP, [new KeyBinding('up'), new KeyBinding('k')]],
[
Command.DIALOG_NAVIGATION_DOWN,
[new KeyBinding('down'), new KeyBinding('j')],
],
[Command.DIALOG_NEXT]: [new KeyBinding('tab')],
[Command.DIALOG_PREV]: [new KeyBinding('shift+tab')],
[Command.DIALOG_NEXT, [new KeyBinding('tab')]],
[Command.DIALOG_PREV, [new KeyBinding('shift+tab')]],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [new KeyBinding('tab'), new KeyBinding('enter')],
[Command.COMPLETION_UP]: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
[Command.COMPLETION_DOWN]: [new KeyBinding('down'), new KeyBinding('ctrl+n')],
[Command.EXPAND_SUGGESTION]: [new KeyBinding('right')],
[Command.COLLAPSE_SUGGESTION]: [new KeyBinding('left')],
[Command.ACCEPT_SUGGESTION, [new KeyBinding('tab'), new KeyBinding('enter')]],
[Command.COMPLETION_UP, [new KeyBinding('up'), new KeyBinding('ctrl+p')]],
[Command.COMPLETION_DOWN, [new KeyBinding('down'), new KeyBinding('ctrl+n')]],
[Command.EXPAND_SUGGESTION, [new KeyBinding('right')]],
[Command.COLLAPSE_SUGGESTION, [new KeyBinding('left')]],
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT]: [new KeyBinding('enter')],
[Command.NEWLINE]: [
new KeyBinding('ctrl+enter'),
new KeyBinding('cmd+enter'),
new KeyBinding('alt+enter'),
new KeyBinding('shift+enter'),
new KeyBinding('ctrl+j'),
[Command.SUBMIT, [new KeyBinding('enter')]],
[
Command.NEWLINE,
[
new KeyBinding('ctrl+enter'),
new KeyBinding('cmd+enter'),
new KeyBinding('alt+enter'),
new KeyBinding('shift+enter'),
new KeyBinding('ctrl+j'),
],
],
[Command.OPEN_EXTERNAL_EDITOR]: [new KeyBinding('ctrl+x')],
[Command.PASTE_CLIPBOARD]: [
new KeyBinding('ctrl+v'),
new KeyBinding('cmd+v'),
new KeyBinding('alt+v'),
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[
Command.PASTE_CLIPBOARD,
[
new KeyBinding('ctrl+v'),
new KeyBinding('cmd+v'),
new KeyBinding('alt+v'),
],
],
// App Controls
[Command.SHOW_ERROR_DETAILS]: [new KeyBinding('f12')],
[Command.SHOW_FULL_TODOS]: [new KeyBinding('ctrl+t')],
[Command.SHOW_IDE_CONTEXT_DETAIL]: [new KeyBinding('ctrl+g')],
[Command.TOGGLE_MARKDOWN]: [new KeyBinding('alt+m')],
[Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')],
[Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')],
[Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')],
[Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')],
[Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')],
[Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')],
[Command.UNFOCUS_SHELL_INPUT]: [new KeyBinding('shift+tab')],
[Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')],
[Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')],
[Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
[Command.SHOW_ERROR_DETAILS, [new KeyBinding('f12')]],
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
[Command.EXPAND_PASTE, [new KeyBinding('ctrl+o')]],
[Command.FOCUS_SHELL_INPUT, [new KeyBinding('tab')]],
[Command.UNFOCUS_SHELL_INPUT, [new KeyBinding('shift+tab')]],
[Command.CLEAR_SCREEN, [new KeyBinding('ctrl+l')]],
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
[Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('enter')],
[Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')],
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')],
[Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')],
[Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')],
};
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
[Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]],
[Command.TOGGLE_BACKGROUND_SHELL, [new KeyBinding('ctrl+b')]],
[Command.TOGGLE_BACKGROUND_SHELL_LIST, [new KeyBinding('ctrl+l')]],
[Command.KILL_BACKGROUND_SHELL, [new KeyBinding('ctrl+k')]],
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
]);
interface CommandCategory {
readonly title: string;
@@ -593,3 +619,62 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from background shell.',
};
const keybindingsSchema = z.array(
z.object({
command: z.nativeEnum(Command),
key: z.string(),
}),
);
/**
* Loads custom keybindings from the user's keybindings.json file.
* Keybindings are merged with the default bindings.
*/
export async function loadCustomKeybindings(): Promise<{
config: KeyBindingConfig;
errors: string[];
}> {
const errors: string[] = [];
let config = defaultKeyBindingConfig;
const userKeybindingsPath = Storage.getUserKeybindingsPath();
try {
const content = await fs.readFile(userKeybindingsPath, 'utf8');
const parsedJson = parseIgnoringComments(content);
const result = keybindingsSchema.safeParse(parsedJson);
if (result.success) {
config = new Map(defaultKeyBindingConfig);
for (const { command, key } of result.data) {
const currentBindings = config.get(command) ?? [];
try {
const keyBinding = new KeyBinding(key);
// Add new binding (prepend so it's the primary one shown in UI)
config.set(command, [keyBinding, ...currentBindings]);
} catch (e) {
errors.push(`Invalid keybinding for command "${command}": ${e}`);
}
}
} else {
errors.push(
...result.error.issues.map(
(issue) =>
`Keybindings file "${userKeybindingsPath}" error at ${issue.path.join('.')}: ${issue.message}`,
),
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
// File doesn't exist, use default bindings
} else {
errors.push(
`Error reading keybindings file "${userKeybindingsPath}": ${error}`,
);
}
}
return { config, errors };
}
+71 -26
View File
@@ -4,28 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Storage } from '@google/gemini-cli-core';
import {
defaultKeyMatchers,
Command,
createKeyMatchers,
loadKeyMatchers,
} from './keyMatchers.js';
import type { KeyBindingConfig } from './keyBindings.js';
import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
import { defaultKeyBindingConfig, KeyBinding } from './keyBindings.js';
import type { Key } from '../hooks/useKeypress.js';
describe('keyMatchers', () => {
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
});
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
});
describe('keyMatchers', () => {
// Test data for each command with positive and negative test cases
const testCases = [
// Basic bindings
@@ -443,10 +447,11 @@ describe('keyMatchers', () => {
describe('Custom key bindings', () => {
it('should work with custom configuration', () => {
const customConfig: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('0')],
};
const customConfig = new Map(defaultKeyBindingConfig);
customConfig.set(Command.HOME, [
new KeyBinding('ctrl+h'),
new KeyBinding('0'),
]);
const customMatchers = createKeyMatchers(customConfig);
@@ -460,10 +465,11 @@ describe('keyMatchers', () => {
});
it('should support multiple key bindings for same command', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.QUIT]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
};
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [
new KeyBinding('ctrl+q'),
new KeyBinding('alt+q'),
]);
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
@@ -473,10 +479,8 @@ describe('keyMatchers', () => {
describe('Edge Cases', () => {
it('should handle empty binding arrays', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
[Command.HOME]: [],
};
const config = new Map(defaultKeyBindingConfig);
config.set(Command.HOME, []);
const matchers = createKeyMatchers(config);
expect(matchers[Command.HOME](createKey('a', { ctrl: true }))).toBe(
@@ -485,3 +489,44 @@ describe('keyMatchers', () => {
});
});
});
describe('loadKeyMatchers integration', () => {
let tempDir: string;
let tempFilePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-keymatchers-test-'),
);
tempFilePath = path.join(tempDir, 'keybindings.json');
vi.spyOn(Storage, 'getUserKeybindingsPath').mockReturnValue(tempFilePath);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('loads matchers from a real file on disk', async () => {
const customJson = JSON.stringify([
{ command: Command.QUIT, key: 'ctrl+y' },
]);
await fs.writeFile(tempFilePath, customJson, 'utf8');
const { matchers, errors } = await loadKeyMatchers();
expect(errors).toHaveLength(0);
// User binding matches
expect(matchers[Command.QUIT](createKey('y', { ctrl: true }))).toBe(true);
// Default binding still matches as fallback
expect(matchers[Command.QUIT](createKey('c', { ctrl: true }))).toBe(true);
});
it('returns errors when the file on disk is invalid', async () => {
await fs.writeFile(tempFilePath, 'invalid json {', 'utf8');
const { errors } = await loadKeyMatchers();
expect(errors.length).toBeGreaterThan(0);
});
});
+27 -6
View File
@@ -6,7 +6,11 @@
import type { Key } from '../hooks/useKeypress.js';
import type { KeyBindingConfig } from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
import {
Command,
defaultKeyBindingConfig,
loadCustomKeybindings,
} from './keyBindings.js';
/**
* Checks if a key matches any of the bindings for a command
@@ -14,9 +18,11 @@ import { Command, defaultKeyBindings } from './keyBindings.js';
function matchCommand(
command: Command,
key: Key,
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
): boolean {
return config[command].some((binding) => binding.matches(key));
const bindings = config.get(command);
if (!bindings) return false;
return bindings.some((binding) => binding.matches(key));
}
/**
@@ -35,7 +41,7 @@ export type KeyMatchers = {
* Creates key matchers from a key binding configuration
*/
export function createKeyMatchers(
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
): KeyMatchers {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const matchers = {} as { [C in Command]: KeyMatcher };
@@ -50,8 +56,23 @@ export function createKeyMatchers(
/**
* Default key binding matchers using the default configuration
*/
export const defaultKeyMatchers: KeyMatchers =
createKeyMatchers(defaultKeyBindings);
export const defaultKeyMatchers: KeyMatchers = createKeyMatchers(
defaultKeyBindingConfig,
);
// Re-export Command for convenience
export { Command };
/**
* Loads and creates key matchers including user customizations.
*/
export async function loadKeyMatchers(): Promise<{
matchers: KeyMatchers;
errors: string[];
}> {
const { config, errors } = await loadCustomKeybindings();
return {
matchers: createKeyMatchers(config),
errors,
};
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
type Command,
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
defaultKeyBindingConfig,
} from './keyBindings.js';
/**
@@ -97,10 +97,10 @@ export function formatKeyBinding(
*/
export function formatCommand(
command: Command,
config: KeyBindingConfig = defaultKeyBindings,
config: KeyBindingConfig = defaultKeyBindingConfig,
platform?: string,
): string {
const bindings = config[command];
const bindings = config.get(command);
if (!bindings || bindings.length === 0) {
return '';
}
+116
View File
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
promises as fs,
type PathLike,
type Dirent,
type Stats,
} from 'node:fs';
import * as path from 'node:path';
import { cleanupBackgroundLogs } from './logCleanup.js';
vi.mock('@google/gemini-cli-core', () => ({
ShellExecutionService: {
getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'),
},
debugLogger: {
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('node:fs', () => ({
promises: {
access: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
},
}));
describe('logCleanup', () => {
const logDir = '/tmp/gemini/tmp/background-processes';
beforeEach(() => {
vi.clearAllMocks();
});
it('should skip cleanup if the directory does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
await cleanupBackgroundLogs();
expect(fs.access).toHaveBeenCalledWith(logDir);
expect(fs.readdir).not.toHaveBeenCalled();
});
it('should skip cleanup if the directory is empty', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([]);
await cleanupBackgroundLogs();
expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true });
expect(fs.unlink).not.toHaveBeenCalled();
});
it('should delete log files older than 7 days', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago
const entries = [
{ name: 'old.log', isFile: () => true },
{ name: 'new.log', isFile: () => true },
{ name: 'not-a-log.txt', isFile: () => true },
{ name: 'some-dir', isFile: () => false },
] as Dirent[];
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(
fs.readdir as (
path: PathLike,
options: { withFileTypes: true },
) => Promise<Dirent[]>,
).mockResolvedValue(entries);
vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString();
if (pathStr.endsWith('old.log')) {
return Promise.resolve({ mtime: new Date(oldTime) } as Stats);
}
if (pathStr.endsWith('new.log')) {
return Promise.resolve({ mtime: new Date(newTime) } as Stats);
}
return Promise.resolve({ mtime: new Date(now) } as Stats);
});
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await cleanupBackgroundLogs();
expect(fs.unlink).toHaveBeenCalledTimes(1);
expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log'));
expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log'));
});
it('should handle errors during file deletion gracefully', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000;
const entries = [{ name: 'old.log', isFile: () => true }];
vi.mocked(fs.access).mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue(entries as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any);
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
await expect(cleanupBackgroundLogs()).resolves.not.toThrow();
expect(fs.unlink).toHaveBeenCalled();
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { ShellExecutionService, debugLogger } from '@google/gemini-cli-core';
const RETENTION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Cleans up background process log files older than 7 days.
* Scans ~/.gemini/tmp/background-processes/ for .log files.
*
* @param debugMode Whether to log detailed debug information.
*/
export async function cleanupBackgroundLogs(
debugMode: boolean = false,
): Promise<void> {
try {
const logDir = ShellExecutionService.getLogDir();
// Check if the directory exists
try {
await fs.access(logDir);
} catch {
// Directory doesn't exist, nothing to clean up
return;
}
const entries = await fs.readdir(logDir, { withFileTypes: true });
const now = Date.now();
let deletedCount = 0;
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.log')) {
const filePath = path.join(logDir, entry.name);
try {
const stats = await fs.stat(filePath);
if (now - stats.mtime.getTime() > RETENTION_PERIOD_MS) {
await fs.unlink(filePath);
deletedCount++;
}
} catch (error) {
if (debugMode) {
debugLogger.debug(
`Failed to process log file ${entry.name}:`,
error,
);
}
}
}
}
if (deletedCount > 0 && debugMode) {
debugLogger.debug(`Cleaned up ${deletedCount} expired background logs.`);
}
} catch (error) {
// Best-effort cleanup, don't let it crash the CLI
if (debugMode) {
debugLogger.warn('Background log cleanup failed:', error);
}
}
}
+28 -3
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
RELAUNCH_EXIT_CODE,
relaunchApp,
@@ -22,17 +22,42 @@ describe('processUtils', () => {
.spyOn(process, 'exit')
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
const originalSend = process.send;
beforeEach(() => {
_resetRelaunchStateForTesting();
process.send = vi.fn(
(_msg: unknown, callback?: (err: Error | null) => void) => {
if (callback) callback(null);
return true;
},
);
});
afterEach(() => vi.clearAllMocks());
afterEach(() => {
vi.clearAllMocks();
process.send = originalSend;
});
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
it('should not send IPC message when no sessionId is provided', async () => {
await relaunchApp();
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(process.send).not.toHaveBeenCalled();
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
it('should send resume session ID via IPC when sessionId is provided', async () => {
await relaunchApp('custom-session-id');
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(process.send).toHaveBeenCalledWith(
{
type: 'relaunch-session',
sessionId: 'custom-session-id',
},
expect.any(Function),
);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
});
+14 -1
View File
@@ -22,10 +22,23 @@ export function _resetRelaunchStateForTesting(): void {
isRelaunching = false;
}
export async function relaunchApp(): Promise<void> {
export async function relaunchApp(sessionId?: string): Promise<void> {
if (isRelaunching) return;
isRelaunching = true;
await waitForUpdateCompletion();
await runExitCleanup();
if (process.send && sessionId) {
await new Promise<void>((resolve) => {
process.send!(
{
type: 'relaunch-session',
sessionId,
},
() => resolve(),
);
});
}
process.exit(RELAUNCH_EXIT_CODE);
}
+45
View File
@@ -315,6 +315,51 @@ describe('relaunchAppInChildProcess', () => {
// Should default to exit code 1
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should set GEMINI_RESUME_SESSION_ID env var on the next spawn if relaunch-session message is received', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js', '--some-flag'];
let spawnCount = 0;
mockedSpawn.mockImplementation(() => {
spawnCount++;
const mockChild = createMockChildProcess(0, false);
if (spawnCount === 1) {
// First run: send the resume session ID, then exit with RELAUNCH_EXIT_CODE
setImmediate(() => {
mockChild.emit('message', {
type: 'relaunch-session',
sessionId: 'test-session-123',
});
mockChild.emit('close', RELAUNCH_EXIT_CODE);
});
} else if (spawnCount === 2) {
// Second run: exit normally
setImmediate(() => {
mockChild.emit('close', 0);
});
}
return mockChild;
});
const promise = relaunchAppInChildProcess([], []);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(mockedSpawn).toHaveBeenCalledTimes(2);
// First spawn should not have the env var set
const firstEnv = mockedSpawn.mock.calls[0][2]?.env;
expect(firstEnv?.['GEMINI_RESUME_SESSION_ID']).toBeUndefined();
// Second spawn should have GEMINI_RESUME_SESSION_ID set
const secondEnv = mockedSpawn.mock.calls[1][2]?.env;
expect(secondEnv?.['GEMINI_RESUME_SESSION_ID']).toBe('test-session-123');
// Args should not contain --resume
const secondArgs = mockedSpawn.mock.calls[1][1];
expect(secondArgs).not.toContain('--resume');
});
});
});
+16 -6
View File
@@ -41,6 +41,7 @@ export async function relaunchAppInChildProcess(
}
let latestAdminSettings = remoteAdminSettings;
let resumeSessionId: string | undefined = undefined;
const runner = () => {
// process.argv is [node, script, ...args]
@@ -55,7 +56,11 @@ export async function relaunchAppInChildProcess(
...additionalScriptArgs,
...scriptArgs,
];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const newEnv: Record<string, string | undefined> = {
...process.env,
GEMINI_CLI_NO_RELAUNCH: 'true',
GEMINI_RESUME_SESSION_ID: resumeSessionId,
};
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();
@@ -69,11 +74,16 @@ export async function relaunchAppInChildProcess(
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}
child.on('message', (msg: { type?: string; settings?: unknown }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings as AdminControlsSettings;
}
});
child.on(
'message',
(msg: { type?: string; settings?: unknown; sessionId?: string }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings as AdminControlsSettings;
} else if (msg.type === 'relaunch-session' && msg.sessionId) {
resumeSessionId = msg.sessionId;
}
},
);
return new Promise<number>((resolve, reject) => {
child.on('error', reject);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260310.4653b126f",
"version": "0.35.0-nightly.20260311.657f19c1f",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -302,7 +302,7 @@ describe('A2AClientManager', () => {
expect(call.message.taskId).toBe(expectedTaskId);
});
it('should throw prefixed error on failure', async () => {
it('should propagate the original error on failure', async () => {
sendMessageStreamMock.mockImplementationOnce(() => {
throw new Error('Network error');
});
@@ -312,9 +312,7 @@ describe('A2AClientManager', () => {
for await (const _ of stream) {
// consume
}
}).rejects.toThrow(
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
);
}).rejects.toThrow('Network error');
});
it('should throw an error if the agent is not found', async () => {
+17 -22
View File
@@ -26,6 +26,7 @@ import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici';
import { debugLogger } from '../utils/debugLogger.js';
import { safeLookup } from '../utils/fetch.js';
import { classifyAgentError } from './a2a-errors.js';
// Remote agents can take 10+ minutes (e.g. Deep Research).
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
@@ -131,18 +132,22 @@ export class A2AClientManager {
},
);
const factory = new ClientFactory(options);
const client = await factory.createFromUrl(agentCardUrl, '');
const agentCard = await client.getAgentCard();
try {
const factory = new ClientFactory(options);
const client = await factory.createFromUrl(agentCardUrl, '');
const agentCard = await client.getAgentCard();
this.clients.set(name, client);
this.agentCards.set(name, agentCard);
this.clients.set(name, client);
this.agentCards.set(name, agentCard);
debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
);
debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
);
return agentCard;
return agentCard;
} catch (error: unknown) {
throw classifyAgentError(name, agentCardUrl, error);
}
}
/**
@@ -183,19 +188,9 @@ export class A2AClientManager {
},
};
try {
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
} catch (error: unknown) {
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
if (error instanceof Error) {
throw new Error(`${prefix}: ${error.message}`, { cause: error });
}
throw new Error(
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
);
}
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
}
/**
+298
View File
@@ -0,0 +1,298 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
A2AAgentError,
AgentCardNotFoundError,
AgentCardAuthError,
AgentAuthConfigMissingError,
AgentConnectionError,
classifyAgentError,
} from './a2a-errors.js';
describe('A2A Error Types', () => {
describe('A2AAgentError', () => {
it('should set name, agentName, and userMessage', () => {
const error = new A2AAgentError('my-agent', 'internal msg', 'user msg');
expect(error.name).toBe('A2AAgentError');
expect(error.agentName).toBe('my-agent');
expect(error.message).toBe('internal msg');
expect(error.userMessage).toBe('user msg');
});
});
describe('AgentCardNotFoundError', () => {
it('should produce a user-friendly 404 message', () => {
const error = new AgentCardNotFoundError(
'my-agent',
'https://example.com/card',
);
expect(error.name).toBe('AgentCardNotFoundError');
expect(error.agentName).toBe('my-agent');
expect(error.userMessage).toContain('404');
expect(error.userMessage).toContain('https://example.com/card');
expect(error.userMessage).toContain('agent_card_url');
});
});
describe('AgentCardAuthError', () => {
it('should produce a user-friendly 401 message', () => {
const error = new AgentCardAuthError(
'secure-agent',
'https://example.com/card',
401,
);
expect(error.name).toBe('AgentCardAuthError');
expect(error.statusCode).toBe(401);
expect(error.userMessage).toContain('401');
expect(error.userMessage).toContain('Unauthorized');
expect(error.userMessage).toContain('"auth" configuration');
});
it('should produce a user-friendly 403 message', () => {
const error = new AgentCardAuthError(
'secure-agent',
'https://example.com/card',
403,
);
expect(error.statusCode).toBe(403);
expect(error.userMessage).toContain('403');
expect(error.userMessage).toContain('Forbidden');
});
});
describe('AgentAuthConfigMissingError', () => {
it('should list missing config fields', () => {
const error = new AgentAuthConfigMissingError(
'api-agent',
'API Key (x-api-key): Send x-api-key in header',
[
'Authentication is required but not configured',
"Scheme 'api_key' requires apiKey authentication",
],
);
expect(error.name).toBe('AgentAuthConfigMissingError');
expect(error.requiredAuth).toContain('API Key');
expect(error.missingFields).toHaveLength(2);
expect(error.userMessage).toContain('API Key');
expect(error.userMessage).toContain('no auth is configured');
expect(error.userMessage).toContain('Missing:');
});
});
describe('AgentConnectionError', () => {
it('should wrap the original error cause', () => {
const cause = new Error('ECONNREFUSED');
const error = new AgentConnectionError(
'my-agent',
'https://example.com/card',
cause,
);
expect(error.name).toBe('AgentConnectionError');
expect(error.userMessage).toContain('ECONNREFUSED');
expect(error.userMessage).toContain('https://example.com/card');
});
it('should handle non-Error causes', () => {
const error = new AgentConnectionError(
'my-agent',
'https://example.com/card',
'raw string error',
);
expect(error.userMessage).toContain('raw string error');
});
});
describe('classifyAgentError', () => {
it('should classify a 404 error message', () => {
const raw = new Error('HTTP 404: Not Found');
const result = classifyAgentError(
'agent-a',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
expect(result.agentName).toBe('agent-a');
});
it('should classify a "not found" error message (case-insensitive)', () => {
const raw = new Error('Agent card not found at the given URL');
const result = classifyAgentError(
'agent-a',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
});
it('should classify a 401 error message', () => {
const raw = new Error('Request failed with status 401');
const result = classifyAgentError(
'agent-b',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});
it('should classify an "unauthorized" error message', () => {
const raw = new Error('Unauthorized access to agent card');
const result = classifyAgentError(
'agent-b',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
});
it('should classify a 403 error message', () => {
const raw = new Error('HTTP 403 Forbidden');
const result = classifyAgentError(
'agent-c',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});
it('should fall back to AgentConnectionError for unknown errors', () => {
const raw = new Error('Something completely unexpected');
const result = classifyAgentError(
'agent-d',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});
it('should classify ECONNREFUSED as AgentConnectionError', () => {
const raw = new Error('ECONNREFUSED 127.0.0.1:8080');
const result = classifyAgentError(
'agent-d',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});
it('should handle non-Error values', () => {
const result = classifyAgentError(
'agent-e',
'https://example.com/card',
'some string error',
);
expect(result).toBeInstanceOf(AgentConnectionError);
});
describe('cause chain inspection', () => {
it('should detect 404 in a nested cause', () => {
const inner = new Error('HTTP 404 Not Found');
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-nested',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
});
it('should detect 401 in a deeply nested cause', () => {
const innermost = new Error('Server returned 401');
const middle = new Error('Request error', { cause: innermost });
const outer = new Error('fetch failed', { cause: middle });
const result = classifyAgentError(
'agent-deep',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});
it('should detect ECONNREFUSED error code in cause chain', () => {
const inner = Object.assign(new Error('connect failed'), {
code: 'ECONNREFUSED',
});
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-conn',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});
it('should detect status property on error objects in cause chain', () => {
const inner = Object.assign(new Error('Bad response'), {
status: 403,
});
const outer = new Error('agent card resolution failed', {
cause: inner,
});
const result = classifyAgentError(
'agent-status',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});
it('should detect status on a plain-object cause (non-Error)', () => {
const outer = new Error('fetch failed');
// Some HTTP libs set cause to a plain object, not an Error instance
(outer as unknown as { cause: unknown }).cause = {
message: 'Unauthorized',
status: 401,
};
const result = classifyAgentError(
'agent-plain-cause',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});
it('should detect statusCode on a plain-object cause (non-Error)', () => {
const outer = new Error('fetch failed');
(outer as unknown as { cause: unknown }).cause = {
message: 'Forbidden',
statusCode: 403,
};
const result = classifyAgentError(
'agent-plain-cause-403',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});
it('should classify ENOTFOUND as AgentConnectionError, not 404', () => {
// ENOTFOUND (DNS resolution failure) should NOT be misclassified
// as a 404 despite containing "NOTFOUND" in the error code.
const inner = Object.assign(
new Error('getaddrinfo ENOTFOUND example.invalid'),
{
code: 'ENOTFOUND',
},
);
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-dns',
'https://example.invalid/card',
outer,
);
expect(result).toBeInstanceOf(AgentConnectionError);
expect(result).not.toBeInstanceOf(AgentCardNotFoundError);
});
});
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Custom error types for A2A remote agent operations.
* Provides structured, user-friendly error messages for common failure modes
* during agent card fetching, authentication, and communication.
*/
/**
* Base class for all A2A agent errors.
* Provides a `userMessage` field with a human-readable description.
*/
export class A2AAgentError extends Error {
/** A user-friendly message suitable for display in the CLI. */
readonly userMessage: string;
/** The agent name associated with this error. */
readonly agentName: string;
constructor(
agentName: string,
message: string,
userMessage: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'A2AAgentError';
this.agentName = agentName;
this.userMessage = userMessage;
}
}
/**
* Thrown when the agent card URL returns a 404 Not Found response.
*/
export class AgentCardNotFoundError extends A2AAgentError {
constructor(agentName: string, agentCardUrl: string) {
const message = `Agent card not found at ${agentCardUrl} (HTTP 404)`;
const userMessage = `Agent card not found (404) at ${agentCardUrl}. Verify the agent_card_url in your agent definition.`;
super(agentName, message, userMessage);
this.name = 'AgentCardNotFoundError';
}
}
/**
* Thrown when the agent card URL returns a 401/403 response,
* indicating an authentication or authorization failure.
*/
export class AgentCardAuthError extends A2AAgentError {
readonly statusCode: number;
constructor(agentName: string, agentCardUrl: string, statusCode: 401 | 403) {
const statusText = statusCode === 401 ? 'Unauthorized' : 'Forbidden';
const message = `Agent card request returned ${statusCode} ${statusText} for ${agentCardUrl}`;
const userMessage = `Authentication failed (${statusCode} ${statusText}) at ${agentCardUrl}. Check the "auth" configuration in your agent definition.`;
super(agentName, message, userMessage);
this.name = 'AgentCardAuthError';
this.statusCode = statusCode;
}
}
/**
* Thrown when the agent card's security schemes require authentication
* but the agent definition does not include the necessary auth configuration.
*/
export class AgentAuthConfigMissingError extends A2AAgentError {
/** Human-readable description of required authentication schemes. */
readonly requiredAuth: string;
/** Specific fields or config entries that are missing. */
readonly missingFields: string[];
constructor(
agentName: string,
requiredAuth: string,
missingFields: string[],
) {
const message = `Agent "${agentName}" requires authentication but none is configured`;
const userMessage = `Agent requires ${requiredAuth} but no auth is configured. Missing: ${missingFields.join(', ')}`;
super(agentName, message, userMessage);
this.name = 'AgentAuthConfigMissingError';
this.requiredAuth = requiredAuth;
this.missingFields = missingFields;
}
}
/**
* Thrown when a generic/unexpected network or server error occurs
* while fetching the agent card or communicating with the remote agent.
*/
export class AgentConnectionError extends A2AAgentError {
constructor(agentName: string, agentCardUrl: string, cause: unknown) {
const causeMessage = cause instanceof Error ? cause.message : String(cause);
const message = `Failed to connect to agent "${agentName}" at ${agentCardUrl}: ${causeMessage}`;
const userMessage = `Connection failed for ${agentCardUrl}: ${causeMessage}`;
super(agentName, message, userMessage, { cause });
this.name = 'AgentConnectionError';
}
}
/** Shape of an error-like object in a cause chain (Error, HTTP response, or plain object). */
interface ErrorLikeObject {
message?: string;
code?: string;
status?: number;
statusCode?: number;
cause?: unknown;
}
/** Type guard for objects that may carry error metadata (message, code, status, cause). */
function isErrorLikeObject(val: unknown): val is ErrorLikeObject {
return typeof val === 'object' && val !== null;
}
/**
* Collects all error messages from an error's cause chain into a single string
* for pattern matching. This is necessary because the A2A SDK and Node's fetch
* often wrap the real error (e.g. HTTP status) deep inside nested causes.
*/
function collectErrorMessages(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
let depth = 0;
const maxDepth = 10;
while (current && depth < maxDepth) {
if (isErrorLikeObject(current)) {
// Save reference before instanceof narrows the type from ErrorLikeObject to Error.
const obj = current;
if (current instanceof Error) {
parts.push(current.message);
} else if (typeof obj.message === 'string') {
parts.push(obj.message);
}
if (typeof obj.code === 'string') {
parts.push(obj.code);
}
if (typeof obj.status === 'number') {
parts.push(String(obj.status));
} else if (typeof obj.statusCode === 'number') {
parts.push(String(obj.statusCode));
}
current = obj.cause;
} else if (typeof current === 'string') {
parts.push(current);
break;
} else {
parts.push(String(current));
break;
}
depth++;
}
return parts.join(' ');
}
/**
* Attempts to classify a raw error from the A2A SDK into a typed A2AAgentError.
*
* Inspects the error message and full cause chain for HTTP status codes and
* well-known patterns to produce a structured, user-friendly error.
*
* @param agentName The name of the agent being loaded.
* @param agentCardUrl The URL of the agent card.
* @param error The raw error caught during agent loading.
* @returns A classified A2AAgentError subclass.
*/
export function classifyAgentError(
agentName: string,
agentCardUrl: string,
error: unknown,
): A2AAgentError {
// Collect messages from the entire cause chain for thorough matching.
const fullErrorText = collectErrorMessages(error);
// Check for well-known connection error codes in the cause chain.
// NOTE: This is checked before the 404 pattern as a defensive measure
// to prevent DNS errors (ENOTFOUND) from being misclassified as 404s.
if (
/\b(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT)\b/i.test(fullErrorText)
) {
return new AgentConnectionError(agentName, agentCardUrl, error);
}
// Check for HTTP status code patterns across the full cause chain.
if (/\b404\b|\bnot[\s_-]?found\b/i.test(fullErrorText)) {
return new AgentCardNotFoundError(agentName, agentCardUrl);
}
if (/\b401\b|unauthorized/i.test(fullErrorText)) {
return new AgentCardAuthError(agentName, agentCardUrl, 401);
}
if (/\b403\b|forbidden/i.test(fullErrorText)) {
return new AgentCardAuthError(agentName, agentCardUrl, 403);
}
// Fallback to a generic connection error.
return new AgentConnectionError(agentName, agentCardUrl, error);
}
@@ -20,6 +20,7 @@ vi.mock('../scheduler/scheduler.js', () => ({
describe('agent-scheduler', () => {
let mockToolRegistry: Mocked<ToolRegistry>;
let mockConfig: Mocked<Config>;
let mockMessageBus: Mocked<MessageBus>;
beforeEach(() => {
@@ -29,6 +30,14 @@ describe('agent-scheduler', () => {
getTool: vi.fn(),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
mockMessageBus;
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry =
mockToolRegistry;
});
it('should create a scheduler with agent-specific config', async () => {
@@ -69,7 +78,8 @@ describe('agent-scheduler', () => {
}),
);
const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].config;
// Verify that the scheduler's context has the overridden tool registry
const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].context;
expect(schedulerConfig.toolRegistry).toBe(mockToolRegistry);
});
@@ -106,9 +116,8 @@ describe('agent-scheduler', () => {
},
);
const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].config;
const schedulerConfig = vi.mocked(Scheduler).mock.calls[0][0].context;
expect(schedulerConfig.toolRegistry).toBe(agentRegistry);
expect(schedulerConfig.toolRegistry).not.toBe(mainRegistry);
expect(schedulerConfig.getToolRegistry()).toBe(agentRegistry);
});
});
+1 -1
View File
@@ -65,7 +65,7 @@ export async function scheduleAgentTools(
});
const scheduler = new Scheduler({
config: agentConfig,
context: agentConfig,
messageBus: toolRegistry.getMessageBus(),
getPreferredEditor: getPreferredEditor ?? (() => undefined),
schedulerId,
@@ -16,6 +16,7 @@
import { randomUUID } from 'node:crypto';
import type { Config } from '../../config/config.js';
import { type AgentLoopContext } from '../../config/agent-loop-context.js';
import { LocalAgentExecutor } from '../local-executor.js';
import { safeJsonToMarkdown } from '../../utils/markdownUtils.js';
import {
@@ -179,7 +180,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
ToolResult
> {
constructor(
private readonly config: Config,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
_toolName?: string,
@@ -194,6 +195,10 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
);
}
private get config(): Config {
return this.context.config;
}
/**
* Returns a concise, human-readable description of the invocation.
*/
@@ -409,7 +414,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
// Create and run executor with the configured definition
const executor = await LocalAgentExecutor.create(
definition,
this.config,
this.context,
onActivity,
);
@@ -307,6 +307,11 @@ describe('LocalAgentExecutor', () => {
vi.useFakeTimers();
mockConfig = makeFakeConfig();
// .config is already set correctly by the getter on the instance.
Object.defineProperty(mockConfig, 'promptId', {
get: () => 'test-prompt-id',
configurable: true,
});
parentToolRegistry = new ToolRegistry(
mockConfig,
mockConfig.getMessageBus(),
@@ -319,7 +324,9 @@ describe('LocalAgentExecutor', () => {
);
parentToolRegistry.registerTool(MOCK_TOOL_NOT_ALLOWED);
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(parentToolRegistry);
vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue(
parentToolRegistry,
);
vi.spyOn(mockConfig, 'getAgentRegistry').mockReturnValue({
getAllAgentNames: () => [],
} as unknown as AgentRegistry);
@@ -382,7 +389,10 @@ describe('LocalAgentExecutor', () => {
it('should use parentPromptId from context to create agentId', async () => {
const parentId = 'parent-id';
mockedPromptIdContext.getStore.mockReturnValue(parentId);
Object.defineProperty(mockConfig, 'promptId', {
get: () => parentId,
configurable: true,
});
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
@@ -2052,7 +2062,7 @@ describe('LocalAgentExecutor', () => {
vi.spyOn(configWithHints, 'getAgentRegistry').mockReturnValue({
getAllAgentNames: () => [],
} as unknown as AgentRegistry);
vi.spyOn(configWithHints, 'getToolRegistry').mockReturnValue(
vi.spyOn(configWithHints, 'toolRegistry', 'get').mockReturnValue(
parentToolRegistry,
);
});
+33 -30
View File
@@ -5,6 +5,7 @@
*/
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import {
@@ -92,12 +93,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private readonly agentId: string;
private readonly toolRegistry: ToolRegistry;
private readonly runtimeContext: Config;
private readonly context: AgentLoopContext;
private readonly onActivity?: ActivityCallback;
private readonly compressionService: ChatCompressionService;
private readonly parentCallId?: string;
private hasFailedCompressionAttempt = false;
private get config(): Config {
return this.context.config;
}
/**
* Creates and validates a new `AgentExecutor` instance.
*
@@ -105,16 +110,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
* safe for non-interactive use before creating the executor.
*
* @param definition The definition object for the agent.
* @param runtimeContext The global runtime configuration.
* @param context The execution context.
* @param onActivity An optional callback to receive activity events.
* @returns A promise that resolves to a new `LocalAgentExecutor` instance.
*/
static async create<TOutput extends z.ZodTypeAny>(
definition: LocalAgentDefinition<TOutput>,
runtimeContext: Config,
context: AgentLoopContext,
onActivity?: ActivityCallback,
): Promise<LocalAgentExecutor<TOutput>> {
const parentMessageBus = runtimeContext.getMessageBus();
const parentMessageBus = context.messageBus;
// Create an override object to inject the subagent name into tool confirmation requests
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -133,12 +138,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// Create an isolated tool registry for this agent instance.
const agentToolRegistry = new ToolRegistry(
runtimeContext,
context.config,
subagentMessageBus,
);
const parentToolRegistry = runtimeContext.getToolRegistry();
const parentToolRegistry = context.toolRegistry;
const allAgentNames = new Set(
runtimeContext.getAgentRegistry().getAllAgentNames(),
context.config.getAgentRegistry().getAllAgentNames(),
);
const registerToolByName = (toolName: string) => {
@@ -190,7 +195,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
agentToolRegistry.sortTools();
// Get the parent prompt ID from context
const parentPromptId = promptIdContext.getStore();
const parentPromptId = context.promptId;
// Get the parent tool call ID from context
const toolContext = getToolCallContext();
@@ -198,7 +203,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return new LocalAgentExecutor(
definition,
runtimeContext,
context,
agentToolRegistry,
parentPromptId,
parentCallId,
@@ -214,14 +219,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
*/
private constructor(
definition: LocalAgentDefinition<TOutput>,
runtimeContext: Config,
context: AgentLoopContext,
toolRegistry: ToolRegistry,
parentPromptId: string | undefined,
parentCallId: string | undefined,
onActivity?: ActivityCallback,
) {
this.definition = definition;
this.runtimeContext = runtimeContext;
this.context = context;
this.toolRegistry = toolRegistry;
this.onActivity = onActivity;
this.compressionService = new ChatCompressionService();
@@ -418,7 +423,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
} finally {
clearTimeout(graceTimeoutId);
logRecoveryAttempt(
this.runtimeContext,
this.config,
new RecoveryAttemptEvent(
this.agentId,
this.definition.name,
@@ -466,7 +471,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
logAgentStart(
this.runtimeContext,
this.config,
new AgentStartEvent(this.agentId, this.definition.name),
);
@@ -477,7 +482,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const augmentedInputs = {
...inputs,
cliVersion: await getVersion(),
activeModel: this.runtimeContext.getActiveModel(),
activeModel: this.config.getActiveModel(),
today: new Date().toLocaleDateString(),
};
@@ -494,13 +499,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// Capture the index of the last hint before starting to avoid re-injecting old hints.
// NOTE: Hints added AFTER this point will be broadcast to all currently running
// local agents via the listener below.
const startIndex =
this.runtimeContext.userHintService.getLatestHintIndex();
this.runtimeContext.userHintService.onUserHint(hintListener);
const startIndex = this.config.userHintService.getLatestHintIndex();
this.config.userHintService.onUserHint(hintListener);
try {
const initialHints =
this.runtimeContext.userHintService.getUserHintsAfter(startIndex);
this.config.userHintService.getUserHintsAfter(startIndex);
const formattedInitialHints = formatUserHintsForModel(initialHints);
let currentMessage: Content = formattedInitialHints
@@ -561,7 +565,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
}
}
} finally {
this.runtimeContext.userHintService.offUserHint(hintListener);
this.config.userHintService.offUserHint(hintListener);
}
// === UNIFIED RECOVERY BLOCK ===
@@ -674,7 +678,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
} finally {
deadlineTimer.abort();
logAgentFinish(
this.runtimeContext,
this.config,
new AgentFinishEvent(
this.agentId,
this.definition.name,
@@ -697,7 +701,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
prompt_id,
false,
model,
this.runtimeContext,
this.config,
this.hasFailedCompressionAttempt,
);
@@ -735,11 +739,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const modelConfigAlias = getModelConfigAlias(this.definition);
// Resolve the model config early to get the concrete model string (which may be `auto`).
const resolvedConfig =
this.runtimeContext.modelConfigService.getResolvedConfig({
model: modelConfigAlias,
overrideScope: this.definition.name,
});
const resolvedConfig = this.config.modelConfigService.getResolvedConfig({
model: modelConfigAlias,
overrideScope: this.definition.name,
});
const requestedModel = resolvedConfig.model;
let modelToUse: string;
@@ -756,7 +759,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
signal,
requestedModel,
};
const router = this.runtimeContext.getModelRouterService();
const router = this.config.getModelRouterService();
const decision = await router.route(routingContext);
modelToUse = decision.model;
} catch (error) {
@@ -844,7 +847,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
try {
return new GeminiChat(
this.runtimeContext,
this.config,
systemInstruction,
[{ functionDeclarations: tools }],
startHistory,
@@ -1092,7 +1095,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// Execute standard tool calls using the new scheduler
if (toolRequests.length > 0) {
const completedCalls = await scheduleAgentTools(
this.runtimeContext,
this.config,
toolRequests,
{
schedulerId: this.agentId,
@@ -1240,7 +1243,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
let finalPrompt = templateString(promptConfig.systemPrompt, inputs);
// Append environment context (CWD and folder structure).
const dirContext = await getDirectoryContextString(this.runtimeContext);
const dirContext = await getDirectoryContextString(this.config);
finalPrompt += `\n\n# Environment Context\n${dirContext}`;
// Append standard rules for non-interactive execution.
@@ -67,6 +67,11 @@ describe('LocalSubagentInvocation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
// .config is already set correctly by the getter on the instance.
Object.defineProperty(mockConfig, 'promptId', {
get: () => 'test-prompt-id',
configurable: true,
});
mockMessageBus = createMockMessageBus();
mockExecutorInstance = {
+4 -4
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { LocalAgentExecutor } from './local-executor.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import {
@@ -43,13 +43,13 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
> {
/**
* @param definition The definition object that configures the agent.
* @param config The global runtime configuration.
* @param context The agent loop context.
* @param params The validated input parameters for the agent.
* @param messageBus Message bus for policy enforcement.
*/
constructor(
private readonly definition: LocalAgentDefinition,
private readonly config: Config,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
_toolName?: string,
@@ -223,7 +223,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const executor = await LocalAgentExecutor.create(
this.definition,
this.config,
this.context,
onActivity,
);
+108 -1
View File
@@ -48,6 +48,8 @@ vi.mock('./a2a-client-manager.js', () => ({
vi.mock('./auth-provider/factory.js', () => ({
A2AAuthProviderFactory: {
create: vi.fn(),
validateAuthConfig: vi.fn().mockReturnValue({ valid: true }),
describeRequiredAuth: vi.fn().mockReturnValue('API key required'),
},
}));
@@ -665,6 +667,111 @@ describe('AgentRegistry', () => {
);
});
it('should emit error feedback with userMessage when A2AAgentError is thrown', async () => {
const { AgentConnectionError } = await import('./a2a-errors.js');
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'FailAgent',
description: 'An agent that fails to load',
agentCardUrl: 'https://unreachable.example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
const a2aError = new AgentConnectionError(
'FailAgent',
'https://unreachable.example.com/card',
new Error('ECONNREFUSED'),
);
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockRejectedValue(a2aError),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
expect(feedbackSpy).toHaveBeenCalledWith(
'error',
`[FailAgent] ${a2aError.userMessage}`,
);
expect(registry.getDefinition('FailAgent')).toBeUndefined();
});
it('should emit generic error feedback for non-A2AAgentError failures', async () => {
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'FailAgent',
description: 'An agent that fails',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockRejectedValue(new Error('unexpected crash')),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
expect(feedbackSpy).toHaveBeenCalledWith(
'error',
'[FailAgent] Failed to load remote agent: unexpected crash',
);
expect(registry.getDefinition('FailAgent')).toBeUndefined();
});
it('should emit warning feedback when auth config is missing for secured agent', async () => {
const feedbackSpy = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
vi.mocked(A2AAuthProviderFactory.validateAuthConfig).mockReturnValue({
valid: false,
diff: { requiredSchemes: ['api_key'], missingConfig: ['api_key'] },
});
vi.mocked(A2AAuthProviderFactory.describeRequiredAuth).mockReturnValue(
'apiKey (header: x-api-key)',
);
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'SecuredAgent',
description: 'A secured remote agent',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
// No auth configured
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({
name: 'SecuredAgent',
securitySchemes: {
api_key: {
type: 'apiKey',
in: 'header',
name: 'x-api-key',
},
},
}),
} as unknown as A2AClientManager);
await registry.testRegisterAgent(remoteAgent);
// Agent should still be registered (ADC fallback)
expect(registry.getDefinition('SecuredAgent')).toBeDefined();
// But a warning should have been emitted
expect(feedbackSpy).toHaveBeenCalledWith(
'warning',
expect.stringContaining('SecuredAgent'),
);
});
it('should surface an error if remote agent registration fails', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
@@ -685,7 +792,7 @@ describe('AgentRegistry', () => {
expect(feedbackSpy).toHaveBeenCalledWith(
'error',
`Error loading A2A agent "FailingRemoteAgent": 401 Unauthorized`,
`[FailingRemoteAgent] Failed to load remote agent: 401 Unauthorized`,
);
});
+45 -4
View File
@@ -24,6 +24,7 @@ import {
ModelConfigService,
} from '../services/modelConfigService.js';
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js';
/**
* Returns the model config alias for a given agent definition.
@@ -366,6 +367,9 @@ export class AgentRegistry {
/**
* Registers a remote agent definition asynchronously.
* Provides robust error handling with user-friendly messages for:
* - Agent card fetch failures (404, 401/403, network errors)
* - Missing authentication configuration
*/
protected async registerRemoteAgent<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
@@ -408,7 +412,7 @@ export class AgentRegistry {
remoteDef.originalDescription = remoteDef.description;
}
// Log remote A2A agent registration for visibility.
// Load the remote A2A agent card and register.
try {
const clientManager = A2AClientManager.getInstance();
let authHandler: AuthenticationHandler | undefined;
@@ -432,6 +436,30 @@ export class AgentRegistry {
authHandler,
);
// Validate auth configuration against the agent card's security schemes.
if (agentCard.securitySchemes) {
const validation = A2AAuthProviderFactory.validateAuthConfig(
definition.auth,
agentCard.securitySchemes,
);
if (!validation.valid && validation.diff) {
const requiredAuth = A2AAuthProviderFactory.describeRequiredAuth(
agentCard.securitySchemes,
);
const authError = new AgentAuthConfigMissingError(
definition.name,
requiredAuth,
validation.diff.missingConfig,
);
coreEvents.emitFeedback(
'warning',
`[${definition.name}] Agent requires authentication: ${requiredAuth}`,
);
debugLogger.warn(`[AgentRegistry] ${authError.message}`);
// Still register the agent — the user can fix config and retry.
}
}
const userDescription = remoteDef.originalDescription;
const agentDescription = agentCard.description;
const descriptions: string[] = [];
@@ -464,9 +492,22 @@ export class AgentRegistry {
this.agents.set(definition.name, definition);
this.addAgentPolicy(definition);
} catch (e) {
const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e);
coreEvents.emitFeedback('error', errorMessage);
// Surface structured, user-friendly error messages for known failure modes.
if (e instanceof A2AAgentError) {
coreEvents.emitFeedback(
'error',
`[${definition.name}] ${e.userMessage}`,
);
} else {
coreEvents.emitFeedback(
'error',
`[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`,
);
}
debugLogger.warn(
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
e,
);
}
}
@@ -613,4 +613,75 @@ describe('RemoteAgentInvocation', () => {
}
});
});
describe('Error Handling', () => {
it('should use A2AAgentError.userMessage for structured errors', async () => {
const { AgentConnectionError } = await import('./a2a-errors.js');
const a2aError = new AgentConnectionError(
'test-agent',
'http://test-agent/card',
new Error('ECONNREFUSED'),
);
mockClientManager.getClient.mockReturnValue(undefined);
mockClientManager.loadAgent.mockRejectedValue(a2aError);
const invocation = new RemoteAgentInvocation(
mockDefinition,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.returnDisplay).toContain(a2aError.userMessage);
});
it('should use generic message for non-A2AAgentError errors', async () => {
mockClientManager.getClient.mockReturnValue(undefined);
mockClientManager.loadAgent.mockRejectedValue(
new Error('something unexpected'),
);
const invocation = new RemoteAgentInvocation(
mockDefinition,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.returnDisplay).toContain(
'Error calling remote agent: something unexpected',
);
});
it('should include partial output when error occurs mid-stream', async () => {
mockClientManager.getClient.mockReturnValue({});
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield {
kind: 'message',
messageId: 'msg-1',
role: 'agent',
parts: [{ kind: 'text', text: 'Partial response' }],
};
// Raw errors propagate from the A2A SDK — no wrapping or classification.
throw new Error('connection reset');
},
);
const invocation = new RemoteAgentInvocation(
mockDefinition,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
// Should contain both the partial output and the error message
expect(result.returnDisplay).toContain('Partial response');
expect(result.returnDisplay).toContain('connection reset');
});
});
});
+21 -1
View File
@@ -28,6 +28,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
/**
* Authentication handler implementation using Google Application Default Credentials (ADC).
@@ -228,7 +229,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
};
} catch (error: unknown) {
const partialOutput = reassembler.toString();
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
// Surface structured, user-friendly error messages.
const errorMessage = this.formatExecutionError(error);
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
@@ -245,4 +247,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
});
}
}
/**
* Formats an execution error into a user-friendly message.
* Recognizes typed A2AAgentError subclasses and falls back to
* a generic message for unknown errors.
*/
private formatExecutionError(error: unknown): string {
// All A2A-specific errors include a human-friendly `userMessage` on the
// A2AAgentError base class. Rely on that to avoid duplicating messages
// for specific subclasses, which improves maintainability.
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
@@ -56,6 +56,11 @@ describe('SubagentToolWrapper', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
// .config is already set correctly by the getter on the instance.
Object.defineProperty(mockConfig, 'promptId', {
get: () => 'test-prompt-id',
configurable: true,
});
mockMessageBus = createMockMessageBus();
});
@@ -11,6 +11,7 @@ import {
type ToolResult,
} from '../tools/tools.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
@@ -33,12 +34,12 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
* parameters based on the subagent's input configuration.
*
* @param definition The `AgentDefinition` of the subagent to wrap.
* @param config The runtime configuration, passed down to the subagent.
* @param context The execution context.
* @param messageBus Optional message bus for policy enforcement.
*/
constructor(
private readonly definition: AgentDefinition,
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
super(
@@ -53,6 +54,10 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
);
}
private get config(): Config {
return this.context.config;
}
/**
* Creates an invocation instance for executing the subagent.
*
@@ -94,7 +99,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
return new LocalSubagentInvocation(
definition,
this.config,
this.context,
params,
effectiveMessageBus,
_toolName,
+13 -3
View File
@@ -77,6 +77,11 @@ describe('SubAgentInvocation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
// .config is already set correctly by the getter on the instance.
Object.defineProperty(mockConfig, 'promptId', {
get: () => 'test-prompt-id',
configurable: true,
});
mockMessageBus = createMockMessageBus();
mockInnerInvocation = {
shouldConfirmExecute: vi.fn(),
@@ -339,6 +344,11 @@ describe('SubagentTool Read-Only logic', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
// .config is already set correctly by the getter on the instance.
Object.defineProperty(mockConfig, 'promptId', {
get: () => 'test-prompt-id',
configurable: true,
});
mockMessageBus = createMockMessageBus();
});
@@ -359,7 +369,7 @@ describe('SubagentTool Read-Only logic', () => {
const registry = {
getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined),
};
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue(
registry as unknown as ToolRegistry,
);
@@ -387,7 +397,7 @@ describe('SubagentTool Read-Only logic', () => {
return undefined;
},
};
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue(
registry as unknown as ToolRegistry,
);
@@ -401,7 +411,7 @@ describe('SubagentTool Read-Only logic', () => {
it('should be true for local agent with no tools', () => {
const registry = { getTool: () => undefined };
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(
vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue(
registry as unknown as ToolRegistry,
);
+13 -8
View File
@@ -15,6 +15,7 @@ import {
type ToolLiveOutput,
} from '../tools/tools.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
@@ -30,7 +31,7 @@ import {
export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
constructor(
private readonly definition: AgentDefinition,
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
) {
const inputSchema = definition.inputConfig.inputSchema;
@@ -65,20 +66,20 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
// This is an invariant: you can't check read-only status if the system isn't initialized.
this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly(
this.definition,
this.config,
this.context,
);
return this._memoizedIsReadOnly;
}
private static checkIsReadOnly(
definition: AgentDefinition,
config: Config,
context: AgentLoopContext,
): boolean {
if (definition.kind === 'remote') {
return false;
}
const tools = definition.toolConfig?.tools ?? [];
const registry = config.getToolRegistry();
const registry = context.toolRegistry;
if (!registry) {
return false;
@@ -111,7 +112,7 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
return new SubAgentInvocation(
params,
this.definition,
this.config,
this.context,
messageBus,
_toolName,
_toolDisplayName,
@@ -125,7 +126,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
constructor(
params: AgentInputs,
private readonly definition: AgentDefinition,
private readonly config: Config,
private readonly context: AgentLoopContext,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
@@ -136,7 +137,11 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
_toolName ?? definition.name,
_toolDisplayName ?? definition.displayName ?? definition.name,
);
this.startIndex = config.userHintService.getLatestHintIndex();
this.startIndex = context.config.userHintService.getLatestHintIndex();
}
private get config(): Config {
return this.context.config;
}
getDescription(): string {
@@ -220,7 +225,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
): ToolInvocation<AgentInputs, ToolResult> {
const wrapper = new SubagentToolWrapper(
definition,
this.config,
this.context,
this.messageBus,
);
@@ -7,12 +7,16 @@
import type { GeminiClient } from '../core/client.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { Config } from './config.js';
/**
* AgentLoopContext represents the execution-scoped view of the world for a single
* agent turn or sub-agent loop.
*/
export interface AgentLoopContext {
/** The global runtime configuration. */
readonly config: Config;
/** The unique ID for the current user turn or agent thought loop. */
readonly promptId: string;
+10 -2
View File
@@ -631,7 +631,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly targetDir: string;
private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly question: string | undefined;
private question: string | undefined;
readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined;
@@ -1024,7 +1024,7 @@ export class Config implements McpContext, AgentLoopContext {
params.gemmaModelRouter?.classifier?.model ?? 'gemma3-1b-gpu-custom',
},
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
@@ -1100,6 +1100,10 @@ export class Config implements McpContext, AgentLoopContext {
);
}
get config(): Config {
return this;
}
isInitialized(): boolean {
return this.initialized;
}
@@ -1665,6 +1669,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.question;
}
clearQuestion(): void {
this.question = undefined;
}
getHasAccessToPreviewModel(): boolean {
return this.hasAccessToPreviewModel !== false;
}
+4
View File
@@ -98,6 +98,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), 'policies');
}
static getUserKeybindingsPath(): string {
return path.join(Storage.getGlobalGeminiDir(), 'keybindings.json');
}
static getUserAgentsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'agents');
}
@@ -118,6 +118,8 @@ describe('BaseLlmClient', () => {
.mockReturnValue(createAvailabilityServiceMock()),
setActiveModel: vi.fn(),
getUserTier: vi.fn().mockReturnValue(undefined),
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getMaxAttempts: vi.fn().mockReturnValue(3),
getModel: vi.fn().mockReturnValue('test-model'),
getActiveModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Mocked<Config>;
+13
View File
@@ -21,6 +21,8 @@ import { getErrorMessage } from '../utils/errors.js';
import { logMalformedJsonResponse } from '../telemetry/loggers.js';
import { MalformedJsonResponseEvent, LlmRole } from '../telemetry/types.js';
import { retryWithBackoff } from '../utils/retry.js';
import { coreEvents } from '../utils/events.js';
import { getDisplayString } from '../config/models.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import {
applyModelSelection,
@@ -327,6 +329,17 @@ export class BaseLlmClient {
: undefined,
authType:
this.authType ?? this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts:
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
delayMs,
error: error instanceof Error ? error.message : String(error),
model: getDisplayString(currentModel),
});
},
});
} catch (error) {
if (abortSignal?.aborted) {
+13 -1
View File
@@ -23,6 +23,7 @@ import {
} from './contentGenerator.js';
import { GeminiChat } from './geminiChat.js';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import {
CompressionStatus,
GeminiEventType,
@@ -234,6 +235,8 @@ describe('Gemini Client (client.ts)', () => {
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
}),
getGeminiClient: vi.fn(),
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getMaxAttempts: vi.fn().mockReturnValue(3),
getModelRouterService: vi
.fn()
.mockReturnValue(mockRouterService as unknown as ModelRouterService),
@@ -278,7 +281,16 @@ describe('Gemini Client (client.ts)', () => {
} as unknown as Config;
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
client = new GeminiClient(mockConfig);
(
mockConfig as unknown as { toolRegistry: typeof mockToolRegistry }
).toolRegistry = mockToolRegistry;
(mockConfig as unknown as { messageBus: undefined }).messageBus = undefined;
(mockConfig as unknown as { config: Config; promptId: string }).config =
mockConfig;
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
await client.initialize();
vi.mocked(mockConfig.getGeminiClient).mockReturnValue(client);
+27 -7
View File
@@ -25,10 +25,17 @@ import {
type ChatCompressionInfo,
} from './turn.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from './prompts.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import {
getDisplayString,
resolveModel,
isGemini2Model,
} from '../config/models.js';
import {
retryWithBackoff,
type RetryAvailabilityContext,
@@ -69,9 +76,7 @@ import {
applyModelSelection,
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { resolveModel, isGemini2Model } from '../config/models.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
const MAX_TURNS = 100;
@@ -105,8 +110,8 @@ export class GeminiClient {
*/
private hasFailedCompressionAttempt = false;
constructor(private readonly config: Config) {
this.loopDetector = new LoopDetectionService(config);
constructor(private readonly context: AgentLoopContext) {
this.loopDetector = new LoopDetectionService(this.config);
this.compressionService = new ChatCompressionService();
this.toolOutputMaskingService = new ToolOutputMaskingService();
this.lastPromptId = this.config.getSessionId();
@@ -114,6 +119,10 @@ export class GeminiClient {
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
}
private get config(): Config {
return this.context.config;
}
private handleModelChanged = () => {
this.currentSequenceModel = null;
};
@@ -281,7 +290,7 @@ export class GeminiClient {
}
this.lastUsedModelId = modelId;
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
this.getChat().setTools(tools);
@@ -345,7 +354,7 @@ export class GeminiClient {
this.hasFailedCompressionAttempt = false;
this.lastUsedModelId = undefined;
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const toolDeclarations = toolRegistry.getFunctionDeclarations();
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
@@ -362,7 +371,7 @@ export class GeminiClient {
resumedSessionData,
async (modelId: string) => {
this.lastUsedModelId = modelId;
const toolRegistry = this.config.getToolRegistry();
const toolRegistry = this.context.toolRegistry;
const toolDeclarations =
toolRegistry.getFunctionDeclarations(modelId);
return [{ functionDeclarations: toolDeclarations }];
@@ -1088,7 +1097,18 @@ export class GeminiClient {
onValidationRequired: onValidationRequiredCallback,
authType: this.config.getContentGeneratorConfig()?.authType,
maxAttempts: availabilityMaxAttempts,
retryFetchErrors: this.config.getRetryFetchErrors(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts:
availabilityMaxAttempts ?? this.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: getDisplayString(currentAttemptModel),
});
},
});
return result;
+1 -1
View File
@@ -133,7 +133,7 @@ export class CoreToolScheduler {
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor;
this.toolExecutor = new ToolExecutor(this.config, this.config);
this.toolExecutor = new ToolExecutor(this.config);
this.toolModifier = new ToolModificationHandler();
// Subscribe to message bus for ASK_USER policy decisions
@@ -59,7 +59,6 @@ vi.mock('../utils/debugLogger.js', () => ({
const MOCK_PRO_MODEL = DEFAULT_GEMINI_MODEL;
const FALLBACK_MODEL = DEFAULT_GEMINI_FLASH_MODEL;
const AUTH_OAUTH = AuthType.LOGIN_WITH_GOOGLE;
const AUTH_API_KEY = AuthType.USE_GEMINI;
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
@@ -129,16 +128,6 @@ describe('handleFallback', () => {
);
});
it('should return null immediately if authType is not OAuth', async () => {
const result = await handleFallback(
policyConfig,
MOCK_PRO_MODEL,
AUTH_API_KEY,
);
expect(result).toBeNull();
expect(policyHandler).not.toHaveBeenCalled();
});
it('uses availability selection with correct candidates when enabled', async () => {
// Direct mock manipulation since it's already a vi.fn()
vi.mocked(policyConfig.getModel).mockReturnValue(
-5
View File
@@ -5,7 +5,6 @@
*/
import type { Config } from '../config/config.js';
import { AuthType } from '../core/contentGenerator.js';
import {
openBrowserSecurely,
shouldLaunchBrowser,
@@ -29,10 +28,6 @@ export async function handleFallback(
authType?: string,
error?: unknown,
): Promise<string | boolean | null> {
if (authType !== AuthType.LOGIN_WITH_GOOGLE) {
return null;
}
const chain = resolvePolicyChain(config);
const { failedPolicy, candidates } = buildFallbackPolicyContext(
chain,
+304
View File
@@ -0,0 +1,304 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
resolvePathFromEnv,
isSectionEnabled,
applySubstitutions,
} from './utils.js';
import type { Config } from '../config/config.js';
vi.mock('../utils/paths.js', () => ({
homedir: vi.fn().mockReturnValue('/mock/home'),
}));
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: {
warn: vi.fn(),
},
}));
vi.mock('./snippets.js', () => ({
renderSubAgents: vi.fn().mockReturnValue('mocked-sub-agents'),
}));
vi.mock('./snippets.legacy.js', () => ({
renderSubAgents: vi.fn().mockReturnValue('mocked-legacy-sub-agents'),
}));
describe('resolvePathFromEnv', () => {
it('should return default values for undefined input', () => {
const result = resolvePathFromEnv(undefined);
expect(result).toEqual({
isSwitch: false,
value: null,
isDisabled: false,
});
});
it('should return default values for empty string input', () => {
const result = resolvePathFromEnv('');
expect(result).toEqual({
isSwitch: false,
value: null,
isDisabled: false,
});
});
it('should return default values for whitespace-only input', () => {
const result = resolvePathFromEnv(' ');
expect(result).toEqual({
isSwitch: false,
value: null,
isDisabled: false,
});
});
it('should recognize "true" as an enabled switch', () => {
const result = resolvePathFromEnv('true');
expect(result).toEqual({
isSwitch: true,
value: 'true',
isDisabled: false,
});
});
it('should recognize "1" as an enabled switch', () => {
const result = resolvePathFromEnv('1');
expect(result).toEqual({
isSwitch: true,
value: '1',
isDisabled: false,
});
});
it('should recognize "false" as a disabled switch', () => {
const result = resolvePathFromEnv('false');
expect(result).toEqual({
isSwitch: true,
value: 'false',
isDisabled: true,
});
});
it('should recognize "0" as a disabled switch', () => {
const result = resolvePathFromEnv('0');
expect(result).toEqual({
isSwitch: true,
value: '0',
isDisabled: true,
});
});
it('should handle case-insensitive switch values', () => {
const result = resolvePathFromEnv('TRUE');
expect(result).toEqual({
isSwitch: true,
value: 'true',
isDisabled: false,
});
});
it('should handle case-insensitive FALSE', () => {
const result = resolvePathFromEnv('FALSE');
expect(result).toEqual({
isSwitch: true,
value: 'false',
isDisabled: true,
});
});
it('should trim whitespace before evaluating switch values', () => {
const result = resolvePathFromEnv(' true ');
expect(result).toEqual({
isSwitch: true,
value: 'true',
isDisabled: false,
});
});
it('should resolve a regular path', () => {
const result = resolvePathFromEnv('/some/absolute/path');
expect(result.isSwitch).toBe(false);
expect(result.value).toBe('/some/absolute/path');
expect(result.isDisabled).toBe(false);
});
it('should resolve a tilde path to the home directory', () => {
const result = resolvePathFromEnv('~/my/custom/path');
expect(result.isSwitch).toBe(false);
expect(result.value).toContain('/mock/home');
expect(result.value).toContain('my/custom/path');
expect(result.isDisabled).toBe(false);
});
it('should resolve a bare tilde to the home directory', () => {
const result = resolvePathFromEnv('~');
expect(result.isSwitch).toBe(false);
expect(result.value).toBe('/mock/home');
expect(result.isDisabled).toBe(false);
});
it('should handle home directory resolution failure gracefully', async () => {
const { homedir } = await import('../utils/paths.js');
vi.mocked(homedir).mockImplementationOnce(() => {
throw new Error('No home directory');
});
const result = resolvePathFromEnv('~/some/path');
expect(result).toEqual({
isSwitch: false,
value: null,
isDisabled: false,
});
});
});
describe('isSectionEnabled', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should return true when the env var is not set', () => {
expect(isSectionEnabled('SOME_KEY')).toBe(true);
});
it('should return true when the env var is set to "1"', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', '1');
expect(isSectionEnabled('SOME_KEY')).toBe(true);
});
it('should return true when the env var is set to "true"', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', 'true');
expect(isSectionEnabled('SOME_KEY')).toBe(true);
});
it('should return false when the env var is set to "0"', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', '0');
expect(isSectionEnabled('SOME_KEY')).toBe(false);
});
it('should return false when the env var is set to "false"', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', 'false');
expect(isSectionEnabled('SOME_KEY')).toBe(false);
});
it('should handle case-insensitive key conversion', () => {
vi.stubEnv('GEMINI_PROMPT_MY_SECTION', '0');
expect(isSectionEnabled('my_section')).toBe(false);
});
it('should handle whitespace around the env var value', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', ' false ');
expect(isSectionEnabled('SOME_KEY')).toBe(false);
});
it('should return true for any non-falsy value', () => {
vi.stubEnv('GEMINI_PROMPT_SOME_KEY', 'enabled');
expect(isSectionEnabled('SOME_KEY')).toBe(true);
});
});
describe('applySubstitutions', () => {
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
getAgentRegistry: vi.fn().mockReturnValue({
getAllDefinitions: vi.fn().mockReturnValue([]),
}),
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
});
it('should replace ${AgentSkills} with the skills prompt', () => {
const result = applySubstitutions(
'Skills: ${AgentSkills}',
mockConfig,
'my-skills-content',
);
expect(result).toBe('Skills: my-skills-content');
});
it('should replace multiple ${AgentSkills} occurrences', () => {
const result = applySubstitutions(
'${AgentSkills} and ${AgentSkills}',
mockConfig,
'skills',
);
expect(result).toBe('skills and skills');
});
it('should replace ${SubAgents} with rendered sub-agents content', () => {
const result = applySubstitutions(
'Agents: ${SubAgents}',
mockConfig,
'',
true,
);
expect(result).toContain('mocked-sub-agents');
});
it('should use legacy snippets when isGemini3 is false', () => {
const result = applySubstitutions(
'Agents: ${SubAgents}',
mockConfig,
'',
false,
);
expect(result).toContain('mocked-legacy-sub-agents');
});
it('should replace ${AvailableTools} with tool names list', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(['read_file', 'write_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
const result = applySubstitutions(
'Tools: ${AvailableTools}',
mockConfig,
'',
);
expect(result).toContain('- read_file');
expect(result).toContain('- write_file');
});
it('should show no tools message when no tools available', () => {
const result = applySubstitutions(
'Tools: ${AvailableTools}',
mockConfig,
'',
);
expect(result).toContain('No tools are currently available.');
});
it('should replace tool-specific ${toolName_ToolName} variables', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue(['read_file']),
getAllTools: vi.fn().mockReturnValue([]),
} as unknown as ReturnType<Config['getToolRegistry']>);
const result = applySubstitutions(
'Use ${read_file_ToolName} to read',
mockConfig,
'',
);
expect(result).toBe('Use read_file to read');
});
it('should handle a prompt with no substitution placeholders', () => {
const result = applySubstitutions(
'A plain prompt with no variables.',
mockConfig,
'',
);
expect(result).toBe('A plain prompt with no variables.');
});
});

Some files were not shown because too many files have changed in this diff Show More