Compare commits

...

10 Commits

Author SHA1 Message Date
Shreya Keshive 5be9172ad5
fix(ide): preserve focus when showing diff view (#6795) 2025-08-22 02:24:45 +00:00
Gal Zahavi 14ca687c05
test(integration-tests): isolate user memory from test runs (#6790) 2025-08-22 00:34:13 +00:00
Tommaso Sciortino 15c62bade3
Reuse CoreToolScheduler for nonInteractiveToolExecutor (#6714) 2025-08-21 23:49:12 +00:00
Jacob Richman 29699274bb
feat(settings) support editing string settings. (#6732) 2025-08-21 23:43:56 +00:00
christine betts 10286934e6
Introduce initial screen reader mode handling and flag (#6653) 2025-08-21 22:29:15 +00:00
Ricardo Fabbri 679acc45b2
fix(docs): path of chat checkpoints in manual (#6303)
Co-authored-by: Arya Gummadi <aryagummadi@google.com>
2025-08-21 22:27:23 +00:00
Billy Biggs 2dd15572ea
Support IDE connections via stdio MCP (#6417) 2025-08-21 22:00:05 +00:00
joshualitt ec41b8db8e
feat(core): Annotate remaining error paths in tools with type. (#6699) 2025-08-21 21:40:18 +00:00
Gaurav 299bf58309
fix: handle extra text in gemini output for dedup workflow (#6771) 2025-08-21 20:40:44 +00:00
Victor May 720eb81890
At Command Race Condition Bugfix For Non-Interactive Mode (#6676) 2025-08-21 18:47:40 +00:00
51 changed files with 1369 additions and 608 deletions

View File

@ -185,8 +185,13 @@ jobs:
core.info(`Raw duplicates JSON: ${rawJson}`);
let parsedJson;
try {
const trimmedJson = rawJson.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '').trim();
parsedJson = JSON.parse(trimmedJson);
const jsonStringMatch = rawJson.match(/{[\s\S]*}/);
if (!jsonStringMatch) {
core.setFailed(`Could not find JSON object in the output.\nRaw output: ${rawJson}`);
return;
}
const jsonString = jsonStringMatch[0];
parsedJson = JSON.parse(jsonString);
core.info(`Parsed duplicates JSON: ${JSON.stringify(parsedJson)}`);
} catch (err) {
core.setFailed(`Failed to parse duplicates JSON from Gemini output: ${err.message}\nRaw output: ${rawJson}`);

View File

@ -18,8 +18,8 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
- Linux/macOS: `~/.gemini/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\.gemini\tmp\<project_hash>\`
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
- **`resume`**

View File

@ -308,6 +308,20 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"showLineNumbers": false
```
- **`accessibility`** (object):
- **Description:** Configures accessibility features for the CLI.
- **Properties:**
- **`screenReader`** (boolean): Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. This can also be enabled with the `--screen-reader` command-line flag, which will take precedence over the setting.
- **`disableLoadingPhrases`** (boolean): Disables the display of loading phrases during operations.
- **Default:** `{"screenReader": false, "disableLoadingPhrases": false}`
- **Example:**
```json
"accessibility": {
"screenReader": true,
"disableLoadingPhrases": true
}
```
### Example `settings.json`:
```json
@ -475,6 +489,8 @@ Arguments passed directly when running the CLI can override other configurations
- Can be specified multiple times or as comma-separated values.
- 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--screen-reader`**:
- Enables screen reader mode for accessibility.
- **`--version`**:
- Displays the version of the CLI.

View File

@ -9,16 +9,38 @@ if (process.env.NO_COLOR !== undefined) {
delete process.env.NO_COLOR;
}
import { mkdir, readdir, rm } from 'fs/promises';
import { mkdir, readdir, rm, readFile, writeFile, unlink } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import * as os from 'os';
import {
GEMINI_CONFIG_DIR,
DEFAULT_CONTEXT_FILENAME,
} from '../packages/core/src/tools/memoryTool.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const integrationTestsDir = join(rootDir, '.integration-tests');
let runDir = ''; // Make runDir accessible in teardown
const memoryFilePath = join(
os.homedir(),
GEMINI_CONFIG_DIR,
DEFAULT_CONTEXT_FILENAME,
);
let originalMemoryContent: string | null = null;
export async function setup() {
try {
originalMemoryContent = await readFile(memoryFilePath, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
throw e;
}
// File doesn't exist, which is fine.
}
runDir = join(integrationTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true });
@ -57,4 +79,15 @@ export async function teardown() {
if (process.env.KEEP_OUTPUT !== 'true' && runDir) {
await rm(runDir, { recursive: true, force: true });
}
if (originalMemoryContent !== null) {
await mkdir(dirname(memoryFilePath), { recursive: true });
await writeFile(memoryFilePath, originalMemoryContent, 'utf-8');
} else {
try {
await unlink(memoryFilePath);
} catch {
// File might not exist if the test failed before creating it.
}
}
}

View File

@ -4,5 +4,6 @@
"noEmit": true,
"allowJs": true
},
"include": ["**/*.ts"]
"include": ["**/*.ts"],
"references": [{ "path": "../packages/core" }]
}

88
package-lock.json generated
View File

@ -63,16 +63,16 @@
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz",
"integrity": "sha512-qI/5TaaaCZE4yeSZ83lu0+xi1r88JSxUjnH4OP/iZF7+KKZ75u3ee5isd0LxX+6N8U0npL61YrpbthILHB6BnA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0"
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=14.13.1"
"node": ">=18"
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
@ -88,12 +88,15 @@
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
"integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.0.0"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@ -5175,9 +5178,9 @@
}
},
"node_modules/es-toolkit": {
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==",
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
"license": "MIT",
"workspaces": [
"docs",
@ -6859,26 +6862,26 @@
}
},
"node_modules/ink": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.1.1.tgz",
"integrity": "sha512-Bqw78FX+1TSIGxs6bdvohgoy6mTfqjFJVNyYzXn8HIyZyVmwLX8XdnhUtUwyaelLCqLz8uuFseCbomRZWjyo5g==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.2.tgz",
"integrity": "sha512-LN1f+/D8KKqMqRux08fIfA9wsEAJ9Bu9CiI3L6ih7bnqNSDUXT/JVJ0rUIc4NkjPiPaeI3BVNREcLYLz9ePSEg==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.3.0",
"chalk": "^5.6.0",
"cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0",
"es-toolkit": "^1.39.10",
"indent-string": "^5.0.0",
"is-in-ci": "^1.0.0",
"is-in-ci": "^2.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.32.0",
"scheduler": "^0.23.0",
"scheduler": "^0.26.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
@ -7030,9 +7033,9 @@
}
},
"node_modules/ink/node_modules/chalk": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@ -7047,6 +7050,21 @@
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT"
},
"node_modules/ink/node_modules/is-in-ci": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -9733,13 +9751,6 @@
"react": "^19.1.0"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"dev": true,
"license": "MIT"
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -9761,12 +9772,6 @@
"react": "^19.1.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@ -10224,13 +10229,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",

View File

@ -73,6 +73,7 @@ export interface CliArgs {
listExtensions: boolean | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@ -229,6 +230,11 @@ export async function parseArguments(): Promise<CliArgs> {
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.option('screen-reader', {
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
default: false,
})
.check((argv) => {
if (argv.prompt && argv['promptInteractive']) {
@ -465,6 +471,9 @@ export async function loadCliConfig(
const sandboxConfig = await loadSandboxConfig(settings, argv);
// The screen reader argument takes precedence over the accessibility setting.
const screenReader =
argv.screenReader ?? settings.accessibility?.screenReader ?? false;
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@ -490,7 +499,10 @@ export async function loadCliConfig(
argv.show_memory_usage ||
settings.showMemoryUsage ||
false,
accessibility: settings.accessibility,
accessibility: {
...settings.accessibility,
screenReader,
},
telemetry: {
enabled: argv.telemetry ?? settings.telemetry?.enabled,
target: (argv.telemetryTarget ??

View File

@ -58,6 +58,7 @@ export interface SummarizeToolOutputSettings {
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
}
export interface SettingsError {

View File

@ -206,6 +206,16 @@ export const SETTINGS_SCHEMA = {
description: 'Disable loading phrases for accessibility',
showInDialog: true,
},
screenReader: {
type: 'boolean',
label: 'Screen Reader Mode',
category: 'Accessibility',
requiresRestart: true,
default: false,
description:
'Render output in plain-text to be more screen reader accessible',
showInDialog: true,
},
},
},
checkpointing: {

View File

@ -316,7 +316,7 @@ export async function main() {
/>
</SettingsContext.Provider>
</React.StrictMode>,
{ exitOnCtrlC: false },
{ exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
);
checkForUpdates()

View File

@ -18,6 +18,7 @@ import { runNonInteractive } from './nonInteractiveCli.js';
import { vi } from 'vitest';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@ -41,7 +42,7 @@ describe('runNonInteractive', () => {
sendMessageStream: vi.Mock;
};
beforeEach(() => {
beforeEach(async () => {
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
@ -72,6 +73,14 @@ describe('runNonInteractive', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
shouldProceed: true,
}));
});
afterEach(() => {
@ -163,7 +172,8 @@ describe('runNonInteractive', () => {
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Execution failed'),
errorType: ToolErrorType.EXECUTION_FAILED,
responseParts: {
responseParts: [
{
functionResponse: {
name: 'errorTool',
response: {
@ -171,6 +181,7 @@ describe('runNonInteractive', () => {
},
},
},
],
resultDisplay: 'Execution failed',
});
const finalResponse: ServerGeminiStreamEvent[] = [
@ -273,4 +284,48 @@ describe('runNonInteractive', () => {
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
});
it('should preprocess @include commands before sending to the model', async () => {
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
const mockHandleAtCommand = vi.mocked(handleAtCommand);
// 2. Define the raw input and the expected processed output
const rawInput = 'Summarize @file.txt';
const processedParts: Part[] = [
{ text: 'Summarize @file.txt' },
{ text: '\n--- Content from referenced files ---\n' },
{ text: 'This is the content of the file.' },
{ text: '\n--- End of content ---' },
];
// 3. Setup the mock to return the processed parts
mockHandleAtCommand.mockResolvedValue({
processedQuery: processedParts,
shouldProceed: true,
});
// Mock a simple stream response from the Gemini client
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Summary complete.' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
// 4. Run the non-interactive mode with the raw input
await runNonInteractive(mockConfig, rawInput, 'prompt-id-7');
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
);
// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
});
});

View File

@ -13,9 +13,10 @@ import {
GeminiEventType,
parseAndFormatApiError,
} from '@google/gemini-cli-core';
import { Content, Part, FunctionCall } from '@google/genai';
import { Content, Part } from '@google/genai';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
export async function runNonInteractive(
config: Config,
@ -40,9 +41,27 @@ export async function runNonInteractive(
const geminiClient = config.getGeminiClient();
const abortController = new AbortController();
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
console.error('Exiting due to an error processing the @ command.');
process.exit(1);
}
let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] },
{ role: 'user', parts: processedQuery as Part[] },
];
let turnCount = 0;
while (true) {
turnCount++;
@ -55,7 +74,7 @@ export async function runNonInteractive(
);
return;
}
const functionCalls: FunctionCall[] = [];
const toolCallRequests: ToolCallRequestInfo[] = [];
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
@ -72,29 +91,13 @@ export async function runNonInteractive(
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
const toolCallRequest = event.value;
const fc: FunctionCall = {
name: toolCallRequest.name,
args: toolCallRequest.args,
id: toolCallRequest.callId,
};
functionCalls.push(fc);
toolCallRequests.push(event.value);
}
}
if (functionCalls.length > 0) {
if (toolCallRequests.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const requestInfo: ToolCallRequestInfo = {
callId,
name: fc.name as string,
args: (fc.args ?? {}) as Record<string, unknown>,
isClientInitiated: false,
prompt_id,
};
for (const requestInfo of toolCallRequests) {
const toolResponse = await executeToolCall(
config,
requestInfo,
@ -103,7 +106,7 @@ export async function runNonInteractive(
if (toolResponse.error) {
console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
);
}

View File

@ -87,6 +87,7 @@ interface MockServerConfig {
getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
getScreenReader: Mock<() => boolean>;
}
// Mock @google/gemini-cli-core and its Config class
@ -168,6 +169,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getConnectionStatus: vi.fn(() => 'connected'),
})),
isTrustedFolder: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
};
});

View File

@ -923,10 +923,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
key={staticKey}
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
{!(settings.merged.hideBanner || config.getScreenReader()) && (
<Header version={version} nightly={nightly} />
)}
{!settings.merged.hideTips && <Tips config={config} />}
{!(settings.merged.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
</Box>,
...history.map((h) => (
<HistoryItemDisplay
@ -1093,12 +1095,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<LoadingIndicator
thought={
streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: currentLoadingPhrase
}

View File

@ -26,6 +26,7 @@ import {
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'path';
import { SCREEN_READER_USER_PREFIX } from '../constants.js';
export interface InputPromptProps {
buffer: TextBuffer;
@ -688,7 +689,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link}>(r:) </Text>
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'! '
)

View File

@ -27,11 +27,61 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {
customThemes: {},
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
path: '/workspace/settings.json',
},
[],
true,
);
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
let settings = createMockSettings({ 'a.string.setting': 'initial' });
return {
...actual,
useSettings: () => ({
settings,
setSetting: (key: string, value: string) => {
settings = createMockSettings({ [key]: value });
},
getSettingDefinition: (key: string) => {
if (key === 'a.string.setting') {
return {
type: 'string',
description: 'A string setting',
};
}
return undefined;
},
}),
};
});
vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
@ -53,28 +103,6 @@ vi.mock('../../utils/settingsUtils.js', async () => {
};
});
// Mock the useKeypress hook to avoid context issues
interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}
// Variables for keypress simulation (not currently used)
// let currentKeypressHandler: ((key: Key) => void) | null = null;
// let isKeypressActive = false;
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(
(_handler: (key: Key) => void, _options: { isActive: boolean }) => {
// Mock implementation - simplified for test stability
},
),
}));
// Helper function to simulate key presses (commented out for now)
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
// if (currentKeypressHandler) {
@ -149,7 +177,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -163,7 +193,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -176,7 +208,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -191,7 +225,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press down arrow
@ -207,7 +243,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// First go down, then up
@ -224,7 +262,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate with vim keys
@ -241,7 +281,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to go up from first item
@ -259,7 +301,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Enter to toggle current setting
@ -274,7 +318,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Space to toggle current setting
@ -289,7 +335,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate to vim mode setting and toggle it
@ -308,7 +356,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope focus
@ -327,7 +377,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
@ -352,11 +404,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
/>
</KeypressProvider>,
);
// This test would need to trigger a restart-required setting change
@ -371,11 +425,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
/>
</KeypressProvider>,
);
// Press 'r' key (this would only work if restart prompt is showing)
@ -393,7 +449,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
@ -418,7 +476,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope selector
@ -442,7 +502,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Should show user scope values initially
@ -459,7 +521,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to toggle a setting (this might trigger vim mode toggle)
@ -477,7 +541,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle a setting
@ -499,7 +565,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate down many times to test scrolling
@ -519,7 +587,9 @@ describe('SettingsDialog', () => {
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
</VimModeProvider>,
);
@ -542,7 +612,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -555,7 +627,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle a non-restart-required setting (like hideTips)
@ -571,7 +645,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// This test would need to navigate to a specific restart-required setting
@ -591,7 +667,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Restart prompt should be cleared when switching scopes
@ -609,7 +687,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -626,7 +706,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@ -641,7 +723,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Rapid navigation
@ -660,7 +744,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Ctrl+C to reset current setting to default
@ -676,7 +762,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Ctrl+L to reset current setting to default
@ -692,7 +780,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to navigate when potentially at bounds
@ -709,7 +799,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
@ -739,7 +831,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Should still render without crashing
@ -752,7 +846,9 @@ describe('SettingsDialog', () => {
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toContain('Settings');
@ -765,7 +861,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
@ -793,7 +891,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle first setting (should require restart)
@ -822,7 +922,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Multiple scope changes
@ -846,11 +948,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
/>
</KeypressProvider>,
);
// This would test the restart workflow if we could trigger it
@ -863,4 +967,58 @@ describe('SettingsDialog', () => {
unmount();
});
});
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
let settings = createMockSettings({ 'a.string.setting': 'initial' });
const onSelect = vi.fn();
const { stdin, unmount, rerender } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for the dialog to render
await wait();
// Navigate to the last setting
for (let i = 0; i < 20; i++) {
stdin.write('j'); // Down
await wait(10);
}
// Press Enter to start editing
stdin.write('\r');
await wait();
// Type a new value
stdin.write('new value');
await wait();
// Press Enter to commit
stdin.write('\r');
await wait();
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
rerender(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
await wait();
// Press Escape to exit
stdin.write('\u001B');
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, 'User');
unmount();
});
});
});

