Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae474d68f | |||
| 88638c14f7 | |||
| c2d38bac54 | |||
| f16f13fba8 | |||
| e54c450bc1 | |||
| 075e0b1a81 | |||
| f8ad3a200a | |||
| 8b09ccc288 | |||
| 5020d8fa57 | |||
| 3a0c9a0d39 | |||
| 20a226a5ab | |||
| daf3701194 | |||
| 657f19c1f3 | |||
| 524679d23c | |||
| 7c4570339e | |||
| fa26a2cb56 | |||
| f1974faac6 | |||
| 5b6fbcd0af | |||
| 2aea682c8f | |||
| 89f2afef02 | |||
| e824cb897a | |||
| 7766b36411 | |||
| fa1e95b360 | |||
| b3b5df96b7 | |||
| 84c3ee1f05 | |||
| 8849319e8b | |||
| 0b3653262c | |||
| fff88e794d | |||
| 395af2a1e4 | |||
| a45e42cf30 | |||
| 0d2ad092ec | |||
| 72068d40b6 |
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||