mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-12 02:50:41 -07:00
Merge branch 'main' into feat/enhance-command
This commit is contained in:
@@ -7,7 +7,10 @@
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/ui/key/keyBindings.ts`
|
||||
- Do not implement any logic performing custom string measurement or string
|
||||
truncation. Use Ink layout instead leveraging ResizeObserver as needed.
|
||||
truncation. Use Ink layout instead leveraging ResizeObserver as needed. When
|
||||
using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
|
||||
## Testing
|
||||
|
||||
+153
-35
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
@@ -6,9 +6,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { main } from './src/gemini.js';
|
||||
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './src/utils/cleanup.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
@@ -28,44 +28,162 @@ process.on('uncaughtException', (error) => {
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
process.stderr.write(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
process.stderr.write(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
async function getMemoryNodeArgs(): Promise<string[]> {
|
||||
let autoConfigureMemory = true;
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
if (!process.env['NO_COLOR']) {
|
||||
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
// Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir()
|
||||
const baseDir =
|
||||
process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini');
|
||||
const settingsPath = join(baseDir, 'settings.json');
|
||||
const rawSettings = readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(rawSettings);
|
||||
if (settings?.advanced?.autoConfigureMemory === false) {
|
||||
autoConfigureMemory = false;
|
||||
}
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
if (autoConfigureMemory) {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const currentMaxOldSpaceSizeMb = Math.floor(
|
||||
heapStats.heap_size_limit / 1024 / 1024,
|
||||
);
|
||||
const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
|
||||
|
||||
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
|
||||
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
|
||||
// --- Lightweight Parent Process / Daemon ---
|
||||
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
|
||||
|
||||
const nodeArgs: string[] = [...process.execArgv];
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
nodeArgs.push(...memoryArgs);
|
||||
|
||||
const script = process.argv[1];
|
||||
nodeArgs.push(script);
|
||||
nodeArgs.push(...scriptArgs);
|
||||
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
const RELAUNCH_EXIT_CODE = 199;
|
||||
let latestAdminSettings: unknown = undefined;
|
||||
|
||||
// Prevent the parent process from exiting prematurely on signals.
|
||||
// The child process will receive the same signals and handle its own cleanup.
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||
process.on(sig as NodeJS.Signals, () => {});
|
||||
}
|
||||
|
||||
const runner = () => {
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
if (latestAdminSettings) {
|
||||
child.send({ type: 'admin-settings', settings: latestAdminSettings });
|
||||
}
|
||||
|
||||
child.on('message', (msg: { type?: string; settings?: unknown }) => {
|
||||
if (msg.type === 'admin-settings-update' && msg.settings) {
|
||||
latestAdminSettings = msg.settings;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
child.on('error', (err) => {
|
||||
process.stderr.write(
|
||||
'Error: Failed to start child process: ' + err.message + '\n',
|
||||
);
|
||||
resolve(1);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const exitCode = await runner();
|
||||
if (exitCode !== RELAUNCH_EXIT_CODE) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
process.stdin.resume();
|
||||
process.stderr.write(
|
||||
`Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- Heavy Child Process ---
|
||||
// Now we can safely import everything.
|
||||
const { main } = await import('./src/gemini.js');
|
||||
const { FatalError, writeToStderr } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const { runExitCleanup } = await import('./src/utils/cleanup.js');
|
||||
|
||||
main().catch(async (error: unknown) => {
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError: unknown) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
if (!process.env['NO_COLOR']) {
|
||||
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
|
||||
}
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.36.0-nightly.20260317.2f90b4653",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
|
||||
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
|
||||
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
|
||||
"Use mock tool
|
||||
Use mock tool again
|
||||
Finished.
|
||||
"
|
||||
`;
|
||||
@@ -21,13 +21,15 @@ import {
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
StreamEventType,
|
||||
isWithinRoot,
|
||||
ReadManyFilesTool,
|
||||
type GeminiChat,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type GitService,
|
||||
type ModelRouterService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -99,19 +101,10 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
logToolCall: vi.fn(),
|
||||
isWithinRoot: vi.fn().mockReturnValue(true),
|
||||
LlmRole: {
|
||||
MAIN: 'main',
|
||||
SUBAGENT: 'subagent',
|
||||
@@ -134,6 +127,7 @@ vi.mock(
|
||||
Cancelled: 'cancelled',
|
||||
AwaitingApproval: 'awaiting_approval',
|
||||
},
|
||||
processSingleFileContent: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -177,6 +171,24 @@ describe('GeminiAgent', () => {
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
setClientName: vi.fn(),
|
||||
},
|
||||
setClientName: vi.fn(),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -191,12 +203,16 @@ describe('GeminiAgent', () => {
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
@@ -396,6 +412,26 @@ describe('GeminiAgent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
name: 'gemini-3.1-flash-lite-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
@@ -621,6 +657,7 @@ describe('Session', () => {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'read',
|
||||
@@ -642,12 +679,16 @@ describe('Session', () => {
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModelRouterService: vi.fn().mockReturnValue({
|
||||
route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMcpServers: vi.fn(),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getFileSystemService: vi.fn().mockReturnValue({}),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
@@ -657,6 +698,10 @@ describe('Session', () => {
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
@@ -677,13 +722,28 @@ describe('Session', () => {
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
|
||||
(ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
@@ -753,6 +813,94 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should use model router to determine model', async () => {
|
||||
const mockRouter = {
|
||||
route: vi.fn().mockResolvedValue({ model: 'routed-model' }),
|
||||
} as unknown as ModelRouterService;
|
||||
mockConfig.getModelRouterService.mockReturnValue(mockRouter);
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockRouter.route).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestedModel: 'gemini-pro',
|
||||
request: [{ text: 'Hi' }],
|
||||
}),
|
||||
);
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'routed-model' }),
|
||||
expect.any(Array),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('No finish reason', 'NO_FINISH_REASON'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with no finish reason (NO_FINISH_REASON anomaly)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_FINISH_REASON' });
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
@@ -1016,6 +1164,166 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: false },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
@@ -1080,6 +1388,120 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should split getDisplayTitle and getExplanation for title and content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Original Description',
|
||||
getDisplayTitle: () => 'Display Title Only',
|
||||
getExplanation: () => 'A detailed explanation text',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolCall: expect.objectContaining({
|
||||
title: 'Display Title Only',
|
||||
content: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'A detailed explanation text' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updatePolicy when tool permission triggers always allow', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
const { updatePolicy } = await import('@google/gemini-cli-core');
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
|
||||
expect(updatePolicy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in tool result', async () => {
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
@@ -1292,7 +1714,6 @@ describe('Session', () => {
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
@@ -1350,7 +1771,6 @@ describe('Session', () => {
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
|
||||
MockReadManyFilesTool.mockImplementationOnce(() => ({
|
||||
@@ -1404,6 +1824,172 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle @path validation error and bubble it to user', async () => {
|
||||
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/disallowed.txt');
|
||||
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||
|
||||
// Force fs.stat to fail to skip direct reading and triggers the warning
|
||||
(fs.stat as unknown as Mock).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file://disallowed.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'disallowed.txt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify warning sent via sendUpdate
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'Warning: skipping access to `disallowed.txt`. Reason: Path is outside workspace',
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should read absolute file directly if outside workspace', async () => {
|
||||
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||
const testFilePath = '/tmp/custom.txt';
|
||||
(path.resolve as unknown as Mock).mockReturnValue(testFilePath);
|
||||
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
} as unknown as acp.RequestPermissionResponse);
|
||||
|
||||
const mockStats = {
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
};
|
||||
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
|
||||
(processSingleFileContent as unknown as Mock).mockResolvedValue({
|
||||
llmContent: 'Absolute File Content',
|
||||
});
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: `file://${testFilePath}`,
|
||||
mimeType: 'text/plain',
|
||||
name: 'custom.txt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||
testFilePath,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify content appended to sendMessageStream parts
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Absolute File Content',
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should read escaping relative file directly if outside workspace', async () => {
|
||||
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||
const testFilePath = '../../custom.txt';
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/custom.txt');
|
||||
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
} as unknown as acp.RequestPermissionResponse);
|
||||
|
||||
const mockStats = {
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
};
|
||||
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
|
||||
(processSingleFileContent as unknown as Mock).mockResolvedValue({
|
||||
llmContent: 'Escaping Relative File Content',
|
||||
});
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: `file://${testFilePath}`,
|
||||
mimeType: 'text/plain',
|
||||
name: 'custom.txt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||
'/custom.txt',
|
||||
expect.any(String),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: 'Escaping Relative File Content',
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle cancellation during prompt', async () => {
|
||||
let streamController: ReadableStreamDefaultController<unknown>;
|
||||
const stream = new ReadableStream({
|
||||
@@ -1602,7 +2188,6 @@ describe('Session', () => {
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
});
|
||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
debugLogger,
|
||||
ReadManyFilesTool,
|
||||
REFERENCE_CONTENT_START,
|
||||
resolveModel,
|
||||
type RoutingContext,
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
Kind,
|
||||
@@ -42,12 +42,16 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -63,6 +67,7 @@ import {
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -73,6 +78,17 @@ import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
|
||||
const RequestPermissionResponseSchema = z.object({
|
||||
outcome: z.discriminatedUnion('outcome', [
|
||||
z.object({ outcome: z.literal('cancelled') }),
|
||||
z.object({
|
||||
outcome: z.literal('selected'),
|
||||
optionId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export async function runAcpClient(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
@@ -98,6 +114,12 @@ export async function runAcpClient(
|
||||
}
|
||||
|
||||
export class GeminiAgent {
|
||||
private static callIdCounter = 0;
|
||||
|
||||
static generateCallId(name: string): string {
|
||||
return `${name}-${Date.now()}-${++GeminiAgent.callIdCounter}`;
|
||||
}
|
||||
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
private apiKey: string | undefined;
|
||||
@@ -115,6 +137,7 @@ export class GeminiAgent {
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -294,6 +317,7 @@ export class GeminiAgent {
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
@@ -303,6 +327,7 @@ export class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
@@ -347,20 +372,10 @@ export class GeminiAgent {
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessionSelector = new SessionSelector(config.storage);
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
@@ -434,7 +449,19 @@ export class GeminiAgent {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Now that we are authenticated, it is safe to initialize the config
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
@@ -491,6 +518,12 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -726,10 +759,15 @@ export class Session {
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
try {
|
||||
const model = resolveModel(
|
||||
this.context.config.getModel(),
|
||||
(await this.context.config.getGemini31Launched?.()) ?? false,
|
||||
);
|
||||
const routingContext: RoutingContext = {
|
||||
history: chat.getHistory(/*curated=*/ true),
|
||||
request: nextMessage?.parts ?? [],
|
||||
signal: pendingSend.signal,
|
||||
requestedModel: this.context.config.getModel(),
|
||||
};
|
||||
|
||||
const router = this.context.config.getModelRouterService();
|
||||
const { model } = await router.route(routingContext);
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{ model },
|
||||
nextMessage?.parts ?? [],
|
||||
@@ -820,6 +858,40 @@ export class Session {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof InvalidStreamError ||
|
||||
(error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
(error.type === 'NO_RESPONSE_TEXT' ||
|
||||
error.type === 'NO_FINISH_REASON' ||
|
||||
error.type === 'MALFORMED_FUNCTION_CALL' ||
|
||||
error.type === 'UNEXPECTED_TOOL_CALL'))
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new acp.RequestError(
|
||||
getErrorStatus(error) || 500,
|
||||
getAcpErrorMessage(error),
|
||||
@@ -897,7 +969,7 @@ export class Session {
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<Part[]> {
|
||||
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
|
||||
const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
|
||||
const args = fc.args ?? {};
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -947,6 +1019,23 @@ export class Session {
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
|
||||
const displayTitle =
|
||||
typeof invocation.getDisplayTitle === 'function'
|
||||
? invocation.getDisplayTitle()
|
||||
: invocation.getDescription();
|
||||
|
||||
const explanation =
|
||||
typeof invocation.getExplanation === 'function'
|
||||
? invocation.getExplanation()
|
||||
: '';
|
||||
|
||||
if (explanation) {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: explanation },
|
||||
});
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
@@ -974,21 +1063,24 @@ export class Session {
|
||||
options: toPermissionOptions(
|
||||
confirmationDetails,
|
||||
this.context.config,
|
||||
this.settings.merged.security.enablePermanentToolApproval,
|
||||
),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
title: invocation.getDescription(),
|
||||
title: displayTitle,
|
||||
content,
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const output = await this.connection.requestPermission(params);
|
||||
const output = RequestPermissionResponseSchema.parse(
|
||||
await this.connection.requestPermission(params),
|
||||
);
|
||||
|
||||
const outcome =
|
||||
output.outcome.outcome === CoreToolCallStatus.Cancelled
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
@@ -996,6 +1088,16 @@ export class Session {
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
|
||||
// Update policy to enable Always Allow persistence
|
||||
await updatePolicy(
|
||||
tool,
|
||||
outcome,
|
||||
confirmationDetails,
|
||||
this.context,
|
||||
this.context.messageBus,
|
||||
invocation,
|
||||
);
|
||||
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.Cancel:
|
||||
return errorResponse(
|
||||
@@ -1014,26 +1116,32 @@ export class Session {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
status: 'in_progress',
|
||||
title: invocation.getDescription(),
|
||||
content: [],
|
||||
title: displayTitle,
|
||||
content,
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
const toolResult: ToolResult = await invocation.execute({
|
||||
abortSignal,
|
||||
});
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
const updateContent: acp.ToolCallContent[] = content ? [content] : [];
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
title: invocation.getDescription(),
|
||||
content: content ? [content] : [],
|
||||
title: displayTitle,
|
||||
content: updateContent,
|
||||
locations: invocation.toolLocations(),
|
||||
kind: toAcpToolKind(tool.kind),
|
||||
});
|
||||
@@ -1195,6 +1303,11 @@ export class Session {
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
const directContents: Array<{
|
||||
spec: string;
|
||||
content?: string;
|
||||
part?: Part;
|
||||
}> = [];
|
||||
|
||||
const toolRegistry = this.context.toolRegistry;
|
||||
const readManyFilesTool = new ReadManyFilesTool(
|
||||
@@ -1217,28 +1330,197 @@ export class Session {
|
||||
}
|
||||
let currentPathSpec = pathName;
|
||||
let resolvedSuccessfully = false;
|
||||
let readDirectly = false;
|
||||
try {
|
||||
const absolutePath = path.resolve(
|
||||
this.context.config.getTargetDir(),
|
||||
pathName,
|
||||
);
|
||||
if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
|
||||
let validationError = this.context.config.validatePathAccess(
|
||||
absolutePath,
|
||||
'read',
|
||||
);
|
||||
|
||||
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
|
||||
if (
|
||||
validationError &&
|
||||
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
|
||||
) {
|
||||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isFile()) {
|
||||
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
|
||||
const params = {
|
||||
sessionId: this.id,
|
||||
options: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow once',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Deny',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as acp.PermissionOption[],
|
||||
toolCall: {
|
||||
toolCallId: syntheticCallId,
|
||||
status: 'pending',
|
||||
title: `Allow access to absolute path: ${pathName}`,
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
locations: [],
|
||||
kind: 'read',
|
||||
},
|
||||
};
|
||||
|
||||
const output = RequestPermissionResponseSchema.parse(
|
||||
await this.connection.requestPermission(params),
|
||||
);
|
||||
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
|
||||
this.context.config
|
||||
.getWorkspaceContext()
|
||||
.addReadOnlyPath(absolutePath);
|
||||
validationError = null;
|
||||
} else {
|
||||
this.debug(
|
||||
`Direct read authorization denied for absolute path ${pathName}`,
|
||||
);
|
||||
directContents.push({
|
||||
spec: pathName,
|
||||
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.debug(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
} else {
|
||||
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!validationError) {
|
||||
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
|
||||
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
|
||||
if (
|
||||
(path.isAbsolute(pathName) ||
|
||||
!isWithinRoot(
|
||||
absolutePath,
|
||||
this.context.config.getTargetDir(),
|
||||
)) &&
|
||||
!readDirectly
|
||||
) {
|
||||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isFile()) {
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
absolutePath,
|
||||
this.context.config.getTargetDir(),
|
||||
this.context.config.getFileSystemService(),
|
||||
);
|
||||
|
||||
if (!fileReadResult.error) {
|
||||
if (
|
||||
typeof fileReadResult.llmContent === 'object' &&
|
||||
'inlineData' in fileReadResult.llmContent
|
||||
) {
|
||||
directContents.push({
|
||||
spec: pathName,
|
||||
part: fileReadResult.llmContent,
|
||||
});
|
||||
} else if (typeof fileReadResult.llmContent === 'string') {
|
||||
let contentToPush = fileReadResult.llmContent;
|
||||
if (fileReadResult.isTruncated) {
|
||||
contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`;
|
||||
}
|
||||
directContents.push({
|
||||
spec: pathName,
|
||||
content: contentToPush,
|
||||
});
|
||||
}
|
||||
readDirectly = true;
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.debug(
|
||||
`Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`,
|
||||
);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.debug(
|
||||
`File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!readDirectly) {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
this.debug(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
this.debug(
|
||||
`Path ${pathName} resolved to file: ${currentPathSpec}`,
|
||||
);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.debug(
|
||||
`Path ${pathName} is outside the project directory. Skipping.`,
|
||||
`Path ${pathName} access disallowed: ${validationError}. Skipping.`,
|
||||
);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
@@ -1298,7 +1580,9 @@ export class Session {
|
||||
}
|
||||
}
|
||||
if (resolvedSuccessfully) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
if (!readDirectly) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
}
|
||||
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
||||
contentLabelsForDisplay.push(pathName);
|
||||
}
|
||||
@@ -1359,7 +1643,11 @@ export class Session {
|
||||
|
||||
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
||||
|
||||
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
|
||||
if (
|
||||
pathSpecsToRead.length === 0 &&
|
||||
embeddedContext.length === 0 &&
|
||||
directContents.length === 0
|
||||
) {
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
debugLogger.warn('No valid file paths found in @ commands to read.');
|
||||
return [{ text: initialQueryText }];
|
||||
@@ -1370,7 +1658,7 @@ export class Session {
|
||||
include: pathSpecsToRead,
|
||||
};
|
||||
|
||||
const callId = `${readManyFilesTool.name}-${Date.now()}`;
|
||||
const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
|
||||
|
||||
try {
|
||||
const invocation = readManyFilesTool.build(toolArgs);
|
||||
@@ -1385,7 +1673,7 @@ export class Session {
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
const content = toToolCallContent(result) || {
|
||||
type: 'content',
|
||||
content: {
|
||||
@@ -1451,6 +1739,30 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
if (directContents.length > 0) {
|
||||
const hasReferenceStart = processedQueryParts.some(
|
||||
(p) =>
|
||||
'text' in p &&
|
||||
typeof p.text === 'string' &&
|
||||
p.text.includes(REFERENCE_CONTENT_START),
|
||||
);
|
||||
if (!hasReferenceStart) {
|
||||
processedQueryParts.push({
|
||||
text: `\n${REFERENCE_CONTENT_START}`,
|
||||
});
|
||||
}
|
||||
for (const item of directContents) {
|
||||
processedQueryParts.push({
|
||||
text: `\nContent from @${item.spec}:\n`,
|
||||
});
|
||||
if (item.content) {
|
||||
processedQueryParts.push({ text: item.content });
|
||||
} else if (item.part) {
|
||||
processedQueryParts.push(item.part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (embeddedContext.length > 0) {
|
||||
processedQueryParts.push({
|
||||
text: '\n--- Content from referenced context ---',
|
||||
@@ -1537,6 +1849,7 @@ const basicPermissionOptions = [
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
@@ -1546,37 +1859,65 @@ function toPermissionOptions(
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
@@ -1598,6 +1939,7 @@ function toPermissionOptions(
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
@@ -1678,10 +2020,31 @@ function buildAvailableModels(
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
@@ -1725,7 +2088,7 @@ function buildAvailableModels(
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
manualOptions.unshift(
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
@@ -1734,7 +2097,16 @@ function buildAvailableModels(
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -26,5 +26,11 @@ describe('CommandHandler', () => {
|
||||
|
||||
const init = parse('/init');
|
||||
expect(init.commandToExecute?.name).toBe('init');
|
||||
|
||||
const about = parse('/about');
|
||||
expect(about.commandToExecute?.name).toBe('about');
|
||||
|
||||
const help = parse('/help');
|
||||
expect(help.commandToExecute?.name).toBe('help');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { MemoryCommand } from './commands/memory.js';
|
||||
import { ExtensionsCommand } from './commands/extensions.js';
|
||||
import { InitCommand } from './commands/init.js';
|
||||
import { RestoreCommand } from './commands/restore.js';
|
||||
import { AboutCommand } from './commands/about.js';
|
||||
import { HelpCommand } from './commands/help.js';
|
||||
|
||||
export class CommandHandler {
|
||||
private registry: CommandRegistry;
|
||||
@@ -24,6 +26,8 @@ export class CommandHandler {
|
||||
registry.register(new ExtensionsCommand());
|
||||
registry.register(new InitCommand());
|
||||
registry.register(new RestoreCommand());
|
||||
registry.register(new AboutCommand());
|
||||
registry.register(new HelpCommand(registry));
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
IdeClient,
|
||||
UserAccountManager,
|
||||
getVersion,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import process from 'node:process';
|
||||
|
||||
export class AboutCommand implements Command {
|
||||
readonly name = 'about';
|
||||
readonly description = 'Show version and environment info';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'];
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.agentContext.config.getModel() || 'Unknown';
|
||||
const cliVersion = await getVersion();
|
||||
const selectedAuthType =
|
||||
context.settings.merged?.security?.auth?.selectedType ?? '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
|
||||
const userAccountManager = new UserAccountManager();
|
||||
const cachedAccount = userAccountManager.getCachedGoogleAccount();
|
||||
const userEmail = cachedAccount ?? 'Unknown';
|
||||
|
||||
const tier = context.agentContext.config.getUserTierName() || 'Unknown';
|
||||
|
||||
const info = [
|
||||
`- Version: ${cliVersion}`,
|
||||
`- OS: ${osVersion}`,
|
||||
`- Sandbox: ${sandboxEnv}`,
|
||||
`- Model: ${modelVersion}`,
|
||||
`- Auth Type: ${selectedAuthType}`,
|
||||
`- GCP Project: ${gcpProject}`,
|
||||
`- IDE Client: ${ideClient}`,
|
||||
`- User Email: ${userEmail}`,
|
||||
`- Tier: ${tier}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Gemini CLI Info:\n${info}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.agentContext.config.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
@@ -284,7 +284,7 @@ export class LinkExtensionCommand implements Command {
|
||||
|
||||
try {
|
||||
await stat(sourceFilepath);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HelpCommand } from './help.js';
|
||||
import { CommandRegistry } from './commandRegistry.js';
|
||||
import type { Command, CommandContext } from './types.js';
|
||||
|
||||
describe('HelpCommand', () => {
|
||||
it('returns formatted help text with sorted commands', async () => {
|
||||
const registry = new CommandRegistry();
|
||||
|
||||
const cmdB: Command = {
|
||||
name: 'bravo',
|
||||
description: 'Bravo command',
|
||||
execute: async () => ({ name: 'bravo', data: '' }),
|
||||
};
|
||||
|
||||
const cmdA: Command = {
|
||||
name: 'alpha',
|
||||
description: 'Alpha command',
|
||||
execute: async () => ({ name: 'alpha', data: '' }),
|
||||
};
|
||||
|
||||
registry.register(cmdB);
|
||||
registry.register(cmdA);
|
||||
|
||||
const helpCommand = new HelpCommand(registry);
|
||||
|
||||
const context = {} as CommandContext;
|
||||
|
||||
const response = await helpCommand.execute(context, []);
|
||||
|
||||
expect(response.name).toBe('help');
|
||||
|
||||
const data = response.data as string;
|
||||
|
||||
expect(data).toContain('Gemini CLI Help:');
|
||||
expect(data).toContain('### Basics');
|
||||
expect(data).toContain('### Commands');
|
||||
|
||||
const lines = data.split('\n');
|
||||
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
|
||||
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
|
||||
|
||||
expect(alphaIndex).toBeLessThan(bravoIndex);
|
||||
expect(alphaIndex).not.toBe(-1);
|
||||
expect(bravoIndex).not.toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import type { CommandRegistry } from './commandRegistry.js';
|
||||
|
||||
export class HelpCommand implements Command {
|
||||
readonly name = 'help';
|
||||
readonly description = 'Show available commands';
|
||||
|
||||
constructor(private registry: CommandRegistry) {}
|
||||
|
||||
async execute(
|
||||
_context: CommandContext,
|
||||
_args: string[] = [],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const commands = this.registry
|
||||
.getAllCommands()
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Gemini CLI Help:');
|
||||
lines.push('');
|
||||
lines.push('### Basics');
|
||||
lines.push(
|
||||
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
lines.push('### Commands');
|
||||
for (const cmd of commands) {
|
||||
if (cmd.description) {
|
||||
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxSkills,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
@@ -30,6 +31,7 @@ export class MemoryCommand implements Command {
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new AddMemoryCommand(),
|
||||
new InboxMemoryCommand(),
|
||||
];
|
||||
readonly requiresWorkspace = true;
|
||||
|
||||
@@ -122,3 +124,39 @@ export class AddMemoryCommand implements Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InboxMemoryCommand implements Command {
|
||||
readonly name = 'memory inbox';
|
||||
readonly description =
|
||||
'Lists skills extracted from past sessions that are pending review.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
if (!context.agentContext.config.isMemoryManagerEnabled()) {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
const skills = await listInboxSkills(context.agentContext.config);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { name: this.name, data: 'No extracted skills in inbox.' };
|
||||
}
|
||||
|
||||
const lines = skills.map((s) => {
|
||||
const date = s.extractedAt
|
||||
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
return `- **${s.name}**: ${s.description}${date}`;
|
||||
});
|
||||
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export class ListCheckpointsCommand implements Command {
|
||||
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
|
||||
try {
|
||||
await fs.mkdir(checkpointDir, { recursive: true });
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class ListCheckpointsCommand implements Command {
|
||||
name: this.name,
|
||||
data: `Available Checkpoints:\n${formatted}`,
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
name: this.name,
|
||||
data: 'An unexpected error occurred while listing checkpoints.',
|
||||
|
||||
@@ -4,10 +4,25 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
let mockConnection: Mocked<AgentSideConnection>;
|
||||
@@ -25,13 +40,19 @@ describe('AcpFileSystemService', () => {
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
};
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('readTextFile', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
desc: 'connection if capability exists',
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
setup: () => {
|
||||
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
|
||||
},
|
||||
@@ -45,6 +66,7 @@ describe('AcpFileSystemService', () => {
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
@@ -56,19 +78,72 @@ describe('AcpFileSystemService', () => {
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, setup, verify }) => {
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
setup: () => {
|
||||
mockFallback.readTextFile.mockResolvedValue('content');
|
||||
},
|
||||
verify: () => {
|
||||
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
);
|
||||
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should use $desc',
|
||||
async ({ capability, path, root, setup, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: capability, writeTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
setup();
|
||||
|
||||
const result = await service.readTextFile(path);
|
||||
|
||||
expect(result).toBe('content');
|
||||
verify();
|
||||
},
|
||||
);
|
||||
|
||||
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: capability, writeTextFile: true },
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.readTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for document'),
|
||||
);
|
||||
setup();
|
||||
|
||||
const result = await service.readTextFile('/path/to/file');
|
||||
|
||||
expect(result).toBe('content');
|
||||
verify();
|
||||
await expect(
|
||||
service.readTextFile('/path/to/missing'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for document',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +151,8 @@ describe('AcpFileSystemService', () => {
|
||||
it.each([
|
||||
{
|
||||
capability: true,
|
||||
desc: 'connection if capability exists',
|
||||
path: '/path/to/file',
|
||||
desc: 'connection if capability exists and file is inside root',
|
||||
verify: () => {
|
||||
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
|
||||
path: '/path/to/file',
|
||||
@@ -88,6 +164,7 @@ describe('AcpFileSystemService', () => {
|
||||
},
|
||||
{
|
||||
capability: false,
|
||||
path: '/path/to/file',
|
||||
desc: 'fallback if capability missing',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
@@ -97,17 +174,63 @@ describe('AcpFileSystemService', () => {
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, verify }) => {
|
||||
{
|
||||
capability: true,
|
||||
path: '/outside/file',
|
||||
desc: 'fallback if capability exists but file is outside root',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/outside/file',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
capability: true,
|
||||
path: '/home/user/.gemini/tmp/file.md',
|
||||
root: '/home/user',
|
||||
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
|
||||
verify: () => {
|
||||
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
|
||||
'/home/user/.gemini/tmp/file.md',
|
||||
'content',
|
||||
);
|
||||
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('should use $desc', async ({ capability, path, root, verify }) => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ writeTextFile: capability, readTextFile: true },
|
||||
mockFallback,
|
||||
root || '/path/to',
|
||||
);
|
||||
|
||||
await service.writeTextFile('/path/to/file', 'content');
|
||||
await service.writeTextFile(path, 'content');
|
||||
|
||||
verify();
|
||||
});
|
||||
|
||||
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
|
||||
service = new AcpFileSystemService(
|
||||
mockConnection,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
mockFallback,
|
||||
'/path/to',
|
||||
);
|
||||
mockConnection.writeTextFile.mockRejectedValue(
|
||||
new Error('Resource not found for directory'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.writeTextFile('/path/to/missing', 'content'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
message: 'Resource not found for directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,44 +4,82 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
private readonly geminiDir = path.join(os.homedir(), '.gemini');
|
||||
|
||||
constructor(
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapabilities,
|
||||
private readonly fallback: FileSystemService,
|
||||
private readonly root: string,
|
||||
) {}
|
||||
|
||||
private shouldUseFallback(filePath: string): boolean {
|
||||
// Files inside the global CLI directory must always use the native file system,
|
||||
// even if the user runs the CLI directly from their home directory (which
|
||||
// would make the IDE's project root overlap with the global directory).
|
||||
return (
|
||||
!isWithinRoot(filePath, this.root) ||
|
||||
isWithinRoot(filePath, this.geminiDir)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeFileSystemError(err: unknown): never {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
errorMessage.includes('Resource not found') ||
|
||||
errorMessage.includes('ENOENT') ||
|
||||
errorMessage.includes('does not exist') ||
|
||||
errorMessage.includes('No such file')
|
||||
) {
|
||||
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
|
||||
newErr.code = 'ENOENT';
|
||||
throw newErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async readTextFile(filePath: string): Promise<string> {
|
||||
if (!this.capabilities.readTextFile) {
|
||||
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.content;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.content;
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(filePath: string, content: string): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
|
||||
return this.fallback.writeTextFile(filePath, content);
|
||||
}
|
||||
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
try {
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
this.normalizeFileSystemError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ toolName = "grep_search"
|
||||
argsPattern = "(\.env|id_rsa|passwd)"
|
||||
decision = "deny"
|
||||
priority = 200
|
||||
deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension."
|
||||
denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension."
|
||||
|
||||
# Safety Checker: Apply path validation to all write operations.
|
||||
[[safety_checker]]
|
||||
|
||||
@@ -25,7 +25,7 @@ async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('mcp command', () => {
|
||||
|
||||
try {
|
||||
await parser.parse('mcp');
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// yargs might throw an error when demandCommand is not met
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async function testMCPConnection(
|
||||
try {
|
||||
// Use the same transport creation logic as core
|
||||
transport = await createTransport(serverName, config, false, mcpContext);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
await client.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
@@ -135,7 +135,7 @@ async function testMCPConnection(
|
||||
|
||||
await client.close();
|
||||
return MCPServerStatus.CONNECTED;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
await transport.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig } from '../../config/config.js';
|
||||
@@ -32,12 +40,16 @@ vi.mock('../utils.js', () => ({
|
||||
describe('skills list command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockLoadCliConfig = vi.mocked(loadCliConfig);
|
||||
let stdoutWriteSpy: MockInstance<typeof process.stdout.write>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -56,10 +68,7 @@ describe('skills list command', () => {
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No skills discovered.',
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith('No skills discovered.\n');
|
||||
});
|
||||
|
||||
it('should list all discovered skills', async () => {
|
||||
@@ -87,24 +96,19 @@ describe('skills list command', () => {
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
chalk.bold('Discovered Agent Skills:'),
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
chalk.bold('Discovered Agent Skills:') + '\n\n',
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('skill1'),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(chalk.green('[Enabled]')),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('skill2'),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(chalk.red('[Disabled]')),
|
||||
);
|
||||
});
|
||||
@@ -135,12 +139,10 @@ describe('skills list command', () => {
|
||||
|
||||
// Default
|
||||
await handleList({ all: false });
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('regular'),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('builtin'),
|
||||
);
|
||||
|
||||
@@ -148,16 +150,13 @@ describe('skills list command', () => {
|
||||
|
||||
// With all: true
|
||||
await handleList({ all: true });
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('regular'),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('builtin'),
|
||||
);
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(chalk.gray(' [Built-in]')),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
@@ -42,12 +41,11 @@ export async function handleList(args: { all?: boolean }) {
|
||||
});
|
||||
|
||||
if (skills.length === 0) {
|
||||
debugLogger.log('No skills discovered.');
|
||||
process.stdout.write('No skills discovered.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
|
||||
debugLogger.log('');
|
||||
process.stdout.write(chalk.bold('Discovered Agent Skills:') + '\n\n');
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.disabled
|
||||
@@ -56,10 +54,11 @@ export async function handleList(args: { all?: boolean }) {
|
||||
|
||||
const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : '';
|
||||
|
||||
debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`);
|
||||
debugLogger.log(` Description: ${skill.description}`);
|
||||
debugLogger.log(` Location: ${skill.location}`);
|
||||
debugLogger.log('');
|
||||
process.stdout.write(
|
||||
`${chalk.bold(skill.name)} ${status}${builtinSuffix}\n`,
|
||||
);
|
||||
process.stdout.write(` Description: ${skill.description}\n`);
|
||||
process.stdout.write(` Location: ${skill.location}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
generalistProfile,
|
||||
type ContextManagementConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -143,12 +145,17 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
createPolicyEngineConfig: vi.fn(
|
||||
async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: interactive
|
||||
? ServerConfig.PolicyDecision.ASK_USER
|
||||
: ServerConfig.PolicyDecision.DENY,
|
||||
approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT,
|
||||
nonInteractive: !interactive,
|
||||
}),
|
||||
),
|
||||
getAdminErrorMessage: vi.fn(
|
||||
(_feature) =>
|
||||
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
|
||||
@@ -322,6 +329,41 @@ describe('parseArguments', () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe('isCommand middleware', () => {
|
||||
it.each([
|
||||
{ cmd: 'mcp list', expected: true },
|
||||
{ cmd: 'extensions list', expected: true },
|
||||
{ cmd: 'extension list', expected: true },
|
||||
{ cmd: 'skills list', expected: true },
|
||||
{ cmd: 'skill list', expected: true },
|
||||
{ cmd: 'hooks migrate', expected: true },
|
||||
{ cmd: 'hook migrate', expected: true },
|
||||
{ cmd: 'some query', expected: undefined },
|
||||
{ cmd: 'hello world', expected: undefined },
|
||||
])(
|
||||
'should set isCommand to $expected for "$cmd"',
|
||||
async ({ cmd, expected }) => {
|
||||
process.argv = ['node', 'script.js', ...cmd.split(' ')];
|
||||
const settings = createTestMergedSettings({
|
||||
admin: {
|
||||
mcp: { enabled: true },
|
||||
},
|
||||
experimental: {
|
||||
extensionManagement: true,
|
||||
},
|
||||
skills: {
|
||||
enabled: true,
|
||||
},
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
const parsedArgs = await parseArguments(settings);
|
||||
expect(parsedArgs.isCommand).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'should allow --prompt without --prompt-interactive',
|
||||
@@ -949,6 +991,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200, // maxDirs
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -978,6 +1021,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1006,6 +1050,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1082,12 +1127,7 @@ describe('mergeExcludeTools', () => {
|
||||
]);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
);
|
||||
@@ -1326,8 +1366,8 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
'test',
|
||||
];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
plan: true,
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1441,9 +1481,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
const settings = createTestMergedSettings({
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
},
|
||||
experimental: {
|
||||
plan: false,
|
||||
plan: { enabled: false },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -1451,14 +1489,12 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should allow plan approval mode if experimental plan is enabled', async () => {
|
||||
it('should allow plan approval mode if plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
},
|
||||
experimental: {
|
||||
plan: true,
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -2140,6 +2176,89 @@ describe('loadCliConfig directWebFetch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig context management', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be false by default when generalistProfile / context management is not set in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).haveOwnProperty(
|
||||
'enabled',
|
||||
false,
|
||||
);
|
||||
expect(config.isContextManagementEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when generalistProfile is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
generalistProfile: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual(
|
||||
generalistProfile,
|
||||
);
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true when contextManagement is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const contextManagementConfig: Partial<ContextManagementConfig> = {
|
||||
historyWindow: {
|
||||
maxTokens: 100_000,
|
||||
retainedTokens: 50_000,
|
||||
},
|
||||
messageLimits: {
|
||||
normalMaxTokens: 1000,
|
||||
retainedMaxTokens: 10_000,
|
||||
normalizationHeadRatio: 0.25,
|
||||
},
|
||||
tools: {
|
||||
distillation: {
|
||||
maxOutputTokens: 10_000,
|
||||
summarizationThresholdTokens: 15_000,
|
||||
},
|
||||
outputMasking: {
|
||||
protectionThresholdTokens: 30_000,
|
||||
minPrunableThresholdTokens: 10_000,
|
||||
protectLatestTurn: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
contextManagement: true,
|
||||
},
|
||||
// The type of numbers is being inferred strangely, and so we have to cast
|
||||
// to `any` here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
contextManagement: contextManagementConfig as any,
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual({
|
||||
enabled: true,
|
||||
...contextManagementConfig,
|
||||
});
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screenReader configuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -2704,12 +2823,12 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => {
|
||||
it('should set Plan approval mode when --approval-mode=plan is used and plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
plan: true,
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2729,12 +2848,12 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => {
|
||||
it('should throw error when --approval-mode=plan is used but plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
plan: false,
|
||||
general: {
|
||||
plan: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2855,22 +2974,26 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
|
||||
it('should respect plan mode from settings when plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
|
||||
it('should fall back to default if plan mode is in settings but disabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: false },
|
||||
general: {
|
||||
defaultApprovalMode: 'plan',
|
||||
plan: { enabled: false },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -3425,6 +3548,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3446,6 +3571,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3469,6 +3596,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3652,7 +3781,9 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
it('should use plan directory from active extension when user has not specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
@@ -3671,9 +3802,11 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
it('should NOT use plan directory from active extension when user has specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { directory: 'user-plans-dir' },
|
||||
plan: {
|
||||
enabled: true,
|
||||
directory: 'user-plans-dir',
|
||||
},
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -3694,7 +3827,9 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
it('should NOT use plan directory from inactive extension', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
@@ -3715,7 +3850,9 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
detectIdeFromEnv,
|
||||
generalistProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -163,12 +164,104 @@ export async function parseArguments(
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
|
||||
)
|
||||
.option('isCommand', {
|
||||
type: 'boolean',
|
||||
hidden: true,
|
||||
description: 'Internal flag to indicate if a subcommand is being run',
|
||||
})
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
description: 'Run in debug mode (open debug console with F12)',
|
||||
default: false,
|
||||
})
|
||||
.middleware((argv) => {
|
||||
const commandModules = [
|
||||
mcpCommand,
|
||||
extensionsCommand,
|
||||
skillsCommand,
|
||||
hooksCommand,
|
||||
];
|
||||
|
||||
const subcommands = commandModules.flatMap((mod) => {
|
||||
const names: string[] = [];
|
||||
|
||||
const cmd = mod.command;
|
||||
if (cmd) {
|
||||
if (Array.isArray(cmd)) {
|
||||
for (const c of cmd) {
|
||||
names.push(String(c).split(' ')[0]);
|
||||
}
|
||||
} else {
|
||||
names.push(String(cmd).split(' ')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const aliases = mod.aliases;
|
||||
if (aliases) {
|
||||
if (Array.isArray(aliases)) {
|
||||
for (const a of aliases) {
|
||||
names.push(String(a).split(' ')[0]);
|
||||
}
|
||||
} else {
|
||||
names.push(String(aliases).split(' ')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
});
|
||||
|
||||
const firstArg = argv._[0];
|
||||
if (typeof firstArg === 'string' && subcommands.includes(firstArg)) {
|
||||
argv['isCommand'] = true;
|
||||
}
|
||||
}, true)
|
||||
// Ensure validation flows through .fail() for clean UX
|
||||
.fail((msg, err) => {
|
||||
if (err) throw err;
|
||||
throw new Error(msg);
|
||||
})
|
||||
.check((argv) => {
|
||||
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
|
||||
// This guard safely checks if any positional argument was provided.
|
||||
const queryArg = argv['query'];
|
||||
const query =
|
||||
typeof queryArg === 'string' || Array.isArray(queryArg)
|
||||
? queryArg
|
||||
: undefined;
|
||||
const hasPositionalQuery = Array.isArray(query)
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
if (argv['prompt'] && argv['promptInteractive']) {
|
||||
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
|
||||
}
|
||||
if (argv['yolo'] && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
|
||||
const outputFormat = argv['outputFormat'];
|
||||
if (
|
||||
typeof outputFormat === 'string' &&
|
||||
!['text', 'json', 'stream-json'].includes(outputFormat)
|
||||
) {
|
||||
return `Invalid values:\n Argument: output-format, Given: "${outputFormat}", Choices: "text", "json", "stream-json"`;
|
||||
}
|
||||
if (argv['worktree'] && !settings.experimental?.worktrees) {
|
||||
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
yargsInstance.command(mcpCommand);
|
||||
yargsInstance.command(extensionsCommand);
|
||||
yargsInstance.command(skillsCommand);
|
||||
yargsInstance.command(hooksCommand);
|
||||
|
||||
yargsInstance
|
||||
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
@@ -352,59 +445,6 @@ export async function parseArguments(
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand)
|
||||
// Ensure validation flows through .fail() for clean UX
|
||||
.fail((msg, err) => {
|
||||
if (err) throw err;
|
||||
throw new Error(msg);
|
||||
})
|
||||
.check((argv) => {
|
||||
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
|
||||
// This guard safely checks if any positional argument was provided.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const query = argv['query'] as string | string[] | undefined;
|
||||
const hasPositionalQuery = Array.isArray(query)
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
if (argv['prompt'] && argv['promptInteractive']) {
|
||||
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
|
||||
}
|
||||
if (argv['yolo'] && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
if (
|
||||
argv['outputFormat'] &&
|
||||
!['text', 'json', 'stream-json'].includes(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['outputFormat'] as string,
|
||||
)
|
||||
) {
|
||||
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
|
||||
}
|
||||
if (argv['worktree'] && !settings.experimental?.worktrees) {
|
||||
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (settings.experimental?.extensionManagement) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
|
||||
if (settings.skills?.enabled ?? true) {
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
// Register hooks command if hooks are enabled
|
||||
if (settings.hooksConfig.enabled) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
yargsInstance
|
||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
@@ -603,6 +643,7 @@ export async function loadCliConfig(
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
settings.context?.memoryBoundaryMarkers,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
@@ -629,9 +670,9 @@ export async function loadCliConfig(
|
||||
approvalMode = ApprovalMode.AUTO_EDIT;
|
||||
break;
|
||||
case 'plan':
|
||||
if (!(settings.experimental?.plan ?? false)) {
|
||||
if (!(settings.general?.plan?.enabled ?? true)) {
|
||||
debugLogger.warn(
|
||||
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
|
||||
'Approval mode "plan" is disabled in your settings. Falling back to "default".',
|
||||
);
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
} else {
|
||||
@@ -753,8 +794,8 @@ export async function loadCliConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
workspacePoliciesDir,
|
||||
interactive,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
@@ -843,6 +884,16 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
|
||||
return new Config({
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
@@ -857,6 +908,7 @@ export async function loadCliConfig(
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
@@ -886,6 +938,8 @@ export async function loadCliConfig(
|
||||
: undefined,
|
||||
blockedEnvironmentVariables:
|
||||
settings.security?.environmentVariableRedaction?.blocked,
|
||||
allowedEnvironmentVariables:
|
||||
settings.security?.environmentVariableRedaction?.allowed,
|
||||
enableEnvironmentVariableRedaction:
|
||||
settings.security?.environmentVariableRedaction?.enabled,
|
||||
userMemory: memoryContent,
|
||||
@@ -925,7 +979,7 @@ export async function loadCliConfig(
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
@@ -936,9 +990,9 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
@@ -949,8 +1003,12 @@ export async function loadCliConfig(
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useTerminalBuffer: settings.ui?.terminalBuffer,
|
||||
useRenderProcess: settings.ui?.renderProcess,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
@@ -963,6 +1021,7 @@ export async function loadCliConfig(
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
|
||||
adk: settings.experimental?.adk,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
@@ -1017,7 +1076,7 @@ async function resolveWorktreeSettings(
|
||||
if (isGeminiWorktree(toplevel, projectRoot)) {
|
||||
worktreePath = toplevel;
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('copyExtension permissions', () => {
|
||||
makeWritableSync(path.join(p, child)),
|
||||
);
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
getMemoryBoundaryMarkers: () => ['.git'],
|
||||
getMcpClientManager: () => ({
|
||||
getMcpInstructions: () => '',
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('ExtensionManager', () => {
|
||||
themeManager.clearExtensionThemes();
|
||||
try {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
@@ -614,7 +614,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
this.loadingPromise = (async () => {
|
||||
try {
|
||||
if (this.settings.admin.extensions.enabled === false) {
|
||||
if (this.settings.admin?.extensions?.enabled === false) {
|
||||
this.loadedExtensions = [];
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
@@ -824,11 +824,11 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
|
||||
if (config.mcpServers) {
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
if (this.settings.admin?.mcp?.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
// Apply admin allowlist if configured
|
||||
const adminAllowlist = this.settings.admin.mcp.config;
|
||||
const adminAllowlist = this.settings.admin?.mcp?.config;
|
||||
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
@@ -1298,7 +1298,9 @@ export async function inferInstallMetadata(
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
source.startsWith('sso://') ||
|
||||
source.startsWith('github:') ||
|
||||
source.startsWith('gitlab:')
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function loadInstallMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function fetchReleaseFromGithub(
|
||||
return await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
||||
);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// This can fail if there is no release marked latest. In that case
|
||||
// we want to just try the pre-release logic below.
|
||||
}
|
||||
|
||||
@@ -5,87 +5,197 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveItemsFromLegacySettings } from './footerItems.js';
|
||||
import {
|
||||
deriveItemsFromLegacySettings,
|
||||
resolveFooterState,
|
||||
} from './footerItems.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
|
||||
describe('deriveItemsFromLegacySettings', () => {
|
||||
it('returns defaults when no legacy settings are customized', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'workspace',
|
||||
'git-branch',
|
||||
'sandbox',
|
||||
'model-name',
|
||||
'quota',
|
||||
]);
|
||||
});
|
||||
describe('footerItems', () => {
|
||||
describe('deriveItemsFromLegacySettings', () => {
|
||||
it('returns defaults when no legacy settings are customized', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'workspace',
|
||||
'git-branch',
|
||||
'sandbox',
|
||||
'model-name',
|
||||
'quota',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes workspace when hideCWD is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('workspace');
|
||||
});
|
||||
it('removes workspace when hideCWD is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('workspace');
|
||||
});
|
||||
|
||||
it('removes sandbox when hideSandboxStatus is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('sandbox');
|
||||
});
|
||||
|
||||
it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('model-name');
|
||||
expect(items).not.toContain('context-used');
|
||||
expect(items).not.toContain('quota');
|
||||
});
|
||||
|
||||
it('includes context-used when hideContextPercentage is false', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: false } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('context-used');
|
||||
// Should be after model-name
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
const contextIdx = items.indexOf('context-used');
|
||||
expect(contextIdx).toBe(modelIdx + 1);
|
||||
});
|
||||
|
||||
it('includes memory-usage when showMemoryUsage is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('memory-usage');
|
||||
});
|
||||
|
||||
it('handles combination of settings', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideModelInfo: true,
|
||||
hideContextPercentage: false,
|
||||
it('removes sandbox when hideSandboxStatus is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: { hideSandboxStatus: true, hideContextPercentage: true },
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'git-branch',
|
||||
'sandbox',
|
||||
'context-used',
|
||||
'memory-usage',
|
||||
]);
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('sandbox');
|
||||
});
|
||||
|
||||
it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).not.toContain('model-name');
|
||||
expect(items).not.toContain('context-used');
|
||||
expect(items).not.toContain('quota');
|
||||
});
|
||||
|
||||
it('includes context-used when hideContextPercentage is false', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { footer: { hideContextPercentage: false } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('context-used');
|
||||
// Should be after model-name
|
||||
const modelIdx = items.indexOf('model-name');
|
||||
const contextIdx = items.indexOf('context-used');
|
||||
expect(contextIdx).toBe(modelIdx + 1);
|
||||
});
|
||||
|
||||
it('includes memory-usage when showMemoryUsage is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toContain('memory-usage');
|
||||
});
|
||||
|
||||
it('handles combination of settings', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideModelInfo: true,
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
const items = deriveItemsFromLegacySettings(settings);
|
||||
expect(items).toEqual([
|
||||
'git-branch',
|
||||
'sandbox',
|
||||
'context-used',
|
||||
'memory-usage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFooterState', () => {
|
||||
it('filters out auth item when showUserIdentity is false', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showUserIdentity: false,
|
||||
footer: {
|
||||
items: ['workspace', 'auth', 'model-name'],
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
expect(state.orderedIds).not.toContain('auth');
|
||||
expect(state.selectedIds.has('auth')).toBe(false);
|
||||
// It should also not be in the 'others' part of orderedIds
|
||||
expect(state.orderedIds).toEqual([
|
||||
'workspace',
|
||||
'model-name',
|
||||
'git-branch',
|
||||
'sandbox',
|
||||
'context-used',
|
||||
'quota',
|
||||
'memory-usage',
|
||||
'session-id',
|
||||
'code-changes',
|
||||
'token-count',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes auth item when showUserIdentity is true', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showUserIdentity: true,
|
||||
footer: {
|
||||
items: ['workspace', 'auth', 'model-name'],
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
expect(state.orderedIds).toContain('auth');
|
||||
expect(state.selectedIds.has('auth')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes auth item by default when showUserIdentity is undefined (defaults to true)', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['workspace', 'auth', 'model-name'],
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
expect(state.orderedIds).toContain('auth');
|
||||
expect(state.selectedIds.has('auth')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes context-used in selectedIds when hideContextPercentage is false and items is undefined', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
expect(state.selectedIds.has('context-used')).toBe(true);
|
||||
expect(state.orderedIds).toContain('context-used');
|
||||
});
|
||||
|
||||
it('does not include context-used in selectedIds when hideContextPercentage is true (default)', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: true,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
expect(state.selectedIds.has('context-used')).toBe(false);
|
||||
// context-used should still be in orderedIds (as unselected)
|
||||
expect(state.orderedIds).toContain('context-used');
|
||||
});
|
||||
|
||||
it('persisted items array takes precedence over hideContextPercentage', () => {
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: ['workspace', 'model-name'],
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
const state = resolveFooterState(settings);
|
||||
// items array explicitly omits context-used, so it should not be selected
|
||||
expect(state.selectedIds.has('context-used')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,11 @@ export const ALL_ITEMS = [
|
||||
header: 'session',
|
||||
description: 'Unique identifier for the current session',
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
header: '/auth',
|
||||
description: 'Current authentication info',
|
||||
},
|
||||
{
|
||||
id: 'code-changes',
|
||||
header: 'diff',
|
||||
@@ -70,6 +75,7 @@ export const DEFAULT_ORDER = [
|
||||
'quota',
|
||||
'memory-usage',
|
||||
'session-id',
|
||||
'auth',
|
||||
'code-changes',
|
||||
'token-count',
|
||||
];
|
||||
@@ -121,10 +127,19 @@ export function resolveFooterState(settings: MergedSettings): {
|
||||
orderedIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
} {
|
||||
const showUserIdentity = settings.ui?.showUserIdentity !== false;
|
||||
const filteredValidIds = showUserIdentity
|
||||
? VALID_IDS
|
||||
: new Set([...VALID_IDS].filter((id) => id !== 'auth'));
|
||||
|
||||
const source = (
|
||||
settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings)
|
||||
).filter((id: string) => VALID_IDS.has(id));
|
||||
const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
|
||||
).filter((id: string) => filteredValidIds.has(id));
|
||||
|
||||
const others = DEFAULT_ORDER.filter(
|
||||
(id) => !source.includes(id) && filteredValidIds.has(id),
|
||||
);
|
||||
|
||||
return {
|
||||
orderedIds: [...source, ...others],
|
||||
selectedIds: new Set(source),
|
||||
|
||||
@@ -67,6 +67,11 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(
|
||||
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// invoke_agent should be allowed by default (via agents.toml)
|
||||
expect(
|
||||
(await engine.check({ name: 'invoke_agent' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should handle MCP server wildcard patterns correctly', async () => {
|
||||
@@ -350,9 +355,37 @@ describe('Policy Engine Integration Tests', () => {
|
||||
(await engine.check({ name: 'get_internal_docs' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'cli_help' }, undefined)).decision,
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'invoke_agent', args: { agent_name: 'cli_help' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// codebase_investigator should be allowed in Plan mode
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{
|
||||
name: 'invoke_agent',
|
||||
args: { agent_name: 'codebase_investigator' },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Unknown agents should be denied in Plan mode (via catch-all)
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'invoke_agent', args: { agent_name: 'unknown_agent' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Other tools should be denied via catch all
|
||||
expect(
|
||||
(await engine.check({ name: 'replace' }, undefined)).decision,
|
||||
@@ -381,6 +414,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
// Add a manual rule with annotations to the config
|
||||
config.rules = config.rules || [];
|
||||
config.rules.push({
|
||||
toolName: '*',
|
||||
toolAnnotations: { readOnlyHint: true },
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
@@ -519,8 +553,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const readOnlyToolRule = rules.find(
|
||||
(r) => r.toolName === 'glob' && !r.subagent,
|
||||
);
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
|
||||
// Priority 50 in default tier → 1.05 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(
|
||||
@@ -604,12 +638,12 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify non-interactive mode transformation', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
const engineConfig = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Enable non-interactive mode
|
||||
const engineConfig = { ...config, nonInteractive: true };
|
||||
const engine = new PolicyEngine(engineConfig);
|
||||
|
||||
// ASK_USER should become DENY in non-interactive mode
|
||||
@@ -676,8 +710,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent);
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
workspacePoliciesDir?: string,
|
||||
interactive: boolean = true,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
// Explicitly construct PolicySettings from Settings to ensure type safety
|
||||
// and avoid accidental leakage of other settings properties.
|
||||
@@ -68,7 +69,12 @@ export async function createPolicyEngineConfig(
|
||||
settings.admin?.secureModeEnabled,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
return createCorePolicyEngineConfig(
|
||||
policySettings,
|
||||
approvalMode,
|
||||
undefined,
|
||||
interactive,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPolicyUpdater(
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'lxc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'env/image',
|
||||
});
|
||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
command: 'podman',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
image: 'custom/image',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/settings-path'],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
||||
|
||||
let sandboxValue: boolean | string | null | undefined;
|
||||
let allowedPaths: string[] = [];
|
||||
let networkAccess = false;
|
||||
let networkAccess = true;
|
||||
let customImage: string | undefined;
|
||||
|
||||
if (
|
||||
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
|
||||
const config = sandboxOption;
|
||||
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
||||
allowedPaths = config.allowedPaths ?? [];
|
||||
networkAccess = config.networkAccess ?? false;
|
||||
networkAccess = config.networkAccess ?? true;
|
||||
customImage = config.image;
|
||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||
sandboxValue = sandboxOption;
|
||||
|
||||
@@ -612,7 +612,7 @@ export function loadEnvironment(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
|
||||
}
|
||||
}
|
||||
@@ -1124,15 +1124,15 @@ function migrateExperimentalSettings(
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
const migrateExperimental = (
|
||||
const migrateExperimental = <T = Record<string, unknown>>(
|
||||
oldKey: string,
|
||||
migrateFn: (oldValue: Record<string, unknown>) => void,
|
||||
migrateFn: (oldValue: T) => void,
|
||||
) => {
|
||||
const old = experimentalSettings[oldKey];
|
||||
if (old) {
|
||||
if (old !== undefined) {
|
||||
foundDeprecated?.push(`experimental.${oldKey}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
migrateFn(old as Record<string, unknown>);
|
||||
migrateFn(old as T);
|
||||
modified = true;
|
||||
}
|
||||
};
|
||||
@@ -1197,6 +1197,24 @@ function migrateExperimentalSettings(
|
||||
agentsOverrides['cli_help'] = override;
|
||||
});
|
||||
|
||||
// Migrate experimental.plan -> general.plan.enabled
|
||||
migrateExperimental<boolean>('plan', (planValue) => {
|
||||
const generalSettings =
|
||||
(settings.general as Record<string, unknown> | undefined) || {};
|
||||
const newGeneral = { ...generalSettings };
|
||||
const planSettings =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(newGeneral['plan'] as Record<string, unknown> | undefined) || {};
|
||||
const newPlan = { ...planSettings };
|
||||
|
||||
if (newPlan['enabled'] === undefined) {
|
||||
newPlan['enabled'] = planValue;
|
||||
newGeneral['plan'] = newPlan;
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
loadedSettings.setValue(scope, 'agents', agentsSettings);
|
||||
@@ -1205,6 +1223,7 @@ function migrateExperimentalSettings(
|
||||
const newExperimental = { ...experimentalSettings };
|
||||
delete newExperimental['codebaseInvestigatorSettings'];
|
||||
delete newExperimental['cliHelpAgentSettings'];
|
||||
delete newExperimental['plan'];
|
||||
loadedSettings.setValue(scope, 'experimental', newExperimental);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('SettingsSchema', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
|
||||
expect(definition).toBeDefined();
|
||||
expect(definition?.type).toBe('enum');
|
||||
expect(definition?.default).toBe('tips');
|
||||
expect(definition?.default).toBe('off');
|
||||
expect(definition?.options?.map((o) => o.value)).toEqual([
|
||||
'tips',
|
||||
'witty',
|
||||
@@ -418,14 +418,17 @@ describe('SettingsSchema', () => {
|
||||
});
|
||||
|
||||
it('should have plan setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.plan;
|
||||
const setting =
|
||||
getSettingsSchema().general.properties.plan.properties.enabled;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.category).toBe('General');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(true);
|
||||
expect(setting.description).toBe('Enable Plan Mode.');
|
||||
expect(setting.description).toBe(
|
||||
'Enable Plan Mode for read-only safety during planning.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
@@ -502,6 +505,31 @@ describe('SettingsSchema', () => {
|
||||
'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have adk setting in schema', () => {
|
||||
const adk = getSettingsSchema().experimental.properties.adk;
|
||||
expect(adk).toBeDefined();
|
||||
expect(adk.type).toBe('object');
|
||||
expect(adk.category).toBe('Experimental');
|
||||
expect(adk.default).toEqual({});
|
||||
expect(adk.requiresRestart).toBe(true);
|
||||
expect(adk.showInDialog).toBe(false);
|
||||
expect(adk.description).toBe(
|
||||
'Settings for the Agent Development Kit (ADK).',
|
||||
);
|
||||
|
||||
const agentSessionNoninteractiveEnabled =
|
||||
adk.properties.agentSessionNoninteractiveEnabled;
|
||||
expect(agentSessionNoninteractiveEnabled).toBeDefined();
|
||||
expect(agentSessionNoninteractiveEnabled.type).toBe('boolean');
|
||||
expect(agentSessionNoninteractiveEnabled.category).toBe('Experimental');
|
||||
expect(agentSessionNoninteractiveEnabled.default).toBe(false);
|
||||
expect(agentSessionNoninteractiveEnabled.requiresRestart).toBe(true);
|
||||
expect(agentSessionNoninteractiveEnabled.showInDialog).toBe(false);
|
||||
expect(agentSessionNoninteractiveEnabled.description).toBe(
|
||||
'Enable non-interactive agent sessions.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('has JSON schema definitions for every referenced ref', () => {
|
||||
|
||||
@@ -261,7 +261,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
|
||||
'Enable run-event notifications for action-required prompts and session completion.',
|
||||
showInDialog: true,
|
||||
},
|
||||
checkpointing: {
|
||||
@@ -293,6 +293,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Planning features configuration.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Plan Mode',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable Plan Mode for read-only safety during planning.',
|
||||
showInDialog: true,
|
||||
},
|
||||
directory: {
|
||||
type: 'string',
|
||||
label: 'Plan Directory',
|
||||
@@ -300,7 +310,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.',
|
||||
showInDialog: true,
|
||||
},
|
||||
modelRouting: {
|
||||
@@ -429,6 +439,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'User interface settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
debugRainbow: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Rainbow',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.',
|
||||
showInDialog: false,
|
||||
},
|
||||
theme: {
|
||||
type: 'string',
|
||||
label: 'Theme',
|
||||
@@ -561,6 +581,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
compactToolOutput: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Tool Output',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -657,6 +687,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the footer from the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
collapseDrawerDuringApproval: {
|
||||
type: 'boolean',
|
||||
label: 'Collapse Drawer During Approval',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Whether to collapse the UI drawer when a tool is awaiting confirmation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
showMemoryUsage: {
|
||||
type: 'boolean',
|
||||
label: 'Show Memory Usage',
|
||||
@@ -713,6 +753,24 @@ const SETTINGS_SCHEMA = {
|
||||
'Use an alternate screen buffer for the UI, preserving shell history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
renderProcess: {
|
||||
type: 'boolean',
|
||||
label: 'Render Process',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable Ink render process for the UI.',
|
||||
showInDialog: true,
|
||||
},
|
||||
terminalBuffer: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Buffer',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Use the new terminal buffer architecture for rendering.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useBackgroundColor: {
|
||||
type: 'boolean',
|
||||
label: 'Use Background Color',
|
||||
@@ -746,9 +804,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Loading Phrases',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'tips',
|
||||
default: 'off',
|
||||
description:
|
||||
'What to show while the model is working: tips, witty comments, both, or nothing.',
|
||||
'What to show while the model is working: tips, witty comments, all, or off.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'tips', label: 'Tips' },
|
||||
@@ -1172,7 +1230,8 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Model override for the visual agent.',
|
||||
description:
|
||||
"Model for the visual agent's analyze_screenshot tool. When set, enables the tool.",
|
||||
showInDialog: false,
|
||||
},
|
||||
allowedDomains: {
|
||||
@@ -1198,6 +1257,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Disable user input on browser window during automation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxActionsPerTask: {
|
||||
type: 'number',
|
||||
label: 'Max Actions Per Task',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: 100,
|
||||
description:
|
||||
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
|
||||
showInDialog: false,
|
||||
},
|
||||
confirmSensitiveActions: {
|
||||
type: 'boolean',
|
||||
label: 'Confirm Sensitive Actions',
|
||||
@@ -1271,6 +1340,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
memoryBoundaryMarkers: {
|
||||
type: 'array',
|
||||
label: 'Memory Boundary Markers',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: ['.git'] as string[],
|
||||
description:
|
||||
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
|
||||
'The upward traversal stops at the first directory containing any of these markers. ' +
|
||||
'An empty array disables parent traversal.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -1425,6 +1507,21 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
backgroundCompletionBehavior: {
|
||||
type: 'enum',
|
||||
label: 'Background Completion Behavior',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'silent',
|
||||
description:
|
||||
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
{ label: 'Inject', value: 'inject' },
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
@@ -1440,7 +1537,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Show Color',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Show color in shell output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -1624,10 +1721,10 @@ const SETTINGS_SCHEMA = {
|
||||
type: 'boolean',
|
||||
label: 'Tool Sandboxing',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Experimental tool-level sandboxing (implementation in progress).',
|
||||
'Tool-level sandboxing. Isolates individual tools instead of the entire CLI process.',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableYoloMode: {
|
||||
@@ -1819,8 +1916,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Auto Configure Max Old Space Size',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
default: true,
|
||||
description:
|
||||
'Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides.',
|
||||
showInDialog: true,
|
||||
},
|
||||
dnsResolutionOrder: {
|
||||
@@ -1865,54 +1963,32 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolOutputMasking: {
|
||||
adk: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
label: 'ADK',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
description: 'Settings for the Agent Development Kit (ADK).',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
agentSessionNoninteractiveEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Masking',
|
||||
label: 'Agent Session Non-interactive Enabled',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: true,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 50000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
default: false,
|
||||
description: 'Enable non-interactive agent sessions.',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableTokensThreshold: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
agentSessionInteractiveEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
label: 'Interactive Agent Session Enabled',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
'Enable the agent session implementation for the interactive CLI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
@@ -1988,7 +2064,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -2012,15 +2088,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
|
||||
showInDialog: true,
|
||||
},
|
||||
plan: {
|
||||
type: 'boolean',
|
||||
label: 'Plan',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable Plan Mode.',
|
||||
showInDialog: true,
|
||||
},
|
||||
taskTracker: {
|
||||
type: 'boolean',
|
||||
label: 'Task Tracker',
|
||||
@@ -2121,6 +2188,25 @@ const SETTINGS_SCHEMA = {
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generalistProfile: {
|
||||
type: 'boolean',
|
||||
label: 'Use the generalist profile to manage agent contexts.',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Suitable for general coding and software development tasks.',
|
||||
showInDialog: true,
|
||||
},
|
||||
contextManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable logic for context management.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
@@ -2397,6 +2483,171 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
contextManagement: {
|
||||
type: 'object',
|
||||
label: 'Context Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Settings for agent history and tool distillation context management.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
historyWindow: {
|
||||
type: 'object',
|
||||
label: 'History Window Settings',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 150_000,
|
||||
description:
|
||||
'The number of tokens to allow before triggering compression.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 40_000,
|
||||
description: 'The number of tokens to always retain.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
messageLimits: {
|
||||
type: 'object',
|
||||
label: 'Message Limits',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
normalMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Normal Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 2500,
|
||||
description:
|
||||
'The target number of tokens to budget for a normal conversation turn.',
|
||||
showInDialog: false,
|
||||
},
|
||||
retainedMaxTokens: {
|
||||
type: 'number',
|
||||
label: 'Retained Maximum Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 12000,
|
||||
description:
|
||||
'The maximum number of tokens a single conversation turn can consume before truncation.',
|
||||
showInDialog: false,
|
||||
},
|
||||
normalizationHeadRatio: {
|
||||
type: 'number',
|
||||
label: 'Normalization Head Ratio',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 0.25,
|
||||
description:
|
||||
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
type: 'object',
|
||||
label: 'Context Management Tools',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
distillation: {
|
||||
type: 'object',
|
||||
label: 'Tool Distillation',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
maxOutputTokens: {
|
||||
type: 'number',
|
||||
label: 'Max Output Tokens',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 10_000,
|
||||
description:
|
||||
'Maximum tokens to show to the model when truncating large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizationThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Summarization Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 20_000,
|
||||
description:
|
||||
'Threshold above which truncated tool outputs will be summarized by an LLM.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
outputMasking: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
protectionThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold (Tokens)',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 50_000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableThresholdTokens: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: 30_000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
category: 'Context Management',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
type: 'object',
|
||||
label: 'Admin',
|
||||
@@ -3004,6 +3255,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
type: 'object',
|
||||
properties: {
|
||||
useGemini3_1: { type: 'boolean' },
|
||||
useGemini3_1FlashLite: { type: 'boolean' },
|
||||
useCustomTools: { type: 'boolean' },
|
||||
hasAccessToPreview: { type: 'boolean' },
|
||||
requestedModels: {
|
||||
|
||||
@@ -88,6 +88,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -107,6 +109,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -131,6 +135,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -163,6 +169,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -201,6 +209,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -237,6 +247,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
),
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -278,6 +290,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
workspacePoliciesDir: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
} finally {
|
||||
// Restore for other tests
|
||||
|
||||
@@ -105,6 +105,9 @@ describe('initializer', () => {
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
// Wait for the background promise to resolve
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
StartSessionEvent,
|
||||
logCliConfiguration,
|
||||
startupProfiler,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
@@ -55,9 +56,18 @@ export async function initializeApp(
|
||||
);
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
IdeClient.getInstance()
|
||||
.then(async (ideClient) => {
|
||||
await ideClient.connect();
|
||||
logIdeConnection(
|
||||
config,
|
||||
new IdeConnectionEvent(IdeConnectionType.START),
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
// We log locally if IDE connection setup fails in the background.
|
||||
debugLogger.error('Failed to initialize IDE client:', e);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -304,6 +304,25 @@ describe('gemini.tsx main function', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should suppress AbortError and not open debug console', async () => {
|
||||
const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
const debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
|
||||
const abortError = new DOMException(
|
||||
'The operation was aborted.',
|
||||
'AbortError',
|
||||
);
|
||||
|
||||
setupUnhandledRejectionHandler();
|
||||
process.emit('unhandledRejection', abortError, Promise.resolve());
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Suppressed unhandled AbortError'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -379,15 +398,30 @@ describe('initializeOutputListenersAndFlush', () => {
|
||||
describe('getNodeMemoryArgs', () => {
|
||||
let osTotalMemSpy: MockInstance;
|
||||
let v8GetHeapStatisticsSpy: MockInstance;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let originalConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
osTotalMemSpy = vi.spyOn(os, 'totalmem');
|
||||
v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
|
||||
originalConfig = process.config;
|
||||
Object.defineProperty(process, 'config', {
|
||||
value: {
|
||||
...originalConfig,
|
||||
variables: { ...originalConfig?.variables, v8_enable_sandbox: 1 },
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process, 'config', {
|
||||
value: originalConfig,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
|
||||
@@ -400,8 +434,10 @@ describe('getNodeMemoryArgs', () => {
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
|
||||
});
|
||||
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual([]);
|
||||
// Target is 50% of 16GB = 8GB. Current is 8GB. Relaunch needed for EPT size only.
|
||||
expect(getNodeMemoryArgs(false)).toEqual([
|
||||
'--max-external-pointer-table-size=268435456',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return memory args if current heap limit is insufficient', () => {
|
||||
@@ -409,8 +445,11 @@ describe('getNodeMemoryArgs', () => {
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
|
||||
});
|
||||
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
|
||||
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed for both.
|
||||
expect(getNodeMemoryArgs(false)).toEqual([
|
||||
'--max-external-pointer-table-size=268435456',
|
||||
'--max-old-space-size=8192',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should log debug info when isDebugMode is true', () => {
|
||||
@@ -528,6 +567,62 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => {
|
||||
const resumeSpy = vi.spyOn(process.stdin, 'resume');
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
query: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await main();
|
||||
});
|
||||
|
||||
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
||||
resumeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ flag: 'listExtensions' },
|
||||
{ flag: 'listSessions' },
|
||||
@@ -1333,12 +1428,13 @@ describe('startInteractiveUI', () => {
|
||||
vi.mock('./ui/utils/updateCheck.js', () => ({
|
||||
checkForUpdates: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||
registerCleanup: vi.fn(),
|
||||
removeCleanup: vi.fn(),
|
||||
runExitCleanup: vi.fn(),
|
||||
registerSyncCleanup: vi.fn(),
|
||||
removeSyncCleanup: vi.fn(),
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
setupSignalHandlers: vi.fn(),
|
||||
setupTtyCheck: vi.fn(() => vi.fn()),
|
||||
|
||||
+88
-52
@@ -13,7 +13,7 @@ import {
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
sessionId,
|
||||
createSessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
UserPromptEvent,
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
debugLogger,
|
||||
isHeadlessMode,
|
||||
Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
@@ -79,10 +81,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { relaunchOnExitCode } from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
@@ -110,6 +109,8 @@ export function validateDnsResolutionOrder(
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const DEFAULT_EPT_SIZE = (256 * 1024 * 1024).toString();
|
||||
|
||||
export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
@@ -129,21 +130,48 @@ export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
// Automatically expand the V8 External Pointer Table to 256MB to prevent
|
||||
// out-of-memory crashes during high native-handle concurrency.
|
||||
// Note: Only supported in specific Node.js versions compiled with V8 Sandbox enabled.
|
||||
const eptFlag = `--max-external-pointer-table-size=${DEFAULT_EPT_SIZE}`;
|
||||
const isV8SandboxEnabled =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(process.config?.variables as any)?.v8_enable_sandbox === 1;
|
||||
|
||||
if (
|
||||
isV8SandboxEnabled &&
|
||||
!process.execArgv.some((arg) =>
|
||||
arg.startsWith('--max-external-pointer-table-size'),
|
||||
)
|
||||
) {
|
||||
args.push(eptFlag);
|
||||
}
|
||||
|
||||
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
|
||||
if (isDebugMode) {
|
||||
debugLogger.debug(
|
||||
`Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,
|
||||
);
|
||||
}
|
||||
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
|
||||
args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`);
|
||||
}
|
||||
|
||||
return [];
|
||||
return args;
|
||||
}
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
// AbortError is expected when the user cancels a request (e.g. pressing ESC).
|
||||
// It may surface as an unhandled rejection due to async timing in the
|
||||
// streaming pipeline, but it is not a bug.
|
||||
if (reason instanceof Error && reason.name === 'AbortError') {
|
||||
debugLogger.log(`Suppressed unhandled AbortError: ${reason.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = `=========================================
|
||||
This is an unexpected error. Please file a bug report using the /bug tool.
|
||||
CRITICAL: Unhandled Promise Rejection!
|
||||
@@ -163,6 +191,39 @@ ${reason.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveSessionId(resumeArg: string | undefined): Promise<{
|
||||
sessionId: string;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}> {
|
||||
if (!resumeArg) {
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
|
||||
try {
|
||||
const { sessionData, sessionPath } = await new SessionSelector(
|
||||
storage,
|
||||
).resolveSession(resumeArg);
|
||||
return {
|
||||
sessionId: sessionData.sessionId,
|
||||
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_INPUT_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
@@ -258,6 +319,8 @@ export async function main() {
|
||||
|
||||
const argv = await argvPromise;
|
||||
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
|
||||
@@ -296,6 +359,7 @@ export async function main() {
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: isHeadlessMode() ? false : true,
|
||||
debugMode: isDebugMode,
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
@@ -334,7 +398,7 @@ export async function main() {
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
let initialAuthFailed = false;
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
if (!settings.merged.security.auth.useExternal && !argv.isCommand) {
|
||||
try {
|
||||
if (
|
||||
partialConfig.isInteractive() &&
|
||||
@@ -380,13 +444,19 @@ export async function main() {
|
||||
// Set remote admin settings if returned from CCPA.
|
||||
if (remoteAdminSettings) {
|
||||
settings.setRemoteAdminSettings(remoteAdminSettings);
|
||||
if (process.send) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteAdminSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run deferred command now that we have admin settings.
|
||||
await runDeferredCommand(settings.merged);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
if (!process.env['SANDBOX'] && !argv.isCommand) {
|
||||
const memoryArgs = settings.merged.advanced.autoConfigureMemory
|
||||
? getNodeMemoryArgs(isDebugMode)
|
||||
: [];
|
||||
@@ -437,10 +507,6 @@ export async function main() {
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
} else {
|
||||
// Relaunch app so we always have a child process that can be internally
|
||||
// restarted if needed.
|
||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,43 +642,18 @@ export async function main() {
|
||||
})),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
let resumedSessionData: ResumedSessionData | undefined = undefined;
|
||||
if (argv.resume) {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
try {
|
||||
const result = await sessionSelector.resolveSession(argv.resume);
|
||||
resumedSessionData = {
|
||||
conversation: result.sessionData,
|
||||
filePath: result.sessionPath,
|
||||
};
|
||||
// Use the existing session ID to continue recording to the same session
|
||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SessionError &&
|
||||
error.code === 'NO_SESSIONS_FOUND'
|
||||
) {
|
||||
// No sessions to resume — start a fresh session with a warning
|
||||
startupWarnings.push({
|
||||
id: 'resume-no-sessions',
|
||||
message: error.message,
|
||||
priority: WarningPriority.High,
|
||||
});
|
||||
} else {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_INPUT_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliStartupHandle?.end();
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
// Earlier initialization phases (like TerminalCapabilityManager resolving
|
||||
// or authWithWeb) may have added and removed 'data' listeners on process.stdin.
|
||||
// When the listener count drops to 0, Node.js implicitly pauses the stream buffer.
|
||||
// React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused.
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
await startInteractiveUI(
|
||||
config,
|
||||
settings,
|
||||
@@ -660,11 +701,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register SessionEnd hook for graceful exit
|
||||
registerCleanup(async () => {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
if (!input) {
|
||||
debugLogger.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
SessionEndReason,
|
||||
type Config,
|
||||
type HookSystem,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -68,6 +73,7 @@ vi.mock('./config/config.js', () => ({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
@@ -136,7 +142,9 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
|
||||
...actual,
|
||||
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
|
||||
registerCleanup: vi.fn(),
|
||||
removeCleanup: vi.fn(),
|
||||
registerSyncCleanup: vi.fn(),
|
||||
removeSyncCleanup: vi.fn(),
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
runExitCleanup: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -197,17 +205,18 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as ReturnType<typeof loadSettings>);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getHookSystem: () => undefined,
|
||||
@@ -238,7 +247,8 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
} as unknown as Config);
|
||||
|
||||
await main();
|
||||
|
||||
@@ -248,4 +258,82 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
|
||||
const mockHookSystem = {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as HookSystem;
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getHookSystem: vi.fn(() => mockHookSystem),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
await main();
|
||||
|
||||
const registeredCallbacks = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map(([fn]) => fn);
|
||||
for (const fn of registeredCallbacks) await fn();
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Exit,
|
||||
);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getAcpMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
getUseTerminalBuffer: vi.fn(() => false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('Model Steering Integration', () => {
|
||||
|
||||
// Then it should proceed with the next action
|
||||
await rig.waitForOutput(
|
||||
/Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/,
|
||||
/Since you want me to focus on \.txt[\s\S]*files,[\s\S]*I will read file1\.txt/,
|
||||
);
|
||||
await rig.waitForOutput('ReadFile');
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import {
|
||||
registerCleanup,
|
||||
removeCleanup,
|
||||
setupTtyCheck,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -43,9 +47,9 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -57,12 +61,13 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
initializeConsoleStore();
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getUseAlternateBuffer(),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
@@ -88,7 +93,6 @@ export async function startInteractiveUI(
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
@@ -106,7 +110,7 @@ export async function startInteractiveUI(
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
@@ -131,7 +135,6 @@ export async function startInteractiveUI(
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -152,20 +155,26 @@ export async function startInteractiveUI(
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
standardReactLayoutTiming:
|
||||
useAlternateBuffer || config.getUseTerminalBuffer(),
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
terminalBuffer: config.getUseTerminalBuffer(),
|
||||
renderProcess:
|
||||
config.getUseRenderProcess() && config.getUseTerminalBuffer(),
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
debugRainbow: settings.merged.ui.debugRainbow === true,
|
||||
},
|
||||
);
|
||||
|
||||
let cleanupLineWrapping: (() => void) | undefined;
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
cleanupLineWrapping = () => enableLineWrapping();
|
||||
registerCleanup(cleanupLineWrapping);
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
@@ -179,9 +188,48 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
const cleanupUnmount = () => instance.unmount();
|
||||
registerCleanup(cleanupUnmount);
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
const cleanupTtyCheck = setupTtyCheck();
|
||||
registerCleanup(cleanupTtyCheck);
|
||||
|
||||
const cleanupConsolePatcher = () => consolePatcher.cleanup();
|
||||
registerCleanup(cleanupConsolePatcher);
|
||||
|
||||
try {
|
||||
await instance.waitUntilExit();
|
||||
} finally {
|
||||
try {
|
||||
removeCleanup(cleanupConsolePatcher);
|
||||
cleanupConsolePatcher();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error cleaning up console patcher:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
removeCleanup(cleanupUnmount);
|
||||
instance.unmount();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error unmounting Ink instance:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
removeCleanup(cleanupTtyCheck);
|
||||
cleanupTtyCheck();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error in TTY cleanup:', e);
|
||||
}
|
||||
|
||||
if (cleanupLineWrapping) {
|
||||
try {
|
||||
removeCleanup(cleanupLineWrapping);
|
||||
cleanupLineWrapping();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error restoring line wrapping:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
|
||||
@@ -71,6 +71,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
Scheduler: class {
|
||||
schedule = mockSchedulerSchedule;
|
||||
cancelAll = vi.fn();
|
||||
dispose = vi.fn();
|
||||
},
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
@@ -166,7 +167,7 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
@@ -190,6 +191,7 @@ describe('runNonInteractive', () => {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -1712,7 +1714,7 @@ describe('runNonInteractive', () => {
|
||||
input,
|
||||
prompt_id: promptId,
|
||||
});
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// Expected exit
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
handleMaxTurnsExceededError,
|
||||
} from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
import { runNonInteractive as runNonInteractiveAgentSession } from './nonInteractiveCliAgentSession.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
@@ -55,16 +56,20 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
export async function runNonInteractive(
|
||||
params: RunNonInteractiveParams,
|
||||
): Promise<void> {
|
||||
const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled();
|
||||
if (useAgentSession) {
|
||||
return runNonInteractiveAgentSession(params);
|
||||
}
|
||||
|
||||
const { config, settings, input, prompt_id, resumedSessionData } = params;
|
||||
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: false,
|
||||
debugMode: config.getDebugMode(),
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
@@ -182,6 +187,7 @@ export async function runNonInteractive({
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let scheduler: Scheduler | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
@@ -210,7 +216,7 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const scheduler = new Scheduler({
|
||||
scheduler = new Scheduler({
|
||||
context: config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
@@ -523,6 +529,7 @@ export async function runNonInteractive({
|
||||
// Cleanup stdin cancellation before other cleanup
|
||||
cleanupStdinCancellation();
|
||||
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
UserFeedbackPayload,
|
||||
AgentEvent,
|
||||
ContentPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
FatalError,
|
||||
FatalAuthenticationError,
|
||||
FatalInputError,
|
||||
FatalSandboxError,
|
||||
FatalConfigError,
|
||||
FatalTurnLimitedError,
|
||||
FatalToolExecutionError,
|
||||
FatalCancellationError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
JsonFormatter,
|
||||
StreamJsonFormatter,
|
||||
JsonStreamEventType,
|
||||
uiTelemetryService,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
import { handleError, handleToolError } from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
input: string;
|
||||
prompt_id: string;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: false,
|
||||
debugMode: config.getDebugMode(),
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const textOutput = new TextOutput(workingStdout);
|
||||
|
||||
const handleUserFeedback = (payload: UserFeedbackPayload) => {
|
||||
const prefix = payload.severity.toUpperCase();
|
||||
process.stderr.write(`[${prefix}] ${payload.message}\n`);
|
||||
if (payload.error && config.getDebugMode()) {
|
||||
const errorToLog =
|
||||
payload.error instanceof Error
|
||||
? payload.error.stack || payload.error.message
|
||||
: String(payload.error);
|
||||
process.stderr.write(`${errorToLog}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const streamFormatter =
|
||||
config.getOutputFormat() === OutputFormat.STREAM_JSON
|
||||
? new StreamJsonFormatter()
|
||||
: null;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Track cancellation state
|
||||
let isAborting = false;
|
||||
let cancelMessageTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Setup stdin listener for Ctrl+C detection
|
||||
let stdinWasRaw = false;
|
||||
let rl: readline.Interface | null = null;
|
||||
|
||||
const setupStdinCancellation = () => {
|
||||
// Only setup if stdin is a TTY (user can interact)
|
||||
if (!process.stdin.isTTY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save original raw mode state
|
||||
stdinWasRaw = process.stdin.isRaw || false;
|
||||
|
||||
// Enable raw mode to capture individual keypresses
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
|
||||
// Setup readline to emit keypress events
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
escapeCodeTimeout: 0,
|
||||
});
|
||||
readline.emitKeypressEvents(process.stdin, rl);
|
||||
|
||||
// Listen for Ctrl+C
|
||||
const keypressHandler = (
|
||||
str: string,
|
||||
key: { name?: string; ctrl?: boolean },
|
||||
) => {
|
||||
// Detect Ctrl+C: either ctrl+c key combo or raw character code 3
|
||||
if ((key && key.ctrl && key.name === 'c') || str === '\u0003') {
|
||||
// Only handle once
|
||||
if (isAborting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAborting = true;
|
||||
|
||||
// Only show message if cancellation takes longer than 200ms
|
||||
// This reduces verbosity for fast cancellations
|
||||
cancelMessageTimer = setTimeout(() => {
|
||||
process.stderr.write('\nCancelling...\n');
|
||||
}, 200);
|
||||
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on('keypress', keypressHandler);
|
||||
};
|
||||
|
||||
const cleanupStdinCancellation = () => {
|
||||
// Clear any pending cancel message timer
|
||||
if (cancelMessageTimer) {
|
||||
clearTimeout(cancelMessageTimer);
|
||||
cancelMessageTimer = null;
|
||||
}
|
||||
|
||||
// Cleanup readline and stdin listeners
|
||||
if (rl) {
|
||||
rl.close();
|
||||
rl = null;
|
||||
}
|
||||
|
||||
// Remove keypress listener
|
||||
process.stdin.removeAllListeners('keypress');
|
||||
|
||||
// Restore stdin to original state
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(stdinWasRaw);
|
||||
process.stdin.pause();
|
||||
}
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let scheduler: Scheduler | undefined;
|
||||
let abortSession = () => {};
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
if (
|
||||
config.getRawOutput() &&
|
||||
!config.getAcceptRawOutputRisk() &&
|
||||
config.getOutputFormat() === OutputFormat.TEXT
|
||||
) {
|
||||
process.stderr.write(
|
||||
'[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Setup stdin cancellation listener
|
||||
setupStdinCancellation();
|
||||
|
||||
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
coreEvents.drainBacklogs();
|
||||
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
cleanupStdinCancellation();
|
||||
consolePatcher.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
scheduler = new Scheduler({
|
||||
context: config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
});
|
||||
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit init event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.INIT,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: config.getSessionId(),
|
||||
model: config.getModel(),
|
||||
});
|
||||
}
|
||||
|
||||
let query: Part[] | undefined;
|
||||
|
||||
if (isSlashCommand(input)) {
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
input,
|
||||
abortController,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
const { processedQuery, error } = await handleAtCommand({
|
||||
query: input,
|
||||
config,
|
||||
addItem: (_item, _timestamp) => 0,
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: abortController.signal,
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
if (error || !processedQuery) {
|
||||
throw new FatalInputError(
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = processedQuery as Part[];
|
||||
}
|
||||
|
||||
// Emit user message event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
});
|
||||
}
|
||||
|
||||
// Create LegacyAgentSession — owns the agentic loop
|
||||
const session = new LegacyAgentSession({
|
||||
client: geminiClient,
|
||||
scheduler,
|
||||
config,
|
||||
promptId: prompt_id,
|
||||
});
|
||||
|
||||
// Wire Ctrl+C to session abort
|
||||
abortSession = () => {
|
||||
void session.abort();
|
||||
};
|
||||
abortController.signal.addEventListener('abort', abortSession);
|
||||
if (abortController.signal.aborted) {
|
||||
throw new FatalCancellationError('Operation cancelled.');
|
||||
}
|
||||
|
||||
// Start the agentic loop (runs in background)
|
||||
const { streamId } = await session.send({
|
||||
message: {
|
||||
content: geminiPartsToContentParts(query),
|
||||
displayContent: input,
|
||||
},
|
||||
});
|
||||
if (streamId === null) {
|
||||
throw new Error(
|
||||
'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
|
||||
);
|
||||
}
|
||||
|
||||
const getTextContent = (parts?: ContentPart[]): string | undefined => {
|
||||
const text = parts
|
||||
?.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('');
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
}
|
||||
};
|
||||
|
||||
const reconstructFatalError = (event: AgentEvent<'error'>): Error => {
|
||||
const errorMeta = event._meta;
|
||||
const name =
|
||||
typeof errorMeta?.['errorName'] === 'string'
|
||||
? errorMeta['errorName']
|
||||
: undefined;
|
||||
|
||||
let errToThrow: Error;
|
||||
switch (name) {
|
||||
case 'FatalAuthenticationError':
|
||||
errToThrow = new FatalAuthenticationError(event.message);
|
||||
break;
|
||||
case 'FatalInputError':
|
||||
errToThrow = new FatalInputError(event.message);
|
||||
break;
|
||||
case 'FatalSandboxError':
|
||||
errToThrow = new FatalSandboxError(event.message);
|
||||
break;
|
||||
case 'FatalConfigError':
|
||||
errToThrow = new FatalConfigError(event.message);
|
||||
break;
|
||||
case 'FatalTurnLimitedError':
|
||||
errToThrow = new FatalTurnLimitedError(event.message);
|
||||
break;
|
||||
case 'FatalToolExecutionError':
|
||||
errToThrow = new FatalToolExecutionError(event.message);
|
||||
break;
|
||||
case 'FatalCancellationError':
|
||||
errToThrow = new FatalCancellationError(event.message);
|
||||
break;
|
||||
case 'FatalError':
|
||||
errToThrow = new FatalError(
|
||||
event.message,
|
||||
typeof errorMeta?.['exitCode'] === 'number'
|
||||
? errorMeta['exitCode']
|
||||
: 1,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
errToThrow = new Error(event.message);
|
||||
if (name) {
|
||||
Object.defineProperty(errToThrow, 'name', {
|
||||
value: name,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (errorMeta?.['exitCode'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'exitCode', {
|
||||
value: errorMeta['exitCode'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['code'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'code', {
|
||||
value: errorMeta['code'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['status'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'status', {
|
||||
value: errorMeta['status'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return errToThrow;
|
||||
};
|
||||
|
||||
// Consume AgentEvents for output formatting
|
||||
let responseText = '';
|
||||
let preToolResponseText: string | undefined;
|
||||
let streamEnded = false;
|
||||
for await (const event of session.stream({ streamId })) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
if (event.role === 'agent') {
|
||||
for (const part of event.content) {
|
||||
if (part.type === 'text') {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? part.text : stripAnsi(part.text);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (part.text) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_request': {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
// Final JSON output should reflect the last assistant answer after
|
||||
// any tool orchestration, not intermediate pre-tool text.
|
||||
preToolResponseText = responseText || preToolResponseText;
|
||||
responseText = '';
|
||||
}
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_USE,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: event.name,
|
||||
tool_id: event.requestId,
|
||||
parameters: event.args,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: event.requestId,
|
||||
status: event.isError ? 'error' : 'success',
|
||||
output: displayText,
|
||||
error: event.isError
|
||||
? {
|
||||
type:
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: 'TOOL_EXECUTION_ERROR',
|
||||
message: errorMsg,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
|
||||
if (
|
||||
config.getOutputFormat() === OutputFormat.JSON &&
|
||||
!responseText &&
|
||||
preToolResponseText
|
||||
) {
|
||||
responseText = preToolResponseText;
|
||||
}
|
||||
const stopMessage = `Agent execution stopped: ${errorMsg}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.NO_SPACE_LEFT) {
|
||||
throw new FatalToolExecutionError(
|
||||
'Error executing tool ' +
|
||||
event.name +
|
||||
': ' +
|
||||
(displayText || errorMsg),
|
||||
);
|
||||
}
|
||||
handleToolError(
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const severity =
|
||||
event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity,
|
||||
message: event.message,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_end': {
|
||||
if (event.reason === 'aborted') {
|
||||
throw new FatalCancellationError('Operation cancelled.');
|
||||
} else if (event.reason === 'max_turns') {
|
||||
const isConfiguredTurnLimit =
|
||||
typeof event.data?.['maxTurns'] === 'number' ||
|
||||
typeof event.data?.['turnCount'] === 'number';
|
||||
|
||||
if (isConfiguredTurnLimit) {
|
||||
throw new FatalTurnLimitedError(
|
||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||
);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stopMessage =
|
||||
typeof event.data?.['message'] === 'string'
|
||||
? event.data['message']
|
||||
: '';
|
||||
if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
case 'initialize':
|
||||
case 'session_update':
|
||||
case 'agent_start':
|
||||
case 'tool_update':
|
||||
case 'elicitation_request':
|
||||
case 'elicitation_response':
|
||||
case 'usage':
|
||||
case 'custom':
|
||||
// Explicitly ignore these non-interactive events
|
||||
break;
|
||||
default:
|
||||
debugLogger.error('Unknown agent event type:', event);
|
||||
event satisfies never;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorToHandle = error;
|
||||
} finally {
|
||||
// Cleanup stdin cancellation before other cleanup
|
||||
cleanupStdinCancellation();
|
||||
abortController.signal.removeEventListener('abort', abortSession);
|
||||
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
}
|
||||
|
||||
if (errorToHandle) {
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -223,7 +223,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('SlashCommandResolver', () => {
|
||||
]);
|
||||
|
||||
expect(finalCommands.map((c) => c.name)).toContain('deploy');
|
||||
expect(finalCommands.map((c) => c.name)).toContain('firebase.deploy');
|
||||
expect(finalCommands.map((c) => c.name)).toContain('firebase:deploy');
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('SlashCommandResolver', () => {
|
||||
|
||||
it('should apply numeric suffixes when renames also conflict', () => {
|
||||
const user1 = createMockCommand('deploy', CommandKind.USER_FILE);
|
||||
const user2 = createMockCommand('gcp.deploy', CommandKind.USER_FILE);
|
||||
const user2 = createMockCommand('gcp:deploy', CommandKind.USER_FILE);
|
||||
const extension = {
|
||||
...createMockCommand('deploy', CommandKind.EXTENSION_FILE),
|
||||
extensionName: 'gcp',
|
||||
@@ -171,7 +171,7 @@ describe('SlashCommandResolver', () => {
|
||||
extension,
|
||||
]);
|
||||
|
||||
expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined();
|
||||
expect(finalCommands.find((c) => c.name === 'gcp:deploy1')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should prefix skills with extension name when they conflict with built-in', () => {
|
||||
@@ -185,7 +185,37 @@ describe('SlashCommandResolver', () => {
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('chat');
|
||||
expect(names).toContain('google-workspace.chat');
|
||||
expect(names).toContain('google-workspace:chat');
|
||||
});
|
||||
|
||||
it('should ALWAYS prefix extension skills even if no conflict exists', () => {
|
||||
const skill = {
|
||||
...createMockCommand('chat', CommandKind.SKILL),
|
||||
extensionName: 'google-workspace',
|
||||
};
|
||||
|
||||
const { finalCommands } = SlashCommandResolver.resolve([skill]);
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('google-workspace:chat');
|
||||
expect(names).not.toContain('chat');
|
||||
});
|
||||
|
||||
it('should use numeric suffixes if prefixed skill names collide', () => {
|
||||
const skill1 = {
|
||||
...createMockCommand('chat', CommandKind.SKILL),
|
||||
extensionName: 'google-workspace',
|
||||
};
|
||||
const skill2 = {
|
||||
...createMockCommand('chat', CommandKind.SKILL),
|
||||
extensionName: 'google-workspace',
|
||||
};
|
||||
|
||||
const { finalCommands } = SlashCommandResolver.resolve([skill1, skill2]);
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('google-workspace:chat');
|
||||
expect(names).toContain('google-workspace:chat1');
|
||||
});
|
||||
|
||||
it('should NOT prefix skills with "skill" when extension name is missing', () => {
|
||||
|
||||
@@ -47,7 +47,17 @@ export class SlashCommandResolver {
|
||||
const originalName = cmd.name;
|
||||
let finalName = originalName;
|
||||
|
||||
if (registry.firstEncounters.has(originalName)) {
|
||||
const shouldAlwaysPrefix =
|
||||
cmd.kind === CommandKind.SKILL && !!cmd.extensionName;
|
||||
|
||||
if (shouldAlwaysPrefix) {
|
||||
finalName = this.getRenamedName(
|
||||
originalName,
|
||||
this.getPrefix(cmd),
|
||||
registry.commandMap,
|
||||
cmd.kind,
|
||||
);
|
||||
} else if (registry.firstEncounters.has(originalName)) {
|
||||
// We've already seen a command with this name, so resolve the conflict.
|
||||
finalName = this.handleConflict(cmd, registry);
|
||||
} else {
|
||||
@@ -93,6 +103,7 @@ export class SlashCommandResolver {
|
||||
incoming.name,
|
||||
this.getPrefix(incoming),
|
||||
registry.commandMap,
|
||||
incoming.kind,
|
||||
);
|
||||
this.trackConflict(
|
||||
registry.conflictsMap,
|
||||
@@ -132,6 +143,7 @@ export class SlashCommandResolver {
|
||||
currentOwner.name,
|
||||
this.getPrefix(currentOwner),
|
||||
registry.commandMap,
|
||||
currentOwner.kind,
|
||||
);
|
||||
|
||||
// Update the registry: remove the old name and add the owner under the new name.
|
||||
@@ -156,8 +168,12 @@ export class SlashCommandResolver {
|
||||
name: string,
|
||||
prefix: string | undefined,
|
||||
commandMap: Map<string, SlashCommand>,
|
||||
kind?: CommandKind,
|
||||
): string {
|
||||
const base = prefix ? `${prefix}.${name}` : name;
|
||||
const isExtensionPrefix =
|
||||
kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE;
|
||||
const separator = isExtensionPrefix ? ':' : '.';
|
||||
const base = prefix ? `${prefix}${separator}${name}` : name;
|
||||
let renamedName = base;
|
||||
let suffix = 1;
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export class AppRig {
|
||||
private sessionId: string;
|
||||
|
||||
private pendingConfirmations = new Map<string, PendingConfirmation>();
|
||||
private breakpointTools = new Set<string | undefined>();
|
||||
private breakpointTools = new Set<string>();
|
||||
private lastAwaitedConfirmation: PendingConfirmation | undefined;
|
||||
|
||||
/**
|
||||
@@ -181,6 +181,16 @@ export class AppRig {
|
||||
);
|
||||
this.sessionId = `test-session-${uniqueId}`;
|
||||
activeRigs.set(this.sessionId, this);
|
||||
|
||||
// Pre-create the persistent state file to bypass the terminal setup prompt
|
||||
const geminiDir = path.join(this.testDir, '.gemini');
|
||||
if (!fs.existsSync(geminiDir)) {
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, 'state.json'),
|
||||
JSON.stringify({ terminalSetupPromptShown: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -436,11 +446,7 @@ export class AppRig {
|
||||
MockShellExecutionService.setMockCommands(commands);
|
||||
}
|
||||
|
||||
setToolPolicy(
|
||||
toolName: string | undefined,
|
||||
decision: PolicyDecision,
|
||||
priority = 10,
|
||||
) {
|
||||
setToolPolicy(toolName: string, decision: PolicyDecision, priority = 10) {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
this.config.getPolicyEngine().addRule({
|
||||
toolName,
|
||||
@@ -450,27 +456,20 @@ export class AppRig {
|
||||
});
|
||||
}
|
||||
|
||||
setBreakpoint(toolName: string | string[] | undefined) {
|
||||
setBreakpoint(toolName: string | string[]) {
|
||||
if (Array.isArray(toolName)) {
|
||||
for (const name of toolName) {
|
||||
this.setBreakpoint(name);
|
||||
}
|
||||
} else {
|
||||
// Use undefined toolName to create a global rule if '*' is provided
|
||||
const actualToolName = toolName === '*' ? undefined : toolName;
|
||||
this.setToolPolicy(actualToolName, PolicyDecision.ASK_USER, 100);
|
||||
this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100);
|
||||
this.breakpointTools.add(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
removeToolPolicy(toolName?: string, source = 'AppRig Override') {
|
||||
removeToolPolicy(toolName: string, source = 'AppRig Override') {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
// Map '*' back to undefined for policy removal
|
||||
const actualToolName = toolName === '*' ? undefined : toolName;
|
||||
this.config
|
||||
.getPolicyEngine()
|
||||
|
||||
.removeRulesForTool(actualToolName as string, source);
|
||||
this.config.getPolicyEngine().removeRulesForTool(toolName, source);
|
||||
this.breakpointTools.delete(toolName);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const createMockCommandContext = (
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
|
||||
@@ -38,6 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
isMemoryManagerEnabled: vi.fn(() => false),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getListSessions: vi.fn(() => false),
|
||||
@@ -135,6 +136,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
sanitizationConfig: {
|
||||
@@ -163,6 +165,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
@@ -174,6 +177,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
getUseTerminalBuffer: vi.fn().mockReturnValue(false),
|
||||
getUseRenderProcess: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -193,6 +198,17 @@ export function createMockSettings(
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
getSnapshot: vi.fn().mockReturnValue({
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
isTrusted: true,
|
||||
errors: [],
|
||||
merged,
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
|
||||
export function mockInkSpinner() {
|
||||
vi.mock('ink-spinner', async () => {
|
||||
const { Text } = await import('ink');
|
||||
const cliSpinners = (await import('cli-spinners')).default;
|
||||
|
||||
return {
|
||||
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
|
||||
const spinner = cliSpinners[type];
|
||||
const frame = spinner ? spinner.frames[0] : '⠋';
|
||||
return <Text>{frame}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type OverflowState,
|
||||
} from '../ui/contexts/OverflowContext.js';
|
||||
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
||||
@@ -51,7 +52,6 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
|
||||
import { pickDefaultThemeName } from '../ui/themes/theme.js';
|
||||
import { generateSvgForTerminal } from './svg.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
@@ -223,7 +223,7 @@ class XtermStdout extends EventEmitter {
|
||||
this.once('render', resolve),
|
||||
);
|
||||
const timeoutPromise = new Promise((resolve) =>
|
||||
setTimeout(resolve, 50),
|
||||
setTimeout(resolve, 1000),
|
||||
);
|
||||
await Promise.race([renderPromise, timeoutPromise]);
|
||||
}
|
||||
@@ -254,7 +254,12 @@ class XtermStdout extends EventEmitter {
|
||||
|
||||
const isMatch = () => {
|
||||
if (expectedFrame === '...') {
|
||||
return currentFrame !== '';
|
||||
// '...' is our fallback when output isn't in metrics, meaning Ink rendered *something*
|
||||
// but we don't know what it is. If terminal has content, we consider it a match.
|
||||
// However, if the component rendered null, both would be empty, but our fallback
|
||||
// made expectedFrame '...'. In that case, we can't easily know if it's ready,
|
||||
// but we can assume if there are no pending writes, it's ready.
|
||||
return currentFrame !== '' || this.pendingWrites === 0;
|
||||
}
|
||||
|
||||
// If Ink expects nothing (no new static content and no dynamic output),
|
||||
@@ -499,6 +504,8 @@ const baseMockUiState = {
|
||||
history: [],
|
||||
renderMarkdown: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfigInitialized: true,
|
||||
isAuthenticating: false,
|
||||
terminalWidth: 100,
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
@@ -506,8 +513,8 @@ const baseMockUiState = {
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -524,6 +531,8 @@ const baseMockUiState = {
|
||||
nightly: false,
|
||||
updateInfo: null,
|
||||
pendingHistoryItems: [],
|
||||
mainControlsRef: () => {},
|
||||
rootUiRef: { current: null },
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -566,6 +575,7 @@ const mockUIActions: UIActions = {
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
@@ -576,9 +586,9 @@ const mockUIActions: UIActions = {
|
||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
@@ -590,6 +600,9 @@ const mockUIActions: UIActions = {
|
||||
clearAccountSuspension: vi.fn(),
|
||||
};
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
|
||||
@@ -606,20 +619,28 @@ export const renderWithProviders = async (
|
||||
shellFocus = true,
|
||||
settings = mockSettings,
|
||||
uiState: providedUiState,
|
||||
inputState: providedInputState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
config,
|
||||
uiActions,
|
||||
toolActions,
|
||||
persistentState,
|
||||
appState = mockAppState,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
uiState?: Partial<UIState>;
|
||||
inputState?: Partial<InputState>;
|
||||
width?: number;
|
||||
mouseEventsEnabled?: boolean;
|
||||
config?: Config;
|
||||
uiActions?: Partial<UIActions>;
|
||||
toolActions?: Partial<{
|
||||
isExpanded: (callId: string) => boolean;
|
||||
toggleExpansion: (callId: string) => void;
|
||||
toggleAllExpansion: (callIds: string[]) => void;
|
||||
}>;
|
||||
persistentState?: {
|
||||
get?: typeof persistentStateMock.get;
|
||||
set?: typeof persistentStateMock.set;
|
||||
@@ -645,6 +666,18 @@ export const renderWithProviders = async (
|
||||
},
|
||||
) as UIState;
|
||||
|
||||
const inputState = {
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
userMessages: [],
|
||||
shellModeActive: false,
|
||||
showEscapePrompt: false,
|
||||
copyModeEnabled: false,
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 40,
|
||||
...(providedUiState as unknown as Partial<InputState>),
|
||||
...providedInputState,
|
||||
};
|
||||
|
||||
if (persistentState?.get) {
|
||||
persistentStateMock.get.mockImplementation(persistentState.get);
|
||||
}
|
||||
@@ -657,15 +690,14 @@ export const renderWithProviders = async (
|
||||
const terminalWidth = width ?? baseState.terminalWidth;
|
||||
|
||||
if (!config) {
|
||||
config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'random-session-id',
|
||||
{} as unknown as CliArgs,
|
||||
{ cwd: '/' },
|
||||
);
|
||||
config = makeFakeConfig({
|
||||
useAlternateBuffer: settings.merged.ui?.useAlternateBuffer,
|
||||
showMemoryUsage: settings.merged.ui?.showMemoryUsage,
|
||||
accessibility: settings.merged.ui?.accessibility,
|
||||
});
|
||||
}
|
||||
|
||||
const mainAreaWidth = terminalWidth;
|
||||
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
|
||||
|
||||
const finalUiState = {
|
||||
...baseState,
|
||||
@@ -695,53 +727,65 @@ export const renderWithProviders = async (
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -70,9 +70,7 @@ describe('App', () => {
|
||||
cleanUiDetailsVisible: true,
|
||||
quittingMessages: null,
|
||||
dialogsVisible: false,
|
||||
mainControlsRef: {
|
||||
current: null,
|
||||
} as unknown as React.MutableRefObject<DOMElement | null>,
|
||||
mainControlsRef: vi.fn(),
|
||||
rootUiRef: {
|
||||
current: null,
|
||||
} as unknown as React.MutableRefObject<DOMElement | null>,
|
||||
@@ -90,7 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
backgroundTasks: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', async () => {
|
||||
|
||||
@@ -122,13 +122,17 @@ vi.mock('ink', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { InputContext, type InputState } from './contexts/InputContext.js';
|
||||
|
||||
// Helper component will read the context values provided by AppContainer
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedInputState: InputState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedInputState = useContext(InputContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
@@ -328,13 +332,13 @@ describe('AppContainer State Management', () => {
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -346,6 +350,7 @@ describe('AppContainer State Management', () => {
|
||||
// Initialize mock stdout for terminal title tests
|
||||
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
capturedUIState = null!;
|
||||
|
||||
@@ -470,6 +475,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false);
|
||||
|
||||
// Mock config's getTargetDir to return consistent workspace directory
|
||||
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
@@ -489,8 +495,8 @@ describe('AppContainer State Management', () => {
|
||||
// Mock LoadedSettings
|
||||
mockSettings = createMockSettings({
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
theme: 'default',
|
||||
ui: {
|
||||
@@ -911,8 +917,8 @@ describe('AppContainer State Management', () => {
|
||||
it('handles settings with all display options disabled', async () => {
|
||||
const settingsAllHidden = createMockSettings({
|
||||
hideBanner: true,
|
||||
hideFooter: true,
|
||||
hideTips: true,
|
||||
hideFooter: true,
|
||||
showMemoryUsage: false,
|
||||
});
|
||||
|
||||
@@ -1356,6 +1362,7 @@ describe('AppContainer State Management', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock stdout for each test
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
});
|
||||
|
||||
it('verifies useStdout is mocked', async () => {
|
||||
@@ -2157,13 +2164,8 @@ describe('AppContainer State Management', () => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// It should still not quit because buffer is non-empty.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -2262,13 +2264,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2282,7 +2284,7 @@ describe('AppContainer State Management', () => {
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -2290,13 +2292,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2308,7 +2310,7 @@ describe('AppContainer State Management', () => {
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
@@ -2316,28 +2318,28 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
mockToggleBackgroundTask.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
@@ -2464,7 +2466,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
describe('Copy Mode (F9)', () => {
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
|
||||
@@ -2473,6 +2475,8 @@ describe('AppContainer State Management', () => {
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
|
||||
isAlternateMode,
|
||||
);
|
||||
@@ -2517,6 +2521,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2537,12 +2543,13 @@ describe('AppContainer State Management', () => {
|
||||
modeName: 'Alternate Buffer Mode',
|
||||
},
|
||||
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
|
||||
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
|
||||
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
|
||||
await setupCopyModeTest(isAlternateMode);
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2555,13 +2562,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
if (shouldEnable) {
|
||||
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
|
||||
it('should toggle mouse back on when F9 is pressed again', async () => {
|
||||
await setupCopyModeTest(isAlternateMode);
|
||||
(writeToStdout as Mock).mockClear();
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
@@ -2581,7 +2588,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2661,7 +2668,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
stdin.write('\x1b[20~'); // F9
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -3033,7 +3040,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
expect(capturedUIState.userMessages).toContain('previous message');
|
||||
expect(capturedInputState.userMessages).toContain('previous message');
|
||||
|
||||
const { onCancelSubmit } = extractUseGeminiStreamArgs(
|
||||
mockedUseGeminiStream.mock.lastCall!,
|
||||
@@ -3061,8 +3068,8 @@ describe('AppContainer State Management', () => {
|
||||
const { rerender, unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
// Verify userMessages is populated from inputHistory
|
||||
expect(capturedUIState.userMessages).toContain('first prompt');
|
||||
expect(capturedUIState.userMessages).toContain('second prompt');
|
||||
expect(capturedInputState.userMessages).toContain('first prompt');
|
||||
expect(capturedInputState.userMessages).toContain('second prompt');
|
||||
|
||||
// Clear the conversation history (simulating /clear command)
|
||||
const mockClearItems = vi.fn();
|
||||
@@ -3081,8 +3088,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Verify that userMessages still contains the input history
|
||||
// (it should not be affected by clearing conversation history)
|
||||
expect(capturedUIState.userMessages).toContain('first prompt');
|
||||
expect(capturedUIState.userMessages).toContain('second prompt');
|
||||
expect(capturedInputState.userMessages).toContain('first prompt');
|
||||
expect(capturedInputState.userMessages).toContain('second prompt');
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -3098,6 +3105,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Clear previous calls
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
|
||||
@@ -3140,16 +3148,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Reset mock stdout to clear any initial writes
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should refresh static (which clears terminal in non-alternate buffer)
|
||||
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -3159,6 +3164,8 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
const { unmount } = await act(async () =>
|
||||
@@ -3175,6 +3182,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Reset mock stdout
|
||||
mocks.mockStdout.write.mockClear();
|
||||
(disableMouseEvents as import('vitest').Mock).mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
@@ -3408,6 +3416,8 @@ describe('AppContainer State Management', () => {
|
||||
ui: { useAlternateBuffer: true },
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
const { unmount } = await act(async () =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, renderWithProviders } from '../test-utils/render.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
import { App } from './App.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
ApprovalMode,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type UIState } from './contexts/UIStateContext.js';
|
||||
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { StreamingState } from './types.js';
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...original,
|
||||
useIsScreenReaderEnabled: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./components/GeminiSpinner.js', () => ({
|
||||
GeminiSpinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./components/CliSpinner.js', () => ({
|
||||
CliSpinner: () => null,
|
||||
}));
|
||||
|
||||
// Mock hooks to align with codebase style, even if App uses UIState directly
|
||||
vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/useHistoryManager.js');
|
||||
vi.mock('./hooks/useQuotaAndFallback.js');
|
||||
vi.mock('./hooks/useThemeCommand.js');
|
||||
vi.mock('./auth/useAuth.js');
|
||||
vi.mock('./hooks/useEditorSettings.js');
|
||||
vi.mock('./hooks/useSettingsCommand.js');
|
||||
vi.mock('./hooks/useModelCommand.js');
|
||||
vi.mock('./hooks/slashCommandProcessor.js');
|
||||
vi.mock('./hooks/useConsoleMessages.js');
|
||||
vi.mock('./hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 30 })),
|
||||
}));
|
||||
|
||||
describe('Full Terminal Tool Confirmation Snapshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders tool confirmation box in the frame of the entire terminal', async () => {
|
||||
// Generate a large diff to warrant truncation
|
||||
let largeDiff =
|
||||
'--- a/packages/cli/src/ui/components/InputPrompt.tsx\n+++ b/packages/cli/src/ui/components/InputPrompt.tsx\n@@ -1,100 +1,105 @@\n';
|
||||
for (let i = 1; i <= 60; i++) {
|
||||
largeDiff += ` const line${i} = true;\n`;
|
||||
}
|
||||
largeDiff += '- return kittyProtocolSupporte...;\n';
|
||||
largeDiff += '+ return kittyProtocolSupporte...;\n';
|
||||
largeDiff += ' buffer: TextBuffer;\n';
|
||||
largeDiff += ' onSubmit: (value: string) => void;';
|
||||
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: 'Edit packages/.../InputPrompt.tsx',
|
||||
fileName: 'InputPrompt.tsx',
|
||||
filePath: 'packages/.../InputPrompt.tsx',
|
||||
fileDiff: largeDiff,
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isModifying: false,
|
||||
};
|
||||
|
||||
const toolCalls = [
|
||||
{
|
||||
callId: 'call-1-modify-selected',
|
||||
name: 'Edit',
|
||||
description:
|
||||
'packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProtocolSupporte...',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
resultDisplay: '',
|
||||
confirmationDetails,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
history: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'user',
|
||||
text: 'Can you edit InputPrompt.tsx for me?',
|
||||
},
|
||||
],
|
||||
mainAreaWidth: 99,
|
||||
availableTerminalHeight: 36,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
constrainHeight: true,
|
||||
isConfigInitialized: true,
|
||||
cleanUiDetailsVisible: true,
|
||||
quota: {
|
||||
userTier: 'PRO',
|
||||
stats: {
|
||||
limits: {},
|
||||
usage: {},
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
id: 2,
|
||||
type: 'tool_group',
|
||||
tools: toolCalls,
|
||||
},
|
||||
],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 175400,
|
||||
contextPercentage: 3,
|
||||
},
|
||||
buffer: { text: '' },
|
||||
messageQueue: [],
|
||||
activeHooks: [],
|
||||
contextFileNames: [],
|
||||
rootUiRef: { current: null },
|
||||
} as unknown as UIState;
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
mockConfig.getUseAlternateBuffer = () => true;
|
||||
mockConfig.isTrustedFolder = () => true;
|
||||
mockConfig.getDisableAlwaysAllow = () => false;
|
||||
mockConfig.getIdeMode = () => false;
|
||||
mockConfig.getTargetDir = () => '/directory';
|
||||
|
||||
const { waitUntilReady, lastFrame, generateSvg, unmount } =
|
||||
await renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
config: mockConfig,
|
||||
settings: createMockSettings({
|
||||
merged: {
|
||||
ui: {
|
||||
useAlternateBuffer: true,
|
||||
theme: 'default',
|
||||
showUserIdentity: false,
|
||||
showShortcutsHint: false,
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
hideTokens: false,
|
||||
hideModel: false,
|
||||
},
|
||||
},
|
||||
security: {
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Give it a moment to render
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -2,109 +2,163 @@
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
ERROR config.getUseTerminalBuffer is not a function
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
src/ui/components/MainContent.tsx:40:36
|
||||
|
||||
37: const uiState = useUIState();
|
||||
38: const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
|
||||
39: const config = useConfig();
|
||||
40: const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
41: const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
42:
|
||||
43: const confirmingTool = useConfirmingTool();
|
||||
|
||||
- MainContent (src/ui/components/MainContent.tsx:40:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kil
|
||||
o-code-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-recon
|
||||
ciler.development.js:15859:20)
|
||||
-renderWithHoo
|
||||
s (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gre
|
||||
p-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js
|
||||
:3221:22)
|
||||
-updateFunctionComp
|
||||
nent (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-cod
|
||||
e-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve
|
||||
lopment.js:6475:19)
|
||||
-beginWor
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-onl
|
||||
y2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8009:18)
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
-runWithFiberIn
|
||||
EV (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12834:22)
|
||||
-workLoopSyn
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-
|
||||
only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:126
|
||||
44:41)
|
||||
-renderRootSy
|
||||
c (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep
|
||||
-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:1
|
||||
2624:11)
|
||||
-performWorkOnR
|
||||
ot (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12135:44)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
"
|
||||
ERROR config.getUseTerminalBuffer is not a function
|
||||
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
src/ui/components/MainContent.tsx:40:36
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
37: const uiState = useUIState();
|
||||
38: const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
|
||||
39: const config = useConfig();
|
||||
40: const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
41: const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
42:
|
||||
43: const confirmingTool = useConfirmingTool();
|
||||
|
||||
- MainContent (src/ui/components/MainContent.tsx:40:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kil
|
||||
o-code-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-recon
|
||||
ciler.development.js:15859:20)
|
||||
-renderWithHoo
|
||||
s (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gre
|
||||
p-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js
|
||||
:3221:22)
|
||||
-updateFunctionComp
|
||||
nent (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-cod
|
||||
e-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve
|
||||
lopment.js:6475:19)
|
||||
-beginWor
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-onl
|
||||
y2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8009:18)
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
Composer
|
||||
-runWithFiberIn
|
||||
EV (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12834:22)
|
||||
-workLoopSyn
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-
|
||||
only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:126
|
||||
44:41)
|
||||
-renderRootSy
|
||||
c (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep
|
||||
-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:1
|
||||
2624:11)
|
||||
-performWorkOnR
|
||||
ot (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12135:44)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
ERROR config.getUseTerminalBuffer is not a function
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
src/ui/components/MainContent.tsx:40:36
|
||||
|
||||
37: const uiState = useUIState();
|
||||
38: const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
|
||||
39: const config = useConfig();
|
||||
40: const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
41: const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
42:
|
||||
43: const confirmingTool = useConfirmingTool();
|
||||
|
||||
- MainContent (src/ui/components/MainContent.tsx:40:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kil
|
||||
o-code-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-recon
|
||||
ciler.development.js:15859:20)
|
||||
-renderWithHoo
|
||||
s (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gre
|
||||
p-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js
|
||||
:3221:22)
|
||||
-updateFunctionComp
|
||||
nent (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-cod
|
||||
e-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve
|
||||
lopment.js:6475:19)
|
||||
-beginWor
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-onl
|
||||
y2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8009:18)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
DialogManager
|
||||
-runWithFiberIn
|
||||
EV (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12834:22)
|
||||
-workLoopSyn
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-
|
||||
only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:126
|
||||
44:41)
|
||||
-renderRootSy
|
||||
c (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep
|
||||
-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:1
|
||||
2624:11)
|
||||
-performWorkOnR
|
||||
ot (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12135:44)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -130,13 +184,14 @@ HistoryItemDisplay
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
│ ls │
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ Allow execution of [ls]? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
@@ -144,9 +199,8 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="870" viewBox="0 0 920 870">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="870" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="9" y="17" width="63" height="17" fill="#cd0000" />
|
||||
<text x="9" y="19" fill="#e5e5e5" textLength="63" lengthAdjust="spacingAndGlyphs"> ERROR </text>
|
||||
<text x="72" y="19" fill="#ffffff" textLength="828" lengthAdjust="spacingAndGlyphs"> config.getUseTerminalBuffer is not a function </text>
|
||||
<text x="0" y="53" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> src/ui/components/MainContent.tsx:40:36 </text>
|
||||
<text x="0" y="87" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 37: const uiState = useUIState(); </text>
|
||||
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 38: const isAlternateBufferOrTerminalBuffer = useAlternateBuffer(); </text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 39: const config = useConfig(); </text>
|
||||
<rect x="9" y="136" width="558" height="17" fill="#cd0000" />
|
||||
<text x="9" y="138" fill="#e5e5e5" textLength="558" lengthAdjust="spacingAndGlyphs">40: const useTerminalBuffer = config.getUseTerminalBuffer();</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 41: const isAlternateBuffer = config.getUseAlternateBuffer(); </text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 42: </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 43: const confirmingTool = useConfirmingTool(); </text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs"> - </text>
|
||||
<text x="27" y="223" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">MainContent</text>
|
||||
<text x="126" y="223" fill="#7f7f7f" textLength="378" lengthAdjust="spacingAndGlyphs" font-weight="bold"> (src/ui/components/MainContent.tsx:40:36)</text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="240" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs" font-weight="bold">Object.react-stack-bott</text>
|
||||
<text x="18" y="257" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">m-frame</text>
|
||||
<text x="225" y="257" fill="#7f7f7f" textLength="666" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kil</text>
|
||||
<text x="225" y="274" fill="#7f7f7f" textLength="666" lengthAdjust="spacingAndGlyphs">o-code-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-recon</text>
|
||||
<text x="225" y="291" fill="#7f7f7f" textLength="270" lengthAdjust="spacingAndGlyphs">ciler.development.js:15859:20)</text>
|
||||
<text x="0" y="308" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-weight="bold">renderWithHoo</text>
|
||||
<text x="18" y="325" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">s</text>
|
||||
<text x="135" y="325" fill="#7f7f7f" textLength="756" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gre</text>
|
||||
<text x="135" y="342" fill="#7f7f7f" textLength="756" lengthAdjust="spacingAndGlyphs">p-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js</text>
|
||||
<text x="135" y="359" fill="#7f7f7f" textLength="81" lengthAdjust="spacingAndGlyphs">:3221:22)</text>
|
||||
<text x="0" y="376" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="376" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs" font-weight="bold">updateFunctionComp</text>
|
||||
<text x="18" y="393" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">nent</text>
|
||||
<text x="180" y="393" fill="#7f7f7f" textLength="711" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-cod</text>
|
||||
<text x="180" y="410" fill="#7f7f7f" textLength="711" lengthAdjust="spacingAndGlyphs">e-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve</text>
|
||||
<text x="180" y="427" fill="#7f7f7f" textLength="171" lengthAdjust="spacingAndGlyphs">lopment.js:6475:19)</text>
|
||||
<text x="0" y="444" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="444" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">beginWor</text>
|
||||
<text x="90" y="461" fill="#7f7f7f" textLength="801" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-onl</text>
|
||||
<text x="90" y="478" fill="#7f7f7f" textLength="792" lengthAdjust="spacingAndGlyphs">y2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8009:18)</text>
|
||||
<text x="0" y="512" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="512" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs" font-weight="bold">runWithFiberIn</text>
|
||||
<text x="18" y="529" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs" font-weight="bold">EV</text>
|
||||
<text x="144" y="529" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr</text>
|
||||
<text x="144" y="546" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.</text>
|
||||
<text x="144" y="563" fill="#7f7f7f" textLength="99" lengthAdjust="spacingAndGlyphs">js:1738:13)</text>
|
||||
<text x="0" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="580" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs" font-weight="bold">performUnitOfW</text>
|
||||
<text x="18" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs" font-weight="bold">rk</text>
|
||||
<text x="144" y="597" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr</text>
|
||||
<text x="144" y="614" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.</text>
|
||||
<text x="144" y="631" fill="#7f7f7f" textLength="108" lengthAdjust="spacingAndGlyphs">js:12834:22)</text>
|
||||
<text x="0" y="648" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="648" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">workLoopSyn</text>
|
||||
<text x="117" y="665" fill="#7f7f7f" textLength="774" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-</text>
|
||||
<text x="117" y="682" fill="#7f7f7f" textLength="774" lengthAdjust="spacingAndGlyphs">only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:126</text>
|
||||
<text x="117" y="699" fill="#7f7f7f" textLength="54" lengthAdjust="spacingAndGlyphs">44:41)</text>
|
||||
<text x="0" y="716" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="716" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs" font-weight="bold">renderRootSy</text>
|
||||
<text x="18" y="733" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">c</text>
|
||||
<text x="126" y="733" fill="#7f7f7f" textLength="765" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep</text>
|
||||
<text x="126" y="750" fill="#7f7f7f" textLength="765" lengthAdjust="spacingAndGlyphs">-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:1</text>
|
||||
<text x="126" y="767" fill="#7f7f7f" textLength="72" lengthAdjust="spacingAndGlyphs">2624:11)</text>
|
||||
<text x="0" y="784" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"> -</text>
|
||||
<text x="18" y="784" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs" font-weight="bold">performWorkOnR</text>
|
||||
<text x="18" y="801" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs" font-weight="bold">ot</text>
|
||||
<text x="144" y="801" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr</text>
|
||||
<text x="144" y="818" fill="#7f7f7f" textLength="747" lengthAdjust="spacingAndGlyphs">ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.</text>
|
||||
<text x="144" y="835" fill="#7f7f7f" textLength="108" lengthAdjust="spacingAndGlyphs">js:12135:44)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,55 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
"
|
||||
ERROR config.getUseTerminalBuffer is not a function
|
||||
|
||||
src/ui/components/MainContent.tsx:40:36
|
||||
|
||||
37: const uiState = useUIState();
|
||||
38: const isAlternateBufferOrTerminalBuffer = useAlternateBuffer();
|
||||
39: const config = useConfig();
|
||||
40: const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
41: const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
42:
|
||||
43: const confirmingTool = useConfirmingTool();
|
||||
|
||||
- MainContent (src/ui/components/MainContent.tsx:40:36)
|
||||
-Object.react-stack-bott
|
||||
m-frame (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kil
|
||||
o-code-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-recon
|
||||
ciler.development.js:15859:20)
|
||||
-renderWithHoo
|
||||
s (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gre
|
||||
p-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js
|
||||
:3221:22)
|
||||
-updateFunctionComp
|
||||
nent (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-cod
|
||||
e-grep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve
|
||||
lopment.js:6475:19)
|
||||
-beginWor
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-onl
|
||||
y2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8009:18)
|
||||
|
||||
-runWithFiberIn
|
||||
EV (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:1738:13)
|
||||
-performUnitOfW
|
||||
rk (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12834:22)
|
||||
-workLoopSyn
|
||||
(/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep-
|
||||
only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:126
|
||||
44:41)
|
||||
-renderRootSy
|
||||
c (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-grep
|
||||
-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:1
|
||||
2624:11)
|
||||
-performWorkOnR
|
||||
ot (/usr/local/google/home/akkr/Documents/kilo_code_gemini_cli/gemini-cli-kilo-code-gr
|
||||
ep-only2/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.
|
||||
js:12135:44)
|
||||
"
|
||||
`;
|
||||
@@ -254,7 +254,7 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('skips API key dialog on initial setup if env var is present', async () => {
|
||||
it('always shows API key dialog even when env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
@@ -265,12 +265,12 @@ describe('AuthDialog', () => {
|
||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
AuthState.AwaitingApiKeyInput,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('skips API key dialog if env var is present but empty', async () => {
|
||||
it('always shows API key dialog even when env var is empty string', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
||||
// props.settings.merged.security.auth.selectedType is undefined here
|
||||
@@ -281,7 +281,7 @@ describe('AuthDialog', () => {
|
||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
AuthState.AwaitingApiKeyInput,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -302,10 +302,10 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
|
||||
it('always shows API key dialog on re-auth even if env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
// Simulate that the user has already authenticated once
|
||||
// Simulate switching from a different auth method (e.g., Google Login → API key)
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('AuthDialog', () => {
|
||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
AuthState.AwaitingApiKeyInput,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -137,13 +137,11 @@ export function AuthDialog({
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_GEMINI) {
|
||||
if (process.env['GEMINI_API_KEY'] !== undefined) {
|
||||
setAuthState(AuthState.Unauthenticated);
|
||||
return;
|
||||
} else {
|
||||
setAuthState(AuthState.AwaitingApiKeyInput);
|
||||
return;
|
||||
}
|
||||
// Always show the API key input dialog so the user can
|
||||
// explicitly enter or confirm their key, regardless of
|
||||
// whether GEMINI_API_KEY env var or a stored key exists.
|
||||
setAuthState(AuthState.AwaitingApiKeyInput);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setAuthState(AuthState.Unauthenticated);
|
||||
|
||||
@@ -9,7 +9,7 @@ import open from 'open';
|
||||
import path from 'node:path';
|
||||
import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getVersion } from '@google/gemini-cli-core';
|
||||
import { getVersion, type Config } from '@google/gemini-cli-core';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('bugCommand', () => {
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
},
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: {
|
||||
getChat: () => ({
|
||||
getHistory: () => [],
|
||||
@@ -137,7 +138,8 @@ describe('bugCommand', () => {
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp/gemini',
|
||||
},
|
||||
},
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: {
|
||||
getChat: () => ({
|
||||
getHistory: () => history,
|
||||
@@ -182,7 +184,8 @@ describe('bugCommand', () => {
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
|
||||
},
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: {
|
||||
getChat: () => ({
|
||||
getHistory: () => [],
|
||||
|
||||
@@ -16,7 +16,6 @@ import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import {
|
||||
IdeClient,
|
||||
sessionId,
|
||||
getVersion,
|
||||
INITIAL_HISTORY_LENGTH,
|
||||
debugLogger,
|
||||
@@ -59,7 +58,7 @@ export const bugCommand: SlashCommand = {
|
||||
let info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Session ID:** ${sessionId}
|
||||
* **Session ID:** ${config?.getSessionId() || 'Unknown'}
|
||||
* **Operating System:** ${osVersion}
|
||||
* **Sandbox Environment:** ${sandboxEnv}
|
||||
* **Model Version:** ${modelVersion}
|
||||
|
||||
@@ -65,7 +65,7 @@ const getSavedChatTags = async (
|
||||
);
|
||||
|
||||
return chatDetails;
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -75,6 +75,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List saved manual conversation checkpoints',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async (context): Promise<void> => {
|
||||
const chatDetails = await getSavedChatTags(context, false);
|
||||
|
||||
@@ -406,14 +407,24 @@ export const chatResumeSubCommands: SlashCommand[] = [
|
||||
checkpointCompatibilityCommand,
|
||||
];
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export const chatCommand: SlashCommand = {
|
||||
name: 'chat',
|
||||
description: 'Browse auto-saved conversations and manage chat checkpoints',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
}),
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'sessionBrowser',
|
||||
};
|
||||
},
|
||||
subCommands: chatResumeSubCommands,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
flushTelemetry,
|
||||
resetBrowserSession,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
|
||||
// Close persistent browser sessions before resetting chat
|
||||
await resetBrowserSession();
|
||||
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
|
||||
@@ -198,7 +198,7 @@ export const directoryCommand: SlashCommand = {
|
||||
alreadyAdded.push(trimmedPath);
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Path might not exist or be inaccessible.
|
||||
// We'll let batchAddDirectories handle it later.
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const enhanceCommand: SlashCommand = {
|
||||
config.getModel(),
|
||||
config.getGemini31LaunchedSync?.() ?? false,
|
||||
false,
|
||||
false,
|
||||
config.getHasAccessToPreviewModel?.() ?? true,
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -321,7 +321,7 @@ async function exploreAction(
|
||||
});
|
||||
try {
|
||||
await open(extensionsUrl);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`,
|
||||
@@ -789,6 +789,7 @@ const listExtensionsCommand: SlashCommand = {
|
||||
description: 'List active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
@@ -849,6 +850,7 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
description: 'Open extensions page in your browser',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
@@ -870,6 +872,8 @@ const configCommand: SlashCommand = {
|
||||
action: configAction,
|
||||
};
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -883,20 +887,29 @@ export function extensionsCommand(
|
||||
configCommand,
|
||||
]
|
||||
: [];
|
||||
const subCommands = [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
reloadCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
subCommands,
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, subCommands);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
// Default to list if no subcommand is provided
|
||||
listExtensionsCommand.action!(context, args),
|
||||
return listExtensionsCommand.action!(context, args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -280,5 +280,41 @@ describe('mcpCommand', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers by name when an argument is provided to list', async () => {
|
||||
await mcpCommand.action!(mockContext, 'list server1');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: expect.objectContaining({
|
||||
server1: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Should NOT contain server2 or server3
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||
.calls[0][0] as HistoryItemMcpStatus;
|
||||
expect(Object.keys(call.servers)).toEqual(['server1']);
|
||||
});
|
||||
|
||||
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
|
||||
await mcpCommand.action!(mockContext, 'desc server2');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
servers: expect.objectContaining({
|
||||
server2: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock
|
||||
.calls[0][0] as HistoryItemMcpStatus;
|
||||
expect(Object.keys(call.servers)).toEqual(['server2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
canLoadServer,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
@@ -177,6 +178,7 @@ const listAction = async (
|
||||
context: CommandContext,
|
||||
showDescriptions = false,
|
||||
showSchema = false,
|
||||
serverNameFilter?: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
@@ -199,11 +201,25 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const blockedMcpServers =
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() || [];
|
||||
|
||||
if (serverNameFilter) {
|
||||
const filter = serverNameFilter.trim().toLowerCase();
|
||||
if (filter) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(
|
||||
([name]) =>
|
||||
name.toLowerCase().includes(filter) ||
|
||||
normalizeServerId(name).includes(filter),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
@@ -306,7 +322,7 @@ const listCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context),
|
||||
action: (context, args) => listAction(context, false, false, args),
|
||||
};
|
||||
|
||||
const descCommand: SlashCommand = {
|
||||
@@ -315,7 +331,7 @@ const descCommand: SlashCommand = {
|
||||
description: 'List configured MCP servers and tools with descriptions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context, true),
|
||||
action: (context, args) => listAction(context, true, false, args),
|
||||
};
|
||||
|
||||
const schemaCommand: SlashCommand = {
|
||||
@@ -324,7 +340,7 @@ const schemaCommand: SlashCommand = {
|
||||
'List configured MCP servers and tools with descriptions and schemas',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => listAction(context, true, true),
|
||||
action: (context, args) => listAction(context, true, true, args),
|
||||
};
|
||||
|
||||
const reloadCommand: SlashCommand = {
|
||||
@@ -333,6 +349,7 @@ const reloadCommand: SlashCommand = {
|
||||
description: 'Reloads MCP servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
@@ -530,5 +547,18 @@ export const mcpCommand: SlashCommand = {
|
||||
enableCommand,
|
||||
disableCommand,
|
||||
],
|
||||
action: async (context: CommandContext) => listAction(context),
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
// If no subcommand matches, treat the whole args as a filter for list
|
||||
return listAction(context, false, false, args);
|
||||
}
|
||||
return listAction(context);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -457,4 +457,78 @@ describe('memoryCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory inbox', () => {
|
||||
let inboxCommand: SlashCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
inboxCommand = memoryCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'inbox',
|
||||
)!;
|
||||
expect(inboxCommand).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return custom_dialog when config is available and flag is enabled', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockConfig = {
|
||||
reloadSkills: vi.fn(),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
},
|
||||
ui: {
|
||||
removeComponent: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toHaveProperty('type', 'custom_dialog');
|
||||
expect(result).toHaveProperty('component');
|
||||
});
|
||||
|
||||
it('should return info message when memory manager is disabled', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockConfig = {
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when config is not loaded', () => {
|
||||
if (!inboxCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = inboxCommand.action(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
type OpenCustomDialogActionReturn,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -124,5 +127,45 @@ export const memoryCommand: SlashCommand = {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inbox',
|
||||
description:
|
||||
'Review skills extracted from past sessions and move them to global or project skills',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (
|
||||
context,
|
||||
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
|
||||
const config = context.services.agentContext?.config;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.isMemoryManagerEnabled()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -104,6 +104,47 @@ describe('planCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return a submit_prompt action if arguments are empty', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||
).mockReturnValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: '/plan',
|
||||
name: 'plan',
|
||||
args: '',
|
||||
};
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
const result = await planCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(
|
||||
mockContext.services.agentContext!.config.setApprovalMode,
|
||||
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should return a submit_prompt action if arguments are provided', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.agentContext!.config.isPlanEnabled,
|
||||
).mockReturnValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: '/plan implement auth',
|
||||
name: 'plan',
|
||||
args: 'implement auth',
|
||||
};
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
const result = await planCommand.action(mockContext, 'implement auth');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: 'implement auth',
|
||||
});
|
||||
expect(
|
||||
mockContext.services.agentContext!.config.setApprovalMode,
|
||||
).toHaveBeenCalledWith(ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should display the approved plan from config', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(
|
||||
|
||||
@@ -66,6 +66,13 @@ export const planCommand: SlashCommand = {
|
||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||
}
|
||||
|
||||
if (context.invocation?.args) {
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: context.invocation.args,
|
||||
};
|
||||
}
|
||||
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!approvedPlanPath) {
|
||||
@@ -86,12 +93,14 @@ export const planCommand: SlashCommand = {
|
||||
type: MessageType.GEMINI,
|
||||
text: partToString(content.llmContent),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
subCommands: [
|
||||
@@ -100,6 +109,7 @@ export const planCommand: SlashCommand = {
|
||||
description: 'Copy the currently approved plan to your clipboard',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
takesArgs: false,
|
||||
action: copyAction,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -151,7 +151,7 @@ async function completion(
|
||||
const files = await fs.readdir(checkpointDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
return getTruncatedCheckpointNames(jsonFiles);
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('rewindCommand', () => {
|
||||
},
|
||||
config: {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContextManager: () => ({ refresh: mockResetContext }),
|
||||
getMemoryContextManager: () => ({ refresh: mockResetContext }),
|
||||
getProjectRoot: mockGetProjectRoot,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,7 +61,9 @@ async function rewindConversation(
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
|
||||
// Reset context manager as we are rewinding history
|
||||
await context.services.agentContext?.config.getContextManager()?.refresh();
|
||||
await context.services.agentContext?.config
|
||||
.getMemoryContextManager()
|
||||
?.refresh();
|
||||
|
||||
// Update UI History
|
||||
// We generate IDs based on index for the rewind history
|
||||
|
||||
@@ -15,7 +15,6 @@ export const settingsCommand: SlashCommand = {
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
let fileExists = true;
|
||||
try {
|
||||
existingContent = await fs.promises.readFile(gitignorePath, 'utf8');
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
fileExists = false;
|
||||
}
|
||||
@@ -168,8 +168,8 @@ async function downloadFiles({
|
||||
async function createDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
} catch (_error) {
|
||||
debugLogger.debug(`Failed to create ${dirPath} directory:`, _error);
|
||||
} catch (error) {
|
||||
debugLogger.debug(`Failed to create ${dirPath} directory:`, error);
|
||||
throw new Error(
|
||||
`Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`,
|
||||
);
|
||||
@@ -222,8 +222,8 @@ export const setupGithubCommand: SlashCommand = {
|
||||
let gitRepoRoot: string;
|
||||
try {
|
||||
gitRepoRoot = getGitRepoRoot();
|
||||
} catch (_error) {
|
||||
debugLogger.debug(`Failed to get git repo root:`, _error);
|
||||
} catch (error) {
|
||||
debugLogger.debug(`Failed to get git repo root:`, error);
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { shellsCommand } from './shellsCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('shellsCommand', () => {
|
||||
it('should call toggleBackgroundShell', async () => {
|
||||
const toggleBackgroundShell = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundShell,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (shellsCommand.action) {
|
||||
await shellsCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(shellsCommand.name).toBe('shells');
|
||||
expect(shellsCommand.altNames).toContain('bashes');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(shellsCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -528,6 +528,7 @@ describe('skillsCommand', () => {
|
||||
await actionPromise;
|
||||
|
||||
expect(reloadSkillsMock).toHaveBeenCalled();
|
||||
expect(context.ui.reloadCommands).toHaveBeenCalled();
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -285,6 +285,8 @@ async function reloadAction(
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
|
||||
context.ui.reloadCommands();
|
||||
|
||||
const afterSkills = skillManager.getSkills();
|
||||
const afterNames = new Set(afterSkills.map((s) => s.name));
|
||||
|
||||
@@ -357,6 +359,8 @@ function enableCompletion(
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
||||
export const skillsCommand: SlashCommand = {
|
||||
name: 'skills',
|
||||
description:
|
||||
@@ -402,5 +406,13 @@ export const skillsCommand: SlashCommand = {
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
action: async (context, args) => {
|
||||
if (args) {
|
||||
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
|
||||
if (parsed.commandToExecute?.action) {
|
||||
return parsed.commandToExecute.action(context, parsed.args);
|
||||
}
|
||||
}
|
||||
return listAction(context, args);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { tasksCommand } from './tasksCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('tasksCommand', () => {
|
||||
it('should call toggleBackgroundTasks', async () => {
|
||||
const toggleBackgroundTasks = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundTasks,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (tasksCommand.action) {
|
||||
await tasksCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(tasksCommand.name).toBe('tasks');
|
||||
expect(tasksCommand.altNames).toContain('bg');
|
||||
expect(tasksCommand.altNames).toContain('background');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(tasksCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
export const tasksCommand: SlashCommand = {
|
||||
name: 'tasks',
|
||||
altNames: ['bg', 'background'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
description: 'Toggle background tasks view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
context.ui.toggleBackgroundTasks();
|
||||
},
|
||||
};
|
||||
@@ -96,7 +96,7 @@ export interface CommandContext {
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
@@ -246,5 +246,14 @@ export interface SlashCommand {
|
||||
*/
|
||||
showCompletionLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the command expects arguments.
|
||||
* If false, and the command is a subcommand, the command parser may treat
|
||||
* any following text as arguments for the parent command instead of this subcommand,
|
||||
* provided the parent command has an action.
|
||||
* Defaults to true.
|
||||
*/
|
||||
takesArgs?: boolean;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const createAnsiToken = (overrides: Partial<AnsiToken>): AnsiToken => ({
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
isUninitialized: false,
|
||||
fg: '#ffffff',
|
||||
bg: '#000000',
|
||||
...overrides,
|
||||
@@ -156,4 +157,30 @@ describe('<AnsiOutputText />', () => {
|
||||
expect(lastFrame()).toBeDefined();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('robustness', () => {
|
||||
it('does NOT crash when data is undefined', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<AnsiOutputText
|
||||
data={undefined as unknown as AnsiOutput}
|
||||
width={80}
|
||||
disableTruncation={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does NOT crash when data is an object but not an array', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<AnsiOutputText
|
||||
data={{ summary: 'test' } as unknown as AnsiOutput}
|
||||
width={80}
|
||||
disableTruncation={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true }).trim()).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,14 +35,16 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation
|
||||
? data
|
||||
: numLinesRetained === 0
|
||||
? []
|
||||
: data.slice(-numLinesRetained);
|
||||
const lastLines = Array.isArray(data)
|
||||
? disableTruncation
|
||||
? data
|
||||
: numLinesRetained === 0
|
||||
? []
|
||||
: data.slice(-numLinesRetained)
|
||||
: [];
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
{(lastLines as AnsiLine[]).map((line: AnsiLine, lineIndex: number) => (
|
||||
<Box key={lineIndex} height={1} overflow="hidden">
|
||||
<AnsiLineText line={line} />
|
||||
</Box>
|
||||
|
||||
@@ -8,16 +8,22 @@ import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import crypto from 'node:crypto';
|
||||
import { _clearSessionBannersForTest } from '../hooks/useBanner.js';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
beforeEach(() => {
|
||||
_clearSessionBannersForTest();
|
||||
});
|
||||
|
||||
it('should render the banner with default text', async () => {
|
||||
const uiState = {
|
||||
history: [],
|
||||
@@ -264,4 +270,23 @@ describe('<AppHeader />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT render Tips when ui.hideTips is true', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
ui: { hideTips: true },
|
||||
},
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).not.toContain('Tips');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,13 +59,20 @@ const NARROW_TERMINAL_BREAKPOINT = 60;
|
||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
|
||||
const {
|
||||
terminalWidth,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
updateInfo,
|
||||
isConfigInitialized,
|
||||
isAuthenticating,
|
||||
} = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const loggedOut = !authType;
|
||||
const loggedOut = isConfigInitialized && !isAuthenticating && !authType;
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
@@ -108,7 +115,7 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
{updateInfo?.isUpdating && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: true, expectedArrows: true },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
])(
|
||||
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
|
||||
@@ -1409,6 +1409,53 @@ describe('AskUserDialog', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('supports "Other" option for yesno questions', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Is this correct?',
|
||||
header: 'Confirm',
|
||||
type: QuestionType.YESNO,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
// Navigate to "Other" (3rd option: 1. Yes, 2. No, 3. Other)
|
||||
writeKey(stdin, '\x1b[B'); // Down to No
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Enter a custom value');
|
||||
});
|
||||
|
||||
// Type feedback
|
||||
for (const char of 'Yes, but with caveats') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Yes, but with caveats');
|
||||
});
|
||||
|
||||
// Submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Yes, but with caveats' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('expands paste placeholders in multi-select custom option via Done', async () => {
|
||||
@@ -1453,4 +1500,85 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows at least 3 selection options even in small terminal heights', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question:
|
||||
'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.',
|
||||
header: 'Test',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'Option 1', description: 'Description 1' },
|
||||
{ label: 'Option 2', description: 'Description 2' },
|
||||
{ label: 'Option 3', description: 'Description 3' },
|
||||
{ label: 'Option 4', description: 'Description 4' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={12} // Very small height
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show at least 3 options
|
||||
expect(frame).toContain('1. Option 1');
|
||||
expect(frame).toContain('2. Option 2');
|
||||
expect(frame).toContain('3. Option 3');
|
||||
});
|
||||
});
|
||||
|
||||
it('allows the question to exceed 15 lines in a tall terminal', async () => {
|
||||
const longQuestion = Array.from(
|
||||
{ length: 25 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Tall Test',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'Option 1', description: 'D1' },
|
||||
{ label: 'Option 2', description: 'D2' },
|
||||
{ label: 'Option 3', description: 'D3' },
|
||||
],
|
||||
multiSelect: false,
|
||||
unconstrainedHeight: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={40} // Tall terminal
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show more than 15 lines of the question
|
||||
// (The limit was previously 15, so showing Line 20 proves it's working)
|
||||
expect(frame).toContain('Line 20');
|
||||
expect(frame).toContain('Line 25');
|
||||
// Should still show the options
|
||||
expect(frame).toContain('1. Option 1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,8 +511,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const hasAll = question.multiSelect && (question.options?.length ?? 0) > 1;
|
||||
// Calculate total options including 'All' and 'Other' to ensure consistent numbering column width
|
||||
const numOptions = (question.options?.length ?? 0) + (hasAll ? 1 : 0) + 1;
|
||||
const numLen = String(numOptions).length;
|
||||
const radioWidth = 2; // "● "
|
||||
const numberWidth = numLen + 2; // e.g., "1. "
|
||||
@@ -735,17 +736,15 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
list.push({ key: 'all', value: allItem });
|
||||
}
|
||||
|
||||
// Only add custom option for choice type, not yesno
|
||||
if (question.type !== 'yesno') {
|
||||
const otherItem: OptionItem = {
|
||||
key: 'other',
|
||||
label: customOptionText || '',
|
||||
description: '',
|
||||
type: 'other',
|
||||
index: list.length,
|
||||
};
|
||||
list.push({ key: 'other', value: otherItem });
|
||||
}
|
||||
// Add custom option for choice and yesno types
|
||||
const otherItem: OptionItem = {
|
||||
key: 'other',
|
||||
label: customOptionText || '',
|
||||
description: '',
|
||||
type: 'other',
|
||||
index: list.length,
|
||||
};
|
||||
list.push({ key: 'other', value: otherItem });
|
||||
|
||||
if (question.multiSelect) {
|
||||
const doneItem: OptionItem = {
|
||||
@@ -759,7 +758,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [questionOptions, question.multiSelect, question.type, customOptionText]);
|
||||
}, [questionOptions, question.multiSelect, customOptionText]);
|
||||
|
||||
const handleHighlight = useCallback(
|
||||
(itemValue: OptionItem) => {
|
||||
@@ -849,16 +848,24 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
// Reserve space for at least 3 items if more selectionItems available.
|
||||
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
|
||||
const questionHeightLimit =
|
||||
listHeight && !isAlternateBuffer
|
||||
? question.unconstrainedHeight
|
||||
? Math.max(1, listHeight - selectionItems.length * 2)
|
||||
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
|
||||
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
|
||||
: undefined;
|
||||
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeightLimit
|
||||
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
|
||||
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
|
||||
? Math.min(
|
||||
selectionItems.length,
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
|
||||
),
|
||||
)
|
||||
: selectionItems.length;
|
||||
|
||||
return (
|
||||
|
||||
+32
-32
@@ -6,8 +6,8 @@
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
|
||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
|
||||
import { Box } from 'ink';
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockDismissBackgroundTask = vi.fn();
|
||||
const mockSetActiveBackgroundTaskPid = vi.fn();
|
||||
const mockSetIsBackgroundTaskListOpen = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask: mockDismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
data,
|
||||
renderItem,
|
||||
}: {
|
||||
data: BackgroundShell[];
|
||||
data: BackgroundTask[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundShell;
|
||||
item: BackgroundTask;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
{data.map((item: BackgroundTask, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
describe('<BackgroundTaskDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundTask>();
|
||||
const shell1: BackgroundTask = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundShell = {
|
||||
const shell2: BackgroundTask = {
|
||||
pid: 1002,
|
||||
command: 'tail -f log.txt',
|
||||
output: 'Log entry 1',
|
||||
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 100;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { rerender, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
|
||||
await act(async () => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={width}
|
||||
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={width}
|
||||
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundShell = {
|
||||
const exitedShell: BackgroundTask = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
const width = 80;
|
||||
const { lastFrame, unmount } = await render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
<BackgroundTaskDisplay
|
||||
shells={mockShells}
|
||||
activePid={exitedShell.pid}
|
||||
width={width}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user