View File

@ -35,7 +35,7 @@ import {
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@ -78,8 +78,8 @@ export function SettingsDialog({
new Set(),
);
// Preserve pending changes across scope switches (boolean and number values only)
type PendingValue = boolean | number;
// Preserve pending changes across scope switches
type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
@ -99,7 +99,10 @@ export function SettingsDialog({
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated);
} else if (def?.type === 'number' && typeof value === 'number') {
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
updated = setPendingSettingValueAny(key, value, updated);
}
newModified.add(key);
@ -123,7 +126,7 @@ export function SettingsDialog({
type: definition?.type,
toggle: () => {
if (definition?.type !== 'boolean') {
// For non-boolean (e.g., number) items, toggle will be handled via edit mode.
// For non-boolean items, toggle will be handled via edit mode.
return;
}
const currentValue = getSettingValue(key, pendingSettings, {});
@ -220,7 +223,7 @@ export function SettingsDialog({
const items = generateSettingsItems();
// Number edit state
// Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
@ -235,29 +238,40 @@ export function SettingsDialog({
return () => clearInterval(id);
}, [editingKey]);
const startEditingNumber = (key: string, initial?: string) => {
const startEditing = (key: string, initial?: string) => {
setEditingKey(key);
const initialValue = initial ?? '';
setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
};
const commitNumberEdit = (key: string) => {
if (editBuffer.trim() === '') {
// Nothing entered; cancel edit
const commitEdit = (key: string) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
const parsed = Number(editBuffer.trim());
if (Number.isNaN(parsed)) {
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
}
// Update pending
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
@ -347,10 +361,16 @@ export function SettingsDialog({
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
// If editing a number, capture numeric input and control keys
// If editing, capture input and control keys
if (editingKey) {
const definition = getSettingDefinition(editingKey);
const type = definition?.type;
if (key.paste && key.sequence) {
const pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
let pasted = key.sequence;
if (type === 'number') {
pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
}
if (pasted) {
setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos);
@ -380,16 +400,27 @@ export function SettingsDialog({
return;
}
if (name === 'escape') {
commitNumberEdit(editingKey);
commitEdit(editingKey);
return;
}
if (name === 'return') {
commitNumberEdit(editingKey);
commitEdit(editingKey);
return;
}
// Allow digits, minus, plus, and dot
const ch = key.sequence;
if (/[0-9\-+.]/.test(ch)) {
let ch = key.sequence;
let isValidChar = false;
if (type === 'number') {
// Allow digits, minus, plus, and dot.
isValidChar = /[0-9\-+.]/.test(ch);
} else {
ch = stripUnsafeCharacters(ch);
// For strings, allow any single character that isn't a control
// sequence.
isValidChar = ch.length === 1;
}
if (isValidChar) {
setEditBuffer((currentBuffer) => {
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
const afterCursor = cpSlice(currentBuffer, editCursorPos);
@ -398,6 +429,7 @@ export function SettingsDialog({
setEditCursorPos((pos) => pos + 1);
return;
}
// Arrow key navigation
if (name === 'left') {
setEditCursorPos((pos) => Math.max(0, pos - 1));
@ -422,7 +454,7 @@ export function SettingsDialog({
if (name === 'up' || name === 'k') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
}
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
@ -436,7 +468,7 @@ export function SettingsDialog({
} else if (name === 'down' || name === 'j') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
}
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
@ -449,15 +481,18 @@ export function SettingsDialog({
}
} else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value);
if (
currentItem?.type === 'number' ||
currentItem?.type === 'string'
) {
startEditing(currentItem.value);
} else {
currentItem?.toggle();
}
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value, key.sequence);
startEditing(currentItem.value, key.sequence);
}
} else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
@ -475,8 +510,11 @@ export function SettingsDialog({
prev,
),
);
} else if (defType === 'number') {
if (typeof defaultValue === 'number') {
} else if (defType === 'number' || defType === 'string') {
if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) =>
setPendingSettingValueAny(
currentSetting.value,
@ -509,7 +547,8 @@ export function SettingsDialog({
? typeof defaultValue === 'boolean'
? defaultValue
: false
: typeof defaultValue === 'number'
: typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
? defaultValue
: undefined;
const immediateSettingsObject =
@ -541,7 +580,9 @@ export function SettingsDialog({
(currentSetting.type === 'boolean' &&
typeof defaultValue === 'boolean') ||
(currentSetting.type === 'number' &&
typeof defaultValue === 'number')
typeof defaultValue === 'number') ||
(currentSetting.type === 'string' &&
typeof defaultValue === 'string')
) {
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
@ -584,7 +625,7 @@ export function SettingsDialog({
}
if (name === 'escape') {
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
} else {
onSelect(undefined, selectedScope);
}
@ -637,8 +678,8 @@ export function SettingsDialog({
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number') {
// For numbers, get the actual current value from pending settings
} else if (item.type === 'number' || item.type === 'string') {
// For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);

View File

@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
@ -40,6 +41,7 @@ export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{text}
</Text>

View File

@ -8,6 +8,7 @@ import React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
interface GeminiMessageProps {
text: string;
@ -28,7 +29,12 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text>
<Text
color={Colors.AccentPurple}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay

View File

@ -7,6 +7,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../constants.js';
interface UserMessageProps {
text: string;
@ -31,7 +32,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={textColor}>{prefix}</Text>
<Text color={textColor} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>

View File

@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
@ -13,7 +11,12 @@ import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@google/gemini-cli-core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
import {
toCodePoints,
cpLen,
cpSlice,
stripUnsafeCharacters,
} from '../../utils/textUtils.js';
import { handleVimAction, VimAction } from './vim-buffer-actions.js';
export type Direction =
@ -494,51 +497,6 @@ export const replaceRangeInternal = (
};
};
/**
* Strip characters that can break terminal rendering.
*
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}
export interface Viewport {
height: number;
width: number;

View File

@ -15,3 +15,7 @@ export const UI_WIDTH =
export const STREAM_DEBOUNCE_MS = 100;
export const SHELL_COMMAND_NAME = 'Shell Command';
export const SCREEN_READER_USER_PREFIX = 'User: ';
export const SCREEN_READER_MODEL_PREFIX = 'Model: ';

View File

@ -134,7 +134,6 @@ export function useReactToolScheduler(
const scheduler = useMemo(
() =>
new CoreToolScheduler({
toolRegistry: config.getToolRegistry(),
outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,

View File

@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
/**
* Calculates the maximum width of a multi-line ASCII art string.
* @param asciiArt The ASCII art string.
@ -38,3 +41,48 @@ export function cpSlice(str: string, start: number, end?: number): string {
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}
/**
* Strip characters that can break terminal rendering.
*
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
export function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9f) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7f) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@ -62,6 +62,7 @@ export enum ApprovalMode {
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
}
export interface BugCommandSettings {
@ -734,6 +735,10 @@ export class Config {
return this.skipNextSpeakerCheck;
}
getScreenReader(): boolean {
return this.accessibility.screenReader ?? false;
}
getEnablePromptCompletion(): boolean {
return this.enablePromptCompletion;
}

View File

@ -129,11 +129,11 @@ describe('CoreToolScheduler', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -189,11 +189,11 @@ describe('CoreToolScheduler with payload', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -462,15 +462,14 @@ class MockEditTool extends BaseDeclarativeTool<
describe('CoreToolScheduler edit cancellation', () => {
it('should preserve diff when an edit is cancelled', async () => {
const mockEditTool = new MockEditTool();
const declarativeTool = mockEditTool;
const mockToolRegistry = {
getTool: () => declarativeTool,
getTool: () => mockEditTool,
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {},
registerTool: () => {},
getToolByName: () => declarativeTool,
getToolByDisplayName: () => declarativeTool,
getToolByName: () => mockEditTool,
getToolByDisplayName: () => mockEditTool,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
@ -489,11 +488,11 @@ describe('CoreToolScheduler edit cancellation', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -581,11 +580,11 @@ describe('CoreToolScheduler YOLO mode', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -670,11 +669,11 @@ describe('CoreToolScheduler request queueing', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -783,11 +782,11 @@ describe('CoreToolScheduler request queueing', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: mockToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
@ -864,7 +863,9 @@ describe('CoreToolScheduler request queueing', () => {
getTools: () => [],
discoverTools: async () => {},
discovery: {},
};
} as unknown as ToolRegistry;
mockConfig.getToolRegistry = () => toolRegistry;
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
@ -874,7 +875,6 @@ describe('CoreToolScheduler request queueing', () => {
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: toolRegistry as unknown as ToolRegistry,
onAllToolCallsComplete,
onToolCallsUpdate: (toolCalls) => {
onToolCallsUpdate(toolCalls);

View File

@ -226,12 +226,11 @@ const createErrorResponse = (
});
interface CoreToolSchedulerOptions {
toolRegistry: ToolRegistry;
config: Config;
outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler;
getPreferredEditor: () => EditorType | undefined;
config: Config;
onEditorClose: () => void;
}
@ -255,7 +254,7 @@ export class CoreToolScheduler {
constructor(options: CoreToolSchedulerOptions) {
this.config = options.config;
this.toolRegistry = options.toolRegistry;
this.toolRegistry = options.config.getToolRegistry();
this.outputUpdateHandler = options.outputUpdateHandler;
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;

View File

@ -12,6 +12,7 @@ import {
ToolResult,
Config,
ToolErrorType,
ApprovalMode,
} from '../index.js';
import { Part } from '@google/genai';
import { MockTool } from '../test-utils/tools.js';
@ -27,10 +28,11 @@ describe('executeToolCall', () => {
mockToolRegistry = {
getTool: vi.fn(),
// Add other ToolRegistry methods if needed, or use a more complete mock
} as unknown as ToolRegistry;
mockConfig = {
getToolRegistry: () => mockToolRegistry,
getApprovalMode: () => ApprovalMode.DEFAULT,
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
@ -38,7 +40,6 @@ describe('executeToolCall', () => {
model: 'test-model',
authType: 'oauth-personal',
}),
getToolRegistry: () => mockToolRegistry,
} as unknown as Config;
abortController = new AbortController();
@ -57,7 +58,7 @@ describe('executeToolCall', () => {
returnDisplay: 'Success!',
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult);
mockTool.executeFn.mockReturnValue(toolResult);
const response = await executeToolCall(
mockConfig,
@ -66,19 +67,19 @@ describe('executeToolCall', () => {
);
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool');
expect(mockTool.validateBuildAndExecute).toHaveBeenCalledWith(
request.args,
abortController.signal,
);
expect(response.callId).toBe('call1');
expect(response.error).toBeUndefined();
expect(response.resultDisplay).toBe('Success!');
expect(response.responseParts).toEqual({
expect(mockTool.executeFn).toHaveBeenCalledWith(request.args);
expect(response).toStrictEqual({
callId: 'call1',
error: undefined,
errorType: undefined,
resultDisplay: 'Success!',
responseParts: {
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'Tool executed successfully' },
},
},
});
});
@ -98,23 +99,19 @@ describe('executeToolCall', () => {
abortController.signal,
);
expect(response.callId).toBe('call2');
expect(response.error).toBeInstanceOf(Error);
expect(response.error?.message).toBe(
'Tool "nonexistentTool" not found in registry.',
);
expect(response.resultDisplay).toBe(
'Tool "nonexistentTool" not found in registry.',
);
expect(response.responseParts).toEqual([
{
expect(response).toStrictEqual({
callId: 'call2',
error: new Error('Tool "nonexistentTool" not found in registry.'),
errorType: ToolErrorType.TOOL_NOT_REGISTERED,
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
responseParts: {
functionResponse: {
name: 'nonexistentTool',
id: 'call2',
response: { error: 'Tool "nonexistentTool" not found in registry.' },
},
},
]);
});
});
it('should return an error if tool validation fails', async () => {
@ -125,24 +122,17 @@ describe('executeToolCall', () => {
isClientInitiated: false,
prompt_id: 'prompt-id-3',
};
const validationErrorResult: ToolResult = {
llmContent: 'Error: Invalid parameters',
returnDisplay: 'Invalid parameters',
error: {
message: 'Invalid parameters',
type: ToolErrorType.INVALID_TOOL_PARAMS,
},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(
validationErrorResult,
);
vi.spyOn(mockTool, 'build').mockImplementation(() => {
throw new Error('Invalid parameters');
});
const response = await executeToolCall(
mockConfig,
request,
abortController.signal,
);
expect(response).toStrictEqual({
callId: 'call3',
error: new Error('Invalid parameters'),
@ -152,7 +142,7 @@ describe('executeToolCall', () => {
id: 'call3',
name: 'testTool',
response: {
output: 'Error: Invalid parameters',
error: 'Invalid parameters',
},
},
},
@ -177,9 +167,7 @@ describe('executeToolCall', () => {
},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(
executionErrorResult,
);
mockTool.executeFn.mockReturnValue(executionErrorResult);
const response = await executeToolCall(
mockConfig,
@ -195,7 +183,7 @@ describe('executeToolCall', () => {
id: 'call4',
name: 'testTool',
response: {
output: 'Error: Execution failed',
error: 'Execution failed',
},
},
},
@ -211,11 +199,10 @@ describe('executeToolCall', () => {
isClientInitiated: false,
prompt_id: 'prompt-id-5',
};
const executionError = new Error('Something went very wrong');
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockRejectedValue(
executionError,
);
mockTool.executeFn.mockImplementation(() => {
throw new Error('Something went very wrong');
});
const response = await executeToolCall(
mockConfig,
@ -223,19 +210,19 @@ describe('executeToolCall', () => {
abortController.signal,
);
expect(response.callId).toBe('call5');
expect(response.error).toBe(executionError);
expect(response.errorType).toBe(ToolErrorType.UNHANDLED_EXCEPTION);
expect(response.resultDisplay).toBe('Something went very wrong');
expect(response.responseParts).toEqual([
{
expect(response).toStrictEqual({
callId: 'call5',
error: new Error('Something went very wrong'),
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
resultDisplay: 'Something went very wrong',
responseParts: {
functionResponse: {
name: 'testTool',
id: 'call5',
response: { error: 'Something went very wrong' },
},
},
]);
});
});
it('should correctly format llmContent with inlineData', async () => {
@ -254,7 +241,7 @@ describe('executeToolCall', () => {
returnDisplay: 'Image processed',
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.spyOn(mockTool, 'validateBuildAndExecute').mockResolvedValue(toolResult);
mockTool.executeFn.mockReturnValue(toolResult);
const response = await executeToolCall(
mockConfig,
@ -262,8 +249,12 @@ describe('executeToolCall', () => {
abortController.signal,
);
expect(response.resultDisplay).toBe('Image processed');
expect(response.responseParts).toEqual([
expect(response).toStrictEqual({
callId: 'call6',
error: undefined,
errorType: undefined,
resultDisplay: 'Image processed',
responseParts: [
{
functionResponse: {
name: 'testTool',
@ -274,6 +265,7 @@ describe('executeToolCall', () => {
},
},
imageDataPart,
]);
],
});
});
});

View File

@ -4,166 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
FileDiff,
logToolCall,
ToolCallRequestInfo,
ToolCallResponseInfo,
ToolErrorType,
ToolResult,
} from '../index.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { Config } from '../config/config.js';
import { convertToFunctionResponse } from './coreToolScheduler.js';
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
import { ToolCallRequestInfo, ToolCallResponseInfo, Config } from '../index.js';
import { CoreToolScheduler } from './coreToolScheduler.js';
/**
* Executes a single tool call non-interactively.
* It does not handle confirmations, multiple calls, or live updates.
* Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
*/
export async function executeToolCall(
config: Config,
toolCallRequest: ToolCallRequestInfo,
abortSignal?: AbortSignal,
abortSignal: AbortSignal,
): Promise<ToolCallResponseInfo> {
const tool = config.getToolRegistry().getTool(toolCallRequest.name);
const startTime = Date.now();
if (!tool) {
const error = new Error(
`Tool "${toolCallRequest.name}" not found in registry.`,
);
const durationMs = Date.now() - startTime;
logToolCall(config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
prompt_id: toolCallRequest.prompt_id,
tool_type: 'native',
return new Promise<ToolCallResponseInfo>((resolve, reject) => {
new CoreToolScheduler({
config,
getPreferredEditor: () => undefined,
onEditorClose: () => {},
onAllToolCallsComplete: async (completedToolCalls) => {
resolve(completedToolCalls[0].response);
},
})
.schedule(toolCallRequest, abortSignal)
.catch(reject);
});
// Ensure the response structure matches what the API expects for an error
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: ToolErrorType.TOOL_NOT_REGISTERED,
};
}
try {
// Directly execute without confirmation or live output handling
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
const toolResult: ToolResult = await tool.validateBuildAndExecute(
toolCallRequest.args,
effectiveAbortSignal,
// No live output callback for non-interactive mode
);
const tool_output = toolResult.llmContent;
const tool_display = toolResult.returnDisplay;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let metadata: { [key: string]: any } = {};
if (
toolResult.error === undefined &&
typeof tool_display === 'object' &&
tool_display !== null &&
'diffStat' in tool_display
) {
const diffStat = (tool_display as FileDiff).diffStat;
if (diffStat) {
metadata = {
ai_added_lines: diffStat.ai_added_lines,
ai_removed_lines: diffStat.ai_removed_lines,
user_added_lines: diffStat.user_added_lines,
user_removed_lines: diffStat.user_removed_lines,
};
}
}
const durationMs = Date.now() - startTime;
logToolCall(config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: toolResult.error === undefined,
error:
toolResult.error === undefined ? undefined : toolResult.error.message,
error_type:
toolResult.error === undefined ? undefined : toolResult.error.type,
prompt_id: toolCallRequest.prompt_id,
metadata,
decision: ToolCallDecision.AUTO_ACCEPT,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
const response = convertToFunctionResponse(
toolCallRequest.name,
toolCallRequest.callId,
tool_output,
);
return {
callId: toolCallRequest.callId,
responseParts: response,
resultDisplay: tool_display,
error:
toolResult.error === undefined
? undefined
: new Error(toolResult.error.message),
errorType:
toolResult.error === undefined ? undefined : toolResult.error.type,
};
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
const durationMs = Date.now() - startTime;
logToolCall(config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
error_type: ToolErrorType.UNHANDLED_EXCEPTION,
prompt_id: toolCallRequest.prompt_id,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
};
}
}

View File

@ -4,75 +4,224 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import * as path from 'path';
import { IdeClient } from './ide-client.js';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import * as fs from 'node:fs';
import { getIdeProcessId } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
detectIde,
DetectedIde,
getIdeInfo,
type IdeInfo,
} from './detect-ide.js';
import * as os from 'node:os';
import * as path from 'node:path';
describe('IdeClient.validateWorkspacePath', () => {
it('should return valid if cwd is a subpath of the IDE workspace path', () => {
const result = IdeClient.validateWorkspacePath(
'/Users/person/gemini-cli',
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(true);
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
promises: {
readFile: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
};
});
vi.mock('./process-utils.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
vi.mock('./detect-ide.js');
vi.mock('node:os');
describe('IdeClient', () => {
let mockClient: Mocked<Client>;
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
let mockStdioTransport: Mocked<StdioClientTransport>;
beforeEach(() => {
// Reset singleton instance for test isolation
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
undefined;
// Mock environment variables
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = '/test/workspace';
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode);
vi.mocked(getIdeInfo).mockReturnValue({
displayName: 'VS Code',
} as IdeInfo);
vi.mocked(getIdeProcessId).mockResolvedValue(12345);
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
// Mock MCP client and transports
mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
setNotificationHandler: vi.fn(),
callTool: vi.fn(),
} as unknown as Mocked<Client>;
mockHttpTransport = {
close: vi.fn(),
} as unknown as Mocked<StreamableHTTPClientTransport>;
mockStdioTransport = {
close: vi.fn(),
} as unknown as Mocked<StdioClientTransport>;
vi.mocked(Client).mockReturnValue(mockClient);
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
});
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is undefined', () => {
const result = IdeClient.validateWorkspacePath(
undefined,
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Failed to connect');
afterEach(() => {
vi.restoreAllMocks();
});
it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => {
const result = IdeClient.validateWorkspacePath(
'',
'VS Code',
'/Users/person/gemini-cli/sub-dir',
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'gemini-ide-server-12345.json'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('please open a workspace folder');
});
it('should return invalid if cwd is not within the IDE workspace path', () => {
const result = IdeClient.validateWorkspacePath(
'/some/other/path',
'VS Code',
'/Users/person/gemini-cli/sub-dir',
it('should connect using stdio when stdio config is provided in file', async () => {
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StdioClientTransport).toHaveBeenCalledWith({
command: 'test-cmd',
args: ['--foo'],
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Directory mismatch');
});
it('should handle multiple workspace paths and return valid', () => {
const result = IdeClient.validateWorkspacePath(
['/some/other/path', '/Users/person/gemini-cli'].join(path.delimiter),
'VS Code',
'/Users/person/gemini-cli/sub-dir',
it('should prioritize port over stdio when both are in config file', async () => {
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
expect(StdioClientTransport).not.toHaveBeenCalled();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(result.isValid).toBe(true);
});
it('should return invalid if cwd is not in any of the multiple workspace paths', () => {
const result = IdeClient.validateWorkspacePath(
['/some/other/path', '/another/path'].join(path.delimiter),
'VS Code',
'/Users/person/gemini-cli/sub-dir',
it('should connect using HTTP when port is provided in environment variables', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:9090/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Directory mismatch');
});
it.skipIf(process.platform !== 'win32')('should handle windows paths', () => {
const result = IdeClient.validateWorkspacePath(
'c:/some/other/path;d:/Users/person/gemini-cli',
'VS Code',
'd:/Users/person/gemini-cli/sub-dir',
it('should connect using stdio when stdio config is in environment variables', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
expect(result.isValid).toBe(true);
process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StdioClientTransport).toHaveBeenCalledWith({
command: 'env-cmd',
args: ['--bar'],
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should prioritize file config over environment variables', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
expect.any(Object),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should be disconnected if no config is found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
const ideClient = IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();
expect(StdioClientTransport).not.toHaveBeenCalled();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Disconnected,
);
expect(ideClient.getConnectionStatus().details).toContain(
'Failed to connect',
);
});
});
});

View File

@ -18,6 +18,7 @@ import {
import { getIdeProcessId } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { EnvHttpProxyAgent } from 'undici';
@ -40,6 +41,16 @@ export enum IDEConnectionStatus {
Connecting = 'connecting',
}
type StdioConfig = {
command: string;
args: string[];
};
type ConnectionConfig = {
port?: string;
stdio?: StdioConfig;
};
function getRealPath(path: string): string {
try {
return fs.realpathSync(path);
@ -104,9 +115,9 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
const ideInfoFromFile = await this.getIdeInfoFromFile();
const configFromFile = await this.getConnectionConfigFromFile();
const workspacePath =
ideInfoFromFile.workspacePath ??
configFromFile?.workspacePath ??
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
const { isValid, error } = IdeClient.validateWorkspacePath(
@ -120,17 +131,36 @@ export class IdeClient {
return;
}
const portFromFile = ideInfoFromFile.port;
if (portFromFile) {
const connected = await this.establishConnection(portFromFile);
if (configFromFile) {
if (configFromFile.port) {
const connected = await this.establishHttpConnection(
configFromFile.port,
);
if (connected) {
return;
}
}
if (configFromFile.stdio) {
const connected = await this.establishStdioConnection(
configFromFile.stdio,
);
if (connected) {
return;
}
}
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const connected = await this.establishConnection(portFromEnv);
const connected = await this.establishHttpConnection(portFromEnv);
if (connected) {
return;
}
}
const stdioConfigFromEnv = this.getStdioConfigFromEnv();
if (stdioConfigFromEnv) {
const connected = await this.establishStdioConnection(stdioConfigFromEnv);
if (connected) {
return;
}
@ -316,10 +346,35 @@ export class IdeClient {
return port;
}
private async getIdeInfoFromFile(): Promise<{
port?: string;
workspacePath?: string;
}> {
private getStdioConfigFromEnv(): StdioConfig | undefined {
const command = process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
if (!command) {
return undefined;
}
const argsStr = process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
let args: string[] = [];
if (argsStr) {
try {
const parsedArgs = JSON.parse(argsStr);
if (Array.isArray(parsedArgs)) {
args = parsedArgs;
} else {
logger.error(
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS must be a JSON array string.',
);
}
} catch (e) {
logger.error('Failed to parse GEMINI_CLI_IDE_SERVER_STDIO_ARGS:', e);
}
}
return { command, args };
}
private async getConnectionConfigFromFile(): Promise<
(ConnectionConfig & { workspacePath?: string }) | undefined
> {
try {
const ideProcessId = await getIdeProcessId();
const portFile = path.join(
@ -327,13 +382,9 @@ export class IdeClient {
`gemini-ide-server-${ideProcessId}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
const ideInfo = JSON.parse(portFileContents);
return {
port: ideInfo?.port?.toString(),
workspacePath: ideInfo?.workspacePath,
};
return JSON.parse(portFileContents);
} catch (_) {
return {};
return undefined;
}
}
@ -414,9 +465,10 @@ export class IdeClient {
);
}
private async establishConnection(port: string): Promise<boolean> {
private async establishHttpConnection(port: string): Promise<boolean> {
let transport: StreamableHTTPClientTransport | undefined;
try {
logger.debug('Attempting to connect to IDE via HTTP SSE');
this.client = new Client({
name: 'streamable-http-client',
// TODO(#3487): use the CLI version here.
@ -443,6 +495,39 @@ export class IdeClient {
return false;
}
}
private async establishStdioConnection({
command,
args,
}: StdioConfig): Promise<boolean> {
let transport: StdioClientTransport | undefined;
try {
logger.debug('Attempting to connect to IDE via stdio');
this.client = new Client({
name: 'stdio-client',
// TODO(#3487): use the CLI version here.
version: '1.0.0',
});
transport = new StdioClientTransport({
command,
args,
});
await this.client.connect(transport);
this.registerClientHandlers();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {
if (transport) {
try {
await transport.close();
} catch (closeError) {
logger.debug('Failed to close transport:', closeError);
}
}
return false;
}
}
}
function getIdeServerHost() {

View File

@ -9,10 +9,14 @@ import { partListUnionToString } from '../core/geminiRequest.js';
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
describe('GlobTool', () => {
let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance
@ -203,6 +207,29 @@ describe('GlobTool', () => {
path.resolve(tempRootDir, 'older.sortme'),
);
});
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
expect(result.returnDisplay).toBe('Path is not within workspace');
});
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GLOB_EXECUTION_ERROR);
expect(result.llmContent).toContain(
'Error during glob search operation: Glob failed',
);
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
});
describe('validateToolParams', () => {

View File

@ -16,6 +16,7 @@ import {
} from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
@ -115,9 +116,14 @@ class GlobToolInvocation extends BaseToolInvocation<
this.params.path,
);
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
return {
llmContent: `Error: Path "${this.params.path}" is not within any workspace directory`,
llmContent: rawError,
returnDisplay: `Path is not within workspace`,
error: {
message: rawError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
searchDirectories = [searchDirAbsolute];
@ -234,9 +240,14 @@ class GlobToolInvocation extends BaseToolInvocation<
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`GlobLogic execute Error: ${errorMessage}`, error);
const rawError = `Error during glob search operation: ${errorMessage}`;
return {
llmContent: `Error during glob search operation: ${errorMessage}`,
llmContent: rawError,
returnDisplay: `Error: An unexpected error occurred.`,
error: {
message: rawError,
type: ToolErrorType.GLOB_EXECUTION_ERROR,
},
};
}
}

View File

@ -11,6 +11,10 @@ import fs from 'fs/promises';
import os from 'os';
import { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({
@ -223,6 +227,15 @@ describe('GrepTool', () => {
/params must have required property 'pattern'/,
);
});
it('should return a GREP_EXECUTION_ERROR on failure', async () => {
vi.mocked(glob.globStream).mockRejectedValue(new Error('Glob failed'));
const params: GrepToolParams = { pattern: 'hello' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GREP_EXECUTION_ERROR);
vi.mocked(glob.globStream).mockReset();
});
});
describe('multi-directory workspace', () => {

View File

@ -21,6 +21,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
// --- Interfaces ---
@ -198,6 +199,10 @@ class GrepToolInvocation extends BaseToolInvocation<
return {
llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.GREP_EXECUTION_ERROR,
},
};
}
}

View File

@ -23,6 +23,7 @@ import { LSTool } from './ls.js';
import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { ToolErrorType } from './tool-error.js';
describe('LSTool', () => {
let lsTool: LSTool;
@ -288,6 +289,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Path is not a directory');
expect(result.returnDisplay).toBe('Error: Path is not a directory.');
expect(result.error?.type).toBe(ToolErrorType.PATH_IS_NOT_A_DIRECTORY);
});
it('should handle non-existent paths', async () => {
@ -302,6 +304,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory');
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
});
it('should sort directories first, then files alphabetically', async () => {
@ -357,6 +360,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory');
expect(result.llmContent).toContain('permission denied');
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
});
it('should throw for invalid params at build time', async () => {

View File

@ -15,6 +15,7 @@ import {
} from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
/**
* Parameters for the LS tool
@ -114,11 +115,19 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
}
// Helper for consistent error formatting
private errorResult(llmContent: string, returnDisplay: string): ToolResult {
private errorResult(
llmContent: string,
returnDisplay: string,
type: ToolErrorType,
): ToolResult {
return {
llmContent,
// Keep returnDisplay simpler in core logic
returnDisplay: `Error: ${returnDisplay}`,
error: {
message: llmContent,
type,
},
};
}
@ -135,12 +144,14 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return this.errorResult(
`Error: Directory not found or inaccessible: ${this.params.path}`,
`Directory not found or inaccessible.`,
ToolErrorType.FILE_NOT_FOUND,
);
}
if (!stats.isDirectory()) {
return this.errorResult(
`Error: Path is not a directory: ${this.params.path}`,
`Path is not a directory.`,
ToolErrorType.PATH_IS_NOT_A_DIRECTORY,
);
}
@ -253,7 +264,11 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
};
} catch (error) {
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
return this.errorResult(errorMsg, 'Failed to list directory.');
return this.errorResult(
errorMsg,
'Failed to list directory.',
ToolErrorType.LS_EXECUTION_ERROR,
);
}
}
}

View File

@ -17,6 +17,7 @@ import {
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import { ToolResult, ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
// Mock @google/genai mcpToTool and CallableTool
// We only need to mock the parts of CallableTool that DiscoveredMCPTool uses.
@ -189,7 +190,7 @@ describe('DiscoveredMCPTool', () => {
{ isErrorValue: true, description: 'true (bool)' },
{ isErrorValue: 'true', description: '"true" (str)' },
])(
'should consider a ToolResult with isError $description to be a failure',
'should return a structured error if MCP tool reports an error',
async ({ isErrorValue }) => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
@ -210,16 +211,18 @@ describe('DiscoveredMCPTool', () => {
},
];
mockCallTool.mockResolvedValue(mockMcpToolResponseParts);
const expectedError = new Error(
`MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
const expectedErrorMessage = `MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
mockMcpToolResponseParts,
)}`,
);
)}`;
const invocation = tool.build(params);
await expect(
invocation.execute(new AbortController().signal),
).rejects.toThrow(expectedError);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
expect(result.llmContent).toBe(expectedErrorMessage);
expect(result.returnDisplay).toContain(
`Error: MCP tool '${serverToolName}' reported an error.`,
);
},
);

View File

@ -16,6 +16,7 @@ import {
ToolResult,
} from './tools.js';
import { CallableTool, FunctionCall, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
type ToolParams = Record<string, unknown>;
@ -139,9 +140,19 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
// Ensure the response is not an error
if (this.isMCPToolError(rawResponseParts)) {
throw new Error(
`MCP tool '${this.serverToolName}' reported tool error with response: ${JSON.stringify(rawResponseParts)}`,
);
const errorMessage = `MCP tool '${
this.serverToolName
}' reported tool error with response: ${JSON.stringify(
rawResponseParts,
)}`;
return {
llmContent: errorMessage,
returnDisplay: `Error: MCP tool '${this.serverToolName}' reported an error.`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_TOOL_ERROR,
},
};
}
const transformedParts = transformMcpContentToParts(rawResponseParts);

View File

@ -16,6 +16,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
// Mock dependencies
vi.mock(import('fs/promises'), async (importOriginal) => {
@ -287,6 +288,9 @@ describe('MemoryTool', () => {
expect(result.returnDisplay).toBe(
`Error saving memory: ${underlyingError.message}`,
);
expect(result.error?.type).toBe(
ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
);
});
});

View File

@ -20,6 +20,7 @@ import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { tildeifyPath } from '../utils/paths.js';
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import { ToolErrorType } from './tool-error.js';
const memoryToolSchemaData: FunctionDeclaration = {
name: 'save_memory',
@ -273,6 +274,10 @@ class MemoryToolInvocation extends BaseToolInvocation<
error: `Failed to save memory. Detail: ${errorMessage}`,
}),
returnDisplay: `Error saving memory: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
},
};
}
}

View File

@ -15,6 +15,10 @@ import os from 'os';
import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
vi.mock('mime-types', () => {
const lookup = (filename: string) => {
@ -566,6 +570,28 @@ Content of file[1]
});
});
describe('Error handling', () => {
it('should return an INVALID_TOOL_PARAMS error if no paths are provided', async () => {
const params = { paths: [], include: [] };
expect(() => {
tool.build(params);
}).toThrow('params/paths must NOT have fewer than 1 items');
});
it('should return a READ_MANY_FILES_SEARCH_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params = { paths: ['*.txt'] };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
);
expect(result.llmContent).toBe('Error during file search: Glob failed');
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
});
describe('Batch Processing', () => {
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
const files: string[] = [];

View File

@ -29,6 +29,7 @@ import {
recordFileOperationMetric,
FileOperation,
} from '../telemetry/metrics.js';
import { ToolErrorType } from './tool-error.js';
/**
* Parameters for the ReadManyFilesTool.
@ -232,13 +233,6 @@ ${finalExclusionPatternsForDescription
: [...exclude];
const searchPatterns = [...inputPatterns, ...include];
if (searchPatterns.length === 0) {
return {
llmContent: 'No search paths or include patterns provided.',
returnDisplay: `## Information\n\nNo search paths or include patterns were specified. Nothing to read or concatenate.`,
};
}
try {
const allEntries = new Set<string>();
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
@ -352,9 +346,14 @@ ${finalExclusionPatternsForDescription
});
}
} catch (error) {
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
return {
llmContent: `Error during file search: ${getErrorMessage(error)}`,
llmContent: errorMessage,
returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``,
error: {
message: errorMessage,
type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
},
};
}

View File

@ -24,10 +24,43 @@ export enum ToolErrorType {
PERMISSION_DENIED = 'permission_denied',
NO_SPACE_LEFT = 'no_space_left',
TARGET_IS_DIRECTORY = 'target_is_directory',
PATH_NOT_IN_WORKSPACE = 'path_not_in_workspace',
SEARCH_PATH_NOT_FOUND = 'search_path_not_found',
SEARCH_PATH_NOT_A_DIRECTORY = 'search_path_not_a_directory',
// Edit-specific Errors
EDIT_PREPARATION_FAILURE = 'edit_preparation_failure',
EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found',
EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch',
EDIT_NO_CHANGE = 'edit_no_change',
// Glob-specific Errors
GLOB_EXECUTION_ERROR = 'glob_execution_error',
// Grep-specific Errors
GREP_EXECUTION_ERROR = 'grep_execution_error',
// Ls-specific Errors
LS_EXECUTION_ERROR = 'ls_execution_error',
PATH_IS_NOT_A_DIRECTORY = 'path_is_not_a_directory',
// MCP-specific Errors
MCP_TOOL_ERROR = 'mcp_tool_error',
// Memory-specific Errors
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
// ReadManyFiles-specific Errors
READ_MANY_FILES_SEARCH_ERROR = 'read_many_files_search_error',
// DiscoveredTool-specific Errors
DISCOVERED_TOOL_EXECUTION_ERROR = 'discovered_tool_execution_error',
// WebFetch-specific Errors
WEB_FETCH_NO_URL_IN_PROMPT = 'web_fetch_no_url_in_prompt',
WEB_FETCH_FALLBACK_FAILED = 'web_fetch_fallback_failed',
WEB_FETCH_PROCESSING_ERROR = 'web_fetch_processing_error',
// WebSearch-specific Errors
WEB_SEARCH_FAILED = 'web_search_failed',
}

View File

@ -24,6 +24,7 @@ import fs from 'node:fs';
import { MockTool } from '../test-utils/tools.js';
import { McpClientManager } from './mcp-client-manager.js';
import { ToolErrorType } from './tool-error.js';
vi.mock('node:fs');
@ -311,6 +312,81 @@ describe('ToolRegistry', () => {
});
});
it('should return a DISCOVERED_TOOL_EXECUTION_ERROR on tool failure', async () => {
const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
vi.spyOn(config, 'getToolCallCommand').mockReturnValue('my-call-command');
const toolDeclaration: FunctionDeclaration = {
name: 'failing-tool',
description: 'A tool that fails',
parametersJsonSchema: {
type: 'object',
properties: {},
},
};
const mockSpawn = vi.mocked(spawn);
// --- Discovery Mock ---
const discoveryProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
};
mockSpawn.mockReturnValueOnce(discoveryProcess as any);
discoveryProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: [toolDeclaration] }]),
),
);
}
});
discoveryProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});
await toolRegistry.discoverAllTools();
const discoveredTool = toolRegistry.getTool('failing-tool');
expect(discoveredTool).toBeDefined();
// --- Execution Mock ---
const executionProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
stdin: { write: vi.fn(), end: vi.fn() },
on: vi.fn(),
connected: true,
disconnect: vi.fn(),
removeListener: vi.fn(),
};
mockSpawn.mockReturnValueOnce(executionProcess as any);
executionProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(Buffer.from('Something went wrong'));
}
});
executionProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(1); // Non-zero exit code
}
});
const invocation = (discoveredTool as DiscoveredTool).build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
);
expect(result.llmContent).toContain('Stderr: Something went wrong');
expect(result.llmContent).toContain('Exit Code: 1');
});
it('should discover tools using MCP servers defined in getMcpServers', async () => {
const discoverSpy = vi.spyOn(
McpClientManager.prototype,

View File

@ -20,6 +20,7 @@ import { connectAndDiscover } from './mcp-client.js';
import { McpClientManager } from './mcp-client-manager.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { parse } from 'shell-quote';
import { ToolErrorType } from './tool-error.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
type ToolParams = Record<string, unknown>;
@ -106,6 +107,10 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
return {
llmContent,
returnDisplay: llmContent,
error: {
message: llmContent,
type: ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
},
};
}

View File

@ -4,17 +4,72 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebFetchTool } from './web-fetch.js';
import { Config, ApprovalMode } from '../config/config.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import * as fetchUtils from '../utils/fetch.js';
const mockGenerateContent = vi.fn();
const mockGetGeminiClient = vi.fn(() => ({
generateContent: mockGenerateContent,
}));
vi.mock('../utils/fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof fetchUtils>();
return {
...actual,
fetchWithTimeout: vi.fn(),
isPrivateIp: vi.fn(),
};
});
describe('WebFetchTool', () => {
const mockConfig = {
let mockConfig: Config;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
} as unknown as Config;
});
describe('execute', () => {
it('should return WEB_FETCH_NO_URL_IN_PROMPT when no URL is in the prompt for fallback', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'no url here' };
expect(() => tool.build(params)).toThrow(
"The 'prompt' must contain at least one valid URL (starting with http:// or https://).",
);
});
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
new Error('fetch failed'),
);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://private.ip' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
it('should return WEB_FETCH_PROCESSING_ERROR on general processing failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
mockGenerateContent.mockRejectedValue(new Error('API error'));
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://public.ip' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
});
});
describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
@ -58,10 +113,10 @@ describe('WebFetchTool', () => {
});
it('should return false if approval mode is AUTO_EDIT', async () => {
const tool = new WebFetchTool({
...mockConfig,
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
} as unknown as Config);
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
@ -72,11 +127,7 @@ describe('WebFetchTool', () => {
});
it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => {
const setApprovalMode = vi.fn();
const tool = new WebFetchTool({
...mockConfig,
setApprovalMode,
} as unknown as Config);
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const confirmationDetails = await invocation.shouldConfirmExecute(
@ -93,7 +144,9 @@ describe('WebFetchTool', () => {
);
}
expect(setApprovalMode).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
});
});
});

View File

@ -13,6 +13,7 @@ import {
ToolInvocation,
ToolResult,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import { ApprovalMode, Config } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
@ -73,12 +74,6 @@ class WebFetchToolInvocation extends BaseToolInvocation<
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
const urls = extractUrls(this.params.prompt);
if (urls.length === 0) {
return {
llmContent: 'Error: No URL found in the prompt for fallback.',
returnDisplay: 'Error: No URL found in the prompt for fallback.',
};
}
// For now, we only support one URL for fallback
let url = urls[0];
@ -130,6 +125,10 @@ ${textContent}
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
};
}
}
@ -300,6 +299,10 @@ ${sourceListFormatted.join('\n')}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
};
}
}

View File

@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { WebSearchTool, WebSearchToolParams } from './web-search.js';
import { Config } from '../config/config.js';
import { GeminiClient } from '../core/client.js';
import { ToolErrorType } from './tool-error.js';
// Mock GeminiClient and Config constructor
vi.mock('../core/client.js');
@ -112,7 +113,7 @@ describe('WebSearchTool', () => {
expect(result.returnDisplay).toBe('No information found.');
});
it('should handle API errors gracefully', async () => {
it('should return a WEB_SEARCH_FAILED error on failure', async () => {
const params: WebSearchToolParams = { query: 'error query' };
const testError = new Error('API Failure');
(mockGeminiClient.generateContent as Mock).mockRejectedValue(testError);
@ -120,6 +121,7 @@ describe('WebSearchTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.WEB_SEARCH_FAILED);
expect(result.llmContent).toContain('Error:');
expect(result.llmContent).toContain('API Failure');
expect(result.returnDisplay).toBe('Error performing web search.');

View File

@ -12,6 +12,7 @@ import {
ToolInvocation,
ToolResult,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import { Config } from '../config/config.js';
@ -153,6 +154,10 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error performing web search.`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_SEARCH_FAILED,
},
};
}
}

View File

@ -121,6 +121,7 @@ export class DiffManager {
diffTitle,
{
preview: false,
preserveFocus: true,
},
);
await vscode.commands.executeCommand